CLI: use global image version field

- Restructure config by removing CSP-specific image references
- Add global image field
- Download image lookup table on create
- Download QEMU image on QEMU create
This commit is contained in:
Malte Poll 2022-11-22 18:47:08 +01:00 committed by Malte Poll
parent 9222468d3b
commit 575b6e93f6
21 changed files with 1068 additions and 380 deletions

View file

@ -8,9 +8,11 @@ package cloudcmd
import (
"context"
"io"
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
)
type terraformClient interface {
@ -25,3 +27,11 @@ type libvirtRunner interface {
Start(ctx context.Context, containerName, imageName string) error
Stop(ctx context.Context) error
}
type imageFetcher interface {
FetchReference(ctx context.Context, config *config.Config) (string, error)
}
type rawDownloader interface {
Download(ctx context.Context, errWriter io.Writer, isTTY bool, source, version string) (string, error)
}

View file

@ -8,10 +8,12 @@ package cloudcmd
import (
"context"
"io"
"testing"
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"go.uber.org/goleak"
)
@ -72,3 +74,21 @@ func (r *stubLibvirtRunner) Stop(context.Context) error {
r.stopCalled = true
return r.stopErr
}
type stubImageFetcher struct {
reference string
fetchReferenceErr error
}
func (f *stubImageFetcher) FetchReference(_ context.Context, _ *config.Config) (string, error) {
return f.reference, f.fetchReferenceErr
}
type stubRawDownloader struct {
destination string
downloadErr error
}
func (d *stubRawDownloader) Download(_ context.Context, _ io.Writer, _ bool, _ string, _ string) (string, error) {
return d.destination, d.downloadErr
}

View file

@ -22,31 +22,43 @@ import (
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/image"
)
// Creator creates cloud resources.
type Creator struct {
out io.Writer
image imageFetcher
newTerraformClient func(ctx context.Context) (terraformClient, error)
newLibvirtRunner func() libvirtRunner
newRawDownloader func() rawDownloader
}
// NewCreator creates a new creator.
func NewCreator(out io.Writer) *Creator {
return &Creator{
out: out,
out: out,
image: image.New(),
newTerraformClient: func(ctx context.Context) (terraformClient, error) {
return terraform.New(ctx, constants.TerraformWorkingDir)
},
newLibvirtRunner: func() libvirtRunner {
return libvirt.New()
},
newRawDownloader: func() rawDownloader {
return image.NewDownloader()
},
}
}
// Create creates the handed amount of instances and all the needed resources.
func (c *Creator) Create(ctx context.Context, provider cloudprovider.Provider, config *config.Config, name, insType string, controlPlaneCount, workerCount int,
) (clusterid.File, error) {
image, err := c.image.FetchReference(ctx, config)
if err != nil {
return clusterid.File{}, fmt.Errorf("fetching image reference: %w", err)
}
switch provider {
case cloudprovider.AWS:
cl, err := c.newTerraformClient(ctx)
@ -54,21 +66,21 @@ func (c *Creator) Create(ctx context.Context, provider cloudprovider.Provider, c
return clusterid.File{}, err
}
defer cl.RemoveInstaller()
return c.createAWS(ctx, cl, config, name, insType, controlPlaneCount, workerCount)
return c.createAWS(ctx, cl, config, name, insType, controlPlaneCount, workerCount, image)
case cloudprovider.GCP:
cl, err := c.newTerraformClient(ctx)
if err != nil {
return clusterid.File{}, err
}
defer cl.RemoveInstaller()
return c.createGCP(ctx, cl, config, name, insType, controlPlaneCount, workerCount)
return c.createGCP(ctx, cl, config, name, insType, controlPlaneCount, workerCount, image)
case cloudprovider.Azure:
cl, err := c.newTerraformClient(ctx)
if err != nil {
return clusterid.File{}, err
}
defer cl.RemoveInstaller()
return c.createAzure(ctx, cl, config, name, insType, controlPlaneCount, workerCount)
return c.createAzure(ctx, cl, config, name, insType, controlPlaneCount, workerCount, image)
case cloudprovider.QEMU:
if runtime.GOARCH != "amd64" || runtime.GOOS != "linux" {
return clusterid.File{}, fmt.Errorf("creation of a QEMU based Constellation is not supported for %s/%s", runtime.GOOS, runtime.GOARCH)
@ -79,14 +91,14 @@ func (c *Creator) Create(ctx context.Context, provider cloudprovider.Provider, c
}
defer cl.RemoveInstaller()
lv := c.newLibvirtRunner()
return c.createQEMU(ctx, cl, lv, name, config, controlPlaneCount, workerCount)
return c.createQEMU(ctx, cl, lv, name, config, controlPlaneCount, workerCount, image)
default:
return clusterid.File{}, fmt.Errorf("unsupported cloud provider: %s", provider)
}
}
func (c *Creator) createAWS(ctx context.Context, cl terraformClient, config *config.Config,
name, insType string, controlPlaneCount, workerCount int,
name, insType string, controlPlaneCount, workerCount int, image string,
) (idFile clusterid.File, retErr error) {
vars := terraform.AWSVariables{
CommonVariables: terraform.CommonVariables{
@ -99,7 +111,7 @@ func (c *Creator) createAWS(ctx context.Context, cl terraformClient, config *con
Region: config.Provider.AWS.Region,
Zone: config.Provider.AWS.Zone,
InstanceType: insType,
AMIImageID: config.Provider.AWS.Image,
AMIImageID: image,
IAMProfileControlPlane: config.Provider.AWS.IAMProfileControlPlane,
IAMProfileWorkerNodes: config.Provider.AWS.IAMProfileWorkerNodes,
Debug: config.IsDebugCluster(),
@ -122,7 +134,7 @@ func (c *Creator) createAWS(ctx context.Context, cl terraformClient, config *con
}
func (c *Creator) createGCP(ctx context.Context, cl terraformClient, config *config.Config,
name, insType string, controlPlaneCount, workerCount int,
name, insType string, controlPlaneCount, workerCount int, image string,
) (idFile clusterid.File, retErr error) {
vars := terraform.GCPVariables{
CommonVariables: terraform.CommonVariables{
@ -137,7 +149,7 @@ func (c *Creator) createGCP(ctx context.Context, cl terraformClient, config *con
CredentialsFile: config.Provider.GCP.ServiceAccountKeyPath,
InstanceType: insType,
StateDiskType: config.Provider.GCP.StateDiskType,
ImageID: config.Provider.GCP.Image,
ImageID: image,
Debug: config.IsDebugCluster(),
}
@ -158,7 +170,7 @@ func (c *Creator) createGCP(ctx context.Context, cl terraformClient, config *con
}
func (c *Creator) createAzure(ctx context.Context, cl terraformClient, config *config.Config,
name, insType string, controlPlaneCount, workerCount int,
name, insType string, controlPlaneCount, workerCount int, image string,
) (idFile clusterid.File, retErr error) {
vars := terraform.AzureVariables{
CommonVariables: terraform.CommonVariables{
@ -172,7 +184,7 @@ func (c *Creator) createAzure(ctx context.Context, cl terraformClient, config *c
UserAssignedIdentity: config.Provider.Azure.UserAssignedIdentity,
InstanceType: insType,
StateDiskType: config.Provider.Azure.StateDiskType,
ImageID: config.Provider.Azure.Image,
ImageID: image,
ConfidentialVM: *config.Provider.Azure.ConfidentialVM,
SecureBoot: *config.Provider.Azure.SecureBoot,
Debug: config.IsDebugCluster(),
@ -223,11 +235,18 @@ func normalizeAzureURIs(vars terraform.AzureVariables) terraform.AzureVariables
}
func (c *Creator) createQEMU(ctx context.Context, cl terraformClient, lv libvirtRunner, name string, config *config.Config,
controlPlaneCount, workerCount int,
controlPlaneCount, workerCount int, source string,
) (idFile clusterid.File, retErr error) {
qemuRollbacker := &rollbackerQEMU{client: cl, libvirt: lv, createdWorkspace: false}
defer rollbackOnError(context.Background(), c.out, &retErr, qemuRollbacker)
// TODO: render progress bar
downloader := c.newRawDownloader()
imagePath, err := downloader.Download(ctx, c.out, false, source, config.Image)
if err != nil {
return clusterid.File{}, fmt.Errorf("download raw image: %w", err)
}
libvirtURI := config.Provider.QEMU.LibvirtURI
libvirtSocketPath := "."
@ -273,7 +292,7 @@ func (c *Creator) createQEMU(ctx context.Context, cl terraformClient, lv libvirt
},
LibvirtURI: libvirtURI,
LibvirtSocketPath: libvirtSocketPath,
ImagePath: config.Provider.QEMU.Image,
ImagePath: imagePath,
ImageFormat: config.Provider.QEMU.ImageFormat,
CPUCount: config.Provider.QEMU.VCPUs,
MemorySizeMiB: config.Provider.QEMU.Memory,

View file

@ -98,12 +98,20 @@ func TestCreator(t *testing.T) {
creator := &Creator{
out: &bytes.Buffer{},
image: &stubImageFetcher{
reference: "some-image",
},
newTerraformClient: func(ctx context.Context) (terraformClient, error) {
return tc.tfClient, tc.newTfClientErr
},
newLibvirtRunner: func() libvirtRunner {
return tc.libvirt
},
newRawDownloader: func() rawDownloader {
return &stubRawDownloader{
destination: "some-destination",
}
},
}
idFile, err := creator.Create(context.Background(), tc.provider, tc.config, "name", "type", 2, 3)

View file

@ -11,13 +11,13 @@ import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/image"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/spf13/afero"
"github.com/spf13/cobra"
@ -49,10 +49,10 @@ func runConfigFetchMeasurements(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err)
}
return configFetchMeasurements(cmd, rekor, fileHandler, http.DefaultClient)
return configFetchMeasurements(cmd, rekor, fileHandler, http.DefaultClient, image.New())
}
func configFetchMeasurements(cmd *cobra.Command, verifier rekorVerifier, fileHandler file.Handler, client *http.Client) error {
func configFetchMeasurements(cmd *cobra.Command, verifier rekorVerifier, fileHandler file.Handler, client *http.Client, img imageFetcher) error {
flags, err := parseFetchMeasurementsFlags(cmd)
if err != nil {
return err
@ -63,16 +63,17 @@ func configFetchMeasurements(cmd *cobra.Command, verifier rekorVerifier, fileHan
return displayConfigValidationErrors(cmd.ErrOrStderr(), err)
}
if conf.IsDebugImage() {
if !conf.IsReleaseImage() {
cmd.PrintErrln("Configured image doesn't look like a released production image. Double check image before deploying to production.")
}
if err := flags.updateURLs(conf); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := flags.updateURLs(ctx, conf, img); err != nil {
return err
}
var fetchedMeasurements measurements.M
hash, err := fetchedMeasurements.FetchAndVerify(ctx, client, flags.measurementsURL, flags.signatureURL, []byte(constants.CosignPublicKey))
if err != nil {
@ -128,9 +129,15 @@ func parseFetchMeasurementsFlags(cmd *cobra.Command) (*fetchMeasurementsFlags, e
}, nil
}
func (f *fetchMeasurementsFlags) updateURLs(conf *config.Config) error {
func (f *fetchMeasurementsFlags) updateURLs(ctx context.Context, conf *config.Config, img imageFetcher) error {
imageRef, err := img.FetchReference(ctx, conf)
if err != nil {
return err
}
if f.measurementsURL == nil {
parsedURL, err := url.Parse(constants.S3PublicBucket + strings.ToLower(conf.Image()) + "/measurements.yaml")
// TODO(AB#2644): resolve image version to reference
parsedURL, err := url.Parse(constants.S3PublicBucket + imageRef + "/measurements.yaml")
if err != nil {
return err
}
@ -138,7 +145,7 @@ func (f *fetchMeasurementsFlags) updateURLs(conf *config.Config) error {
}
if f.signatureURL == nil {
parsedURL, err := url.Parse(constants.S3PublicBucket + strings.ToLower(conf.Image()) + "/measurements.yaml.sig")
parsedURL, err := url.Parse(constants.S3PublicBucket + imageRef + "/measurements.yaml.sig")
if err != nil {
return err
}
@ -146,3 +153,7 @@ func (f *fetchMeasurementsFlags) updateURLs(conf *config.Config) error {
}
return nil
}
type imageFetcher interface {
FetchReference(ctx context.Context, config *config.Config) (string, error)
}

View file

@ -8,6 +8,7 @@ package cmd
import (
"bytes"
"context"
"errors"
"io"
"net/http"
@ -102,10 +103,9 @@ func TestUpdateURLs(t *testing.T) {
}{
"both values nil": {
conf: &config.Config{
Image: "someImageVersion",
Provider: config.ProviderConfig{
GCP: &config.GCPConfig{
Image: "some/image/path/image-123456",
},
GCP: &config.GCPConfig{},
},
},
flags: &fetchMeasurementsFlags{},
@ -127,7 +127,9 @@ func TestUpdateURLs(t *testing.T) {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := tc.flags.updateURLs(tc.conf)
err := tc.flags.updateURLs(context.Background(), tc.conf, &stubImageFetcher{
reference: "some/image/path/image-123456",
})
assert.NoError(err)
assert.Equal(tc.wantMeasurementsURL, tc.flags.measurementsURL.String())
})
@ -162,14 +164,14 @@ func TestConfigFetchMeasurements(t *testing.T) {
signature := "MEUCIFdJ5dH6HDywxQWTUh9Bw77wMrq0mNCUjMQGYP+6QsVmAiEAmazj/L7rFGA4/Gz8y+kI5h5E5cDgc3brihvXBKF6qZA="
client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.String() == "https://public-edgeless-constellation.s3.us-east-2.amazonaws.com/projects/constellation-images/global/images/constellation-coreos-1658216163/measurements.yaml" {
if req.URL.String() == "https://public-edgeless-constellation.s3.us-east-2.amazonaws.com/someImage/measurements.yaml" {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(measurements)),
Header: make(http.Header),
}
}
if req.URL.String() == "https://public-edgeless-constellation.s3.us-east-2.amazonaws.com/projects/constellation-images/global/images/constellation-coreos-1658216163/measurements.yaml.sig" {
if req.URL.String() == "https://public-edgeless-constellation.s3.us-east-2.amazonaws.com/someImage/measurements.yaml.sig" {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewBufferString(signature)),
@ -213,12 +215,23 @@ func TestConfigFetchMeasurements(t *testing.T) {
fileHandler := file.NewHandler(afero.NewMemMapFs())
gcpConfig := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.GCP)
gcpConfig.Provider.GCP.Image = "projects/constellation-images/global/images/constellation-coreos-1658216163"
gcpConfig.Image = "someImage"
err := fileHandler.WriteYAML(constants.ConfigFilename, gcpConfig, file.OptMkdirAll)
require.NoError(err)
assert.NoError(configFetchMeasurements(cmd, tc.verifier, fileHandler, client))
assert.NoError(configFetchMeasurements(cmd, tc.verifier, fileHandler, client, &stubImageFetcher{
reference: "someImage",
}))
})
}
}
type stubImageFetcher struct {
reference string
fetchReferenceErr error
}
func (f *stubImageFetcher) FetchReference(_ context.Context, _ *config.Config) (string, error) {
return f.reference, f.fetchReferenceErr
}

View file

@ -67,7 +67,7 @@ func create(cmd *cobra.Command, creator cloudCreator, fileHandler file.Handler,
}
var printedAWarning bool
if conf.IsDebugImage() {
if !conf.IsReleaseImage() {
cmd.PrintErrln("Configured image doesn't look like a released production image. Double check image before deploying to production.")
printedAWarning = true
}

View file

@ -388,8 +388,8 @@ func TestAttestation(t *testing.T) {
require.NoError(fileHandler.WriteJSON(constants.ClusterIDsFileName, existingIDFile, file.OptNone))
cfg := config.Default()
cfg.Image = "image"
cfg.RemoveProviderExcept(cloudprovider.QEMU)
cfg.Provider.QEMU.Image = "some/image/location"
cfg.Provider.QEMU.Measurements[0] = measurements.PCRWithAllBytes(0x00)
cfg.Provider.QEMU.Measurements[1] = measurements.PCRWithAllBytes(0x11)
cfg.Provider.QEMU.Measurements[2] = measurements.PCRWithAllBytes(0x22)
@ -463,13 +463,14 @@ func (s *stubInitServer) Init(ctx context.Context, req *initproto.InitRequest) (
func defaultConfigWithExpectedMeasurements(t *testing.T, conf *config.Config, csp cloudprovider.Provider) *config.Config {
t.Helper()
conf.Image = "image"
switch csp {
case cloudprovider.Azure:
conf.Provider.Azure.SubscriptionID = "01234567-0123-0123-0123-0123456789ab"
conf.Provider.Azure.TenantID = "01234567-0123-0123-0123-0123456789ab"
conf.Provider.Azure.Location = "test-location"
conf.Provider.Azure.UserAssignedIdentity = "test-identity"
conf.Provider.Azure.Image = "some/image/location"
conf.Provider.Azure.ResourceGroup = "test-resource-group"
conf.Provider.Azure.AppClientID = "01234567-0123-0123-0123-0123456789ab"
conf.Provider.Azure.ClientSecretValue = "test-client-secret"
@ -479,14 +480,12 @@ func defaultConfigWithExpectedMeasurements(t *testing.T, conf *config.Config, cs
case cloudprovider.GCP:
conf.Provider.GCP.Region = "test-region"
conf.Provider.GCP.Project = "test-project"
conf.Provider.GCP.Image = "some/image/location"
conf.Provider.GCP.Zone = "test-zone"
conf.Provider.GCP.ServiceAccountKeyPath = "test-key-path"
conf.Provider.GCP.Measurements[4] = measurements.PCRWithAllBytes(0x44)
conf.Provider.GCP.Measurements[9] = measurements.PCRWithAllBytes(0x11)
conf.Provider.GCP.Measurements[12] = measurements.PCRWithAllBytes(0xcc)
case cloudprovider.QEMU:
conf.Provider.QEMU.Image = "some/image/location"
conf.Provider.QEMU.Measurements[4] = measurements.PCRWithAllBytes(0x44)
conf.Provider.QEMU.Measurements[9] = measurements.PCRWithAllBytes(0x11)
conf.Provider.QEMU.Measurements[12] = measurements.PCRWithAllBytes(0xcc)

View file

@ -13,7 +13,6 @@ import (
"fmt"
"io"
"net"
"net/http"
"os"
"runtime"
"strings"
@ -26,8 +25,6 @@ import (
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/grpc/dialer"
"github.com/edgelesssys/constellation/v2/internal/license"
"github.com/edgelesssys/constellation/v2/internal/versions"
"github.com/schollz/progressbar/v3"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"golang.org/x/sys/unix"
@ -188,23 +185,9 @@ func prepareConfig(cmd *cobra.Command, fileHandler file.Handler) (*config.Config
}
}
// download image to current directory if it doesn't exist
const imagePath = "./constellation.raw"
if _, err := os.Stat(imagePath); err == nil {
cmd.Printf("Using existing image at %s\n\n", imagePath)
} else if errors.Is(err, os.ErrNotExist) {
cmd.Printf("Downloading image to %s\n", imagePath)
if err := installImage(cmd.Context(), cmd.ErrOrStderr(), versions.ConstellationQEMUImageURL, imagePath); err != nil {
return nil, fmt.Errorf("downloading image to %s: %w", imagePath, err)
}
} else {
return nil, fmt.Errorf("checking if image exists: %w", err)
}
config := config.Default()
config.RemoveProviderExcept(cloudprovider.QEMU)
config.StateDiskSizeGB = 8
config.Provider.QEMU.Image = imagePath
return config, fileHandler.WriteYAML(constants.ConfigFilename, config, file.OptOverwrite)
}
@ -245,51 +228,3 @@ func initializeMiniCluster(cmd *cobra.Command, fileHandler file.Handler, spinner
}
return nil
}
// installImage downloads the image from sourceURL to the destination.
func installImage(ctx context.Context, errWriter io.Writer, sourceURL, destination string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("downloading image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("downloading image: %s", resp.Status)
}
f, err := os.OpenFile(destination, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
bar := progressbar.NewOptions64(
resp.ContentLength,
progressbar.OptionSetWriter(errWriter),
progressbar.OptionShowBytes(true),
progressbar.OptionSetPredictTime(true),
progressbar.OptionFullWidth(),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "=",
SaucerHead: ">",
SaucerPadding: " ",
BarStart: "[",
BarEnd: "]",
}),
progressbar.OptionClearOnFinish(),
progressbar.OptionOnCompletion(func() { fmt.Fprintf(errWriter, "Done.\n\n") }),
)
defer bar.Close()
_, err = io.Copy(io.MultiWriter(f, bar), resp.Body)
if err != nil {
return err
}
return nil
}