diff --git a/cli/internal/cloudcmd/upgrade.go b/cli/internal/cloudcmd/upgrade.go index 4781d5a9a..d55adea4d 100644 --- a/cli/internal/cloudcmd/upgrade.go +++ b/cli/internal/cloudcmd/upgrade.go @@ -63,6 +63,34 @@ func (u *Upgrader) Upgrade(ctx context.Context, image string, measurements map[u return nil } +// GetCurrentImage returns the currently used image of the cluster. +func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) { + imageStruct, err := u.imageUpdater.getCurrent(ctx, "constellation-coreos") + if err != nil { + return nil, "", err + } + + spec, ok := imageStruct.Object["spec"] + if !ok { + return nil, "", errors.New("image spec missing") + } + retErr := errors.New("invalid image spec") + specMap, ok := spec.(map[string]any) + if !ok { + return nil, "", retErr + } + currentImageDefinition, ok := specMap["image"] + if !ok { + return nil, "", retErr + } + imageDefinition, ok := currentImageDefinition.(string) + if !ok { + return nil, "", retErr + } + + return imageStruct, imageDefinition, nil +} + func (u *Upgrader) updateMeasurements(ctx context.Context, measurements map[uint32][]byte) error { existingConf, err := u.measurementsUpdater.getCurrent(ctx, constants.JoinConfigMap) if err != nil { @@ -107,30 +135,17 @@ func (u *Upgrader) updateMeasurements(ctx context.Context, measurements map[uint } func (u *Upgrader) updateImage(ctx context.Context, image string) error { - currentImage, err := u.imageUpdater.getCurrent(ctx, "constellation-coreos") + currentImage, currentImageDefinition, err := u.GetCurrentImage(ctx) if err != nil { return fmt.Errorf("retrieving current image: %w", err) } - spec, ok := currentImage.Object["spec"] - if !ok { - return errors.New("current image has no spec") - } - specMap, ok := spec.(map[string]interface{}) - if !ok { - return errors.New("current image spec is not a map") - } - currentImageDefinition, ok := specMap["image"] - if !ok { - return errors.New("unable to read current image") - } - if currentImageDefinition == image { fmt.Fprintln(u.writer, "Cluster is already using the chosen image, skipping image upgrade") return nil } - currentImage.Object["spec"].(map[string]interface{})["image"] = image + currentImage.Object["spec"].(map[string]any)["image"] = image if _, err := u.imageUpdater.update(ctx, currentImage); err != nil { return fmt.Errorf("setting new image: %w", err) } diff --git a/cli/internal/cmd/upgrade.go b/cli/internal/cmd/upgrade.go index 381d05f0e..a42101ad2 100644 --- a/cli/internal/cmd/upgrade.go +++ b/cli/internal/cmd/upgrade.go @@ -14,6 +14,7 @@ func NewUpgradeCmd() *cobra.Command { } cmd.AddCommand(newUpgradeExecuteCmd()) + cmd.AddCommand(newUpgradePlanCmd()) return cmd } diff --git a/cli/internal/cmd/upgradeplan.go b/cli/internal/cmd/upgradeplan.go new file mode 100644 index 000000000..2aae32829 --- /dev/null +++ b/cli/internal/cmd/upgradeplan.go @@ -0,0 +1,304 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/edgelesssys/constellation/cli/internal/cloudcmd" + "github.com/edgelesssys/constellation/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/internal/config" + "github.com/edgelesssys/constellation/internal/constants" + "github.com/edgelesssys/constellation/internal/file" + "github.com/manifoldco/promptui" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/talos-systems/talos/pkg/machinery/config/encoder" + "golang.org/x/mod/semver" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const imageReleaseURL = "https://github.com/edgelesssys/constellation/releases/latest/download/image-manifest.json" + +var ( + azureCVMRxp = regexp.MustCompile(`^\/CommunityGalleries\/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df\/Images\/constellation\/Versions\/[\d]+.[\d]+.[\d]+$`) + gcpCVMRxp = regexp.MustCompile(`^projects\/constellation-images\/global\/images\/constellation-(v[\d]+-[\d]+-[\d]+)$`) +) + +func newUpgradePlanCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "plan", + Short: "Plan an upgrade of a Constellation cluster", + Long: "Plan an upgrade of a Constellation cluster by fetching compatible image versions and their measurements.", + Args: cobra.NoArgs, + RunE: runUpgradePlan, + } + + cmd.Flags().StringP("file", "f", "", "path to output file, or '-' for stdout, leave empty for interactive mode") + + return cmd +} + +func runUpgradePlan(cmd *cobra.Command, args []string) error { + fileHandler := file.NewHandler(afero.NewOsFs()) + flags, err := parseUpgradePlanFlags(cmd) + if err != nil { + return err + } + planner, err := cloudcmd.NewUpgrader(cmd.OutOrStdout()) + if err != nil { + return err + } + + return upgradePlan(cmd, planner, fileHandler, http.DefaultClient, flags) +} + +// upgradePlan plans an upgrade of a Constellation cluster. +func upgradePlan(cmd *cobra.Command, planner upgradePlanner, + fileHandler file.Handler, client *http.Client, flags upgradePlanFlags, +) error { + config, err := config.FromFile(fileHandler, flags.configPath) + if err != nil { + return err + } + + // get current image version of the cluster + csp := config.GetProvider() + + version, err := getCurrentImageVersion(cmd.Context(), planner, csp) + if err != nil { + return fmt.Errorf("checking current image version: %w", err) + } + + // fetch images definitions from GitHub and filter to only compatible images + images, err := fetchImages(cmd.Context(), client) + if err != nil { + return fmt.Errorf("fetching available images: %w", err) + } + compatibleImages := getCompatibleImages(csp, version, images) + if len(compatibleImages) == 0 { + cmd.Println("No compatible images found to upgrade to.") + return nil + } + + // get expected measurements for each image + if err := getCompatibleImageMeasurements(cmd.Context(), client, []byte(flags.cosignPubKey), compatibleImages); err != nil { + return fmt.Errorf("fetching measurements for compatible images: %w", err) + } + + // interactive mode + if flags.filePath == "" { + fmt.Fprintf(cmd.OutOrStdout(), "Current version: %s\n", version) + return upgradePlanInteractive( + &nopWriteCloser{cmd.OutOrStdout()}, + io.NopCloser(cmd.InOrStdin()), + flags.configPath, config, fileHandler, + compatibleImages, + ) + } + + // write upgrade plan to stdout + if flags.filePath == "-" { + content, err := encoder.NewEncoder(compatibleImages).Encode() + if err != nil { + return fmt.Errorf("encoding compatible images: %w", err) + } + _, err = cmd.OutOrStdout().Write(content) + return err + } + + // write upgrade plan to file + return fileHandler.WriteYAML(flags.filePath, compatibleImages) +} + +// fetchImages retrieves a list of the latest Constellation node images from GitHub. +func fetchImages(ctx context.Context, client *http.Client) (map[string]imageManifest, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageReleaseURL, http.NoBody) + if err != nil { + return nil, err + } + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) + } + + imagesJSON, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + images := make(map[string]imageManifest) + if err := json.Unmarshal(imagesJSON, &images); err != nil { + return nil, err + } + + return images, nil +} + +// getCompatibleImages trims the list of images to only ones compatible with the current cluster. +func getCompatibleImages(csp cloudprovider.Provider, currentVersion string, images map[string]imageManifest) map[string]config.UpgradeConfig { + compatibleImages := make(map[string]config.UpgradeConfig) + + switch csp { + case cloudprovider.Azure: + for imgVersion, image := range images { + if semver.Compare(currentVersion, imgVersion) < 0 { + compatibleImages[imgVersion] = config.UpgradeConfig{Image: image.AzureImage} + } + } + + case cloudprovider.GCP: + for imgVersion, image := range images { + if semver.Compare(currentVersion, imgVersion) < 0 { + compatibleImages[imgVersion] = config.UpgradeConfig{Image: image.GCPImage} + } + } + } + + return compatibleImages +} + +// getCompatibleImageMeasurements retrieves the expected measurements for each image. +func getCompatibleImageMeasurements(ctx context.Context, client *http.Client, pubK []byte, images map[string]config.UpgradeConfig) error { + for idx, img := range images { + measurementsURL, err := url.Parse(constants.S3PublicBucket + img.Image + "/measurements.yaml") + if err != nil { + return err + } + + signatureURL, err := url.Parse(constants.S3PublicBucket + img.Image + "/measurements.yaml.sig") + if err != nil { + return err + } + + if err := img.Measurements.FetchAndVerify(ctx, client, measurementsURL, signatureURL, pubK); err != nil { + return err + } + images[idx] = img + } + + return nil +} + +// getCurrentImageVersion retrieves the semantic version of the image currently installed in the cluster. +// If the cluster is not using a release image, an error is returned. +func getCurrentImageVersion(ctx context.Context, planner upgradePlanner, csp cloudprovider.Provider) (string, error) { + _, image, err := planner.GetCurrentImage(ctx) + if err != nil { + return "", err + } + + var version string + switch csp { + case cloudprovider.Azure: + if !azureCVMRxp.MatchString(image) { + return "", fmt.Errorf("image %q does not look like a released production image for Azure", image) + } + versionRxp := regexp.MustCompile(`[\d]+.[\d]+.[\d]+$`) + version = "v" + versionRxp.FindString(image) + case cloudprovider.GCP: + gcpVersion := gcpCVMRxp.FindStringSubmatch(image) + if len(gcpVersion) != 2 { + return "", fmt.Errorf("image %q does not look like a released production image for GCP", image) + } + version = strings.ReplaceAll(gcpVersion[1], "-", ".") + default: + return "", fmt.Errorf("unsupported cloud provider: %s", csp.String()) + } + + if !semver.IsValid(version) { + return "", fmt.Errorf("image %q has no valid semantic version", image) + } + return version, nil +} + +func parseUpgradePlanFlags(cmd *cobra.Command) (upgradePlanFlags, error) { + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return upgradePlanFlags{}, err + } + filePath, err := cmd.Flags().GetString("file") + if err != nil { + return upgradePlanFlags{}, err + } + + return upgradePlanFlags{ + configPath: configPath, + filePath: filePath, + cosignPubKey: constants.CosignPublicKey, + }, nil +} + +func upgradePlanInteractive(out io.WriteCloser, in io.ReadCloser, + configPath string, config *config.Config, fileHandler file.Handler, + compatibleImages map[string]config.UpgradeConfig, +) error { + var imageVersions []string + for k := range compatibleImages { + imageVersions = append(imageVersions, k) + } + semver.Sort(imageVersions) + + prompt := promptui.Select{ + Label: "Select an image version to upgrade to", + Items: imageVersions, + Searcher: func(input string, index int) bool { + version := imageVersions[index] + trimmedVersion := strings.TrimPrefix(strings.Replace(version, ".", "", -1), "v") + input = strings.TrimPrefix(strings.Replace(input, ".", "", -1), "v") + return strings.Contains(trimmedVersion, input) + }, + Size: 10, + Stdin: in, + Stdout: out, + } + + _, res, err := prompt.Run() + if err != nil { + return err + } + + fmt.Fprintln(out, "Updating config to the following:") + + fmt.Fprintf(out, "Image: %s\n", compatibleImages[res].Image) + fmt.Fprintln(out, "Measurements:") + content, err := encoder.NewEncoder(compatibleImages[res].Measurements).Encode() + if err != nil { + return fmt.Errorf("encoding measurements: %w", err) + } + measurements := strings.TrimSuffix(strings.Replace("\t"+string(content), "\n", "\n\t", -1), "\n\t") + fmt.Fprintln(out, measurements) + + config.Upgrade = compatibleImages[res] + return fileHandler.WriteYAML(configPath, config, file.OptOverwrite) +} + +type upgradePlanFlags struct { + configPath string + filePath string + cosignPubKey string +} + +type imageManifest struct { + AzureImage string `json:"AzureCoreOSImage"` + GCPImage string `json:"GCPCoreOSImage"` +} + +type nopWriteCloser struct { + io.Writer +} + +func (c *nopWriteCloser) Close() error { return nil } + +type upgradePlanner interface { + GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) +} diff --git a/cli/internal/cmd/upgradeplan_test.go b/cli/internal/cmd/upgradeplan_test.go new file mode 100644 index 000000000..68e1552d4 --- /dev/null +++ b/cli/internal/cmd/upgradeplan_test.go @@ -0,0 +1,477 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/edgelesssys/constellation/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/internal/config" + "github.com/edgelesssys/constellation/internal/constants" + "github.com/edgelesssys/constellation/internal/file" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" + "gopkg.in/yaml.v2" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func TestGetCurrentImageVersion(t *testing.T) { + testCases := map[string]struct { + stubUpgradePlanner stubUpgradePlanner + csp cloudprovider.Provider + wantErr bool + }{ + "valid Azure": { + stubUpgradePlanner: stubUpgradePlanner{ + image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0", + }, + csp: cloudprovider.Azure, + }, + "invalid Azure": { + stubUpgradePlanner: stubUpgradePlanner{ + image: "/CommunityGalleries/someone-else/Images/constellation/Versions/0.0.1", + }, + csp: cloudprovider.Azure, + wantErr: true, + }, + "valid GCP": { + stubUpgradePlanner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v0-0-0", + }, + csp: cloudprovider.GCP, + }, + "invalid GCP": { + stubUpgradePlanner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-debug-image", + }, + csp: cloudprovider.GCP, + wantErr: true, + }, + "invalid CSP": { + stubUpgradePlanner: stubUpgradePlanner{ + image: "some-image", + }, + csp: cloudprovider.Unknown, + wantErr: true, + }, + "GetCurrentImage error": { + stubUpgradePlanner: stubUpgradePlanner{ + err: errors.New("error"), + }, + csp: cloudprovider.Azure, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradePlanner, tc.csp) + if tc.wantErr { + assert.Error(err) + return + } + + assert.NoError(err) + assert.True(semver.IsValid(version)) + }) + } +} + +type stubUpgradePlanner struct { + image string + err error +} + +func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) { + return nil, u.image, u.err +} + +func TestFetchImages(t *testing.T) { + testImages := map[string]imageManifest{ + "v0.0.0": { + AzureImage: "azure-v0.0.0", + GCPImage: "gcp-v0.0.0", + }, + "v999.999.999": { + AzureImage: "azure-v999.999.999", + GCPImage: "gcp-v999.999.999", + }, + } + + testCases := map[string]struct { + client *http.Client + wantErr bool + }{ + "success": { + client: newTestClient(func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))), + Header: make(http.Header), + } + }), + }, + "error": { + client: newTestClient(func(req *http.Request) *http.Response { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBuffer([]byte{})), + Header: make(http.Header), + } + }), + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + images, err := fetchImages(context.Background(), tc.client) + if tc.wantErr { + assert.Error(err) + return + } + + assert.NoError(err) + assert.NotNil(images) + }) + } +} + +func TestGetCompatibleImages(t *testing.T) { + imageList := map[string]imageManifest{ + "v0.0.0": { + AzureImage: "azure-v0.0.0", + GCPImage: "gcp-v0.0.0", + }, + "v1.0.0": { + AzureImage: "azure-v1.0.0", + GCPImage: "gcp-v1.0.0", + }, + "v1.0.1": { + AzureImage: "azure-v1.0.1", + GCPImage: "gcp-v1.0.1", + }, + "v1.0.2": { + AzureImage: "azure-v1.0.2", + GCPImage: "gcp-v1.0.2", + }, + "v1.1.0": { + AzureImage: "azure-v1.1.0", + GCPImage: "gcp-v1.1.0", + }, + } + + testCases := map[string]struct { + images map[string]imageManifest + csp cloudprovider.Provider + version string + wantImages map[string]config.UpgradeConfig + }{ + "azure": { + images: imageList, + csp: cloudprovider.Azure, + version: "v1.0.0", + wantImages: map[string]config.UpgradeConfig{ + "v1.0.1": { + Image: "azure-v1.0.1", + }, + "v1.0.2": { + Image: "azure-v1.0.2", + }, + "v1.1.0": { + Image: "azure-v1.1.0", + }, + }, + }, + "gcp": { + images: imageList, + csp: cloudprovider.GCP, + version: "v1.0.0", + wantImages: map[string]config.UpgradeConfig{ + "v1.0.1": { + Image: "gcp-v1.0.1", + }, + "v1.0.2": { + Image: "gcp-v1.0.2", + }, + "v1.1.0": { + Image: "gcp-v1.1.0", + }, + }, + }, + "no compatible images": { + images: imageList, + csp: cloudprovider.Azure, + version: "v999.999.999", + wantImages: map[string]config.UpgradeConfig{}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + compatibleImages := getCompatibleImages(tc.csp, tc.version, tc.images) + assert.Equal(tc.wantImages, compatibleImages) + }) + } +} + +func TestGetCompatibleImageMeasurements(t *testing.T) { + assert := assert.New(t) + + testImages := map[string]config.UpgradeConfig{ + "v0.0.0": { + Image: "azure-v0.0.0", + }, + "v1.0.0": { + Image: "azure-v1.0.0", + }, + } + + client := newTestClient(func(req *http.Request) *http.Response { + if strings.HasSuffix(req.URL.String(), "/measurements.yaml") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")), + Header: make(http.Header), + } + } + if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")), + Header: make(http.Header), + } + } + + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("Not found.")), + Header: make(http.Header), + } + }) + + pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----") + + err := getCompatibleImageMeasurements(context.Background(), client, pubK, testImages) + assert.NoError(err) + + for _, image := range testImages { + assert.NotEmpty(image.Measurements) + } +} + +func TestUpgradePlan(t *testing.T) { + testImages := map[string]imageManifest{ + "v1.0.0": { + AzureImage: "azure-v1.0.0", + GCPImage: "gcp-v1.0.0", + }, + "v2.0.0": { + AzureImage: "azure-v2.0.0", + GCPImage: "gcp-v2.0.0", + }, + } + + testCases := map[string]struct { + planner stubUpgradePlanner + flags upgradePlanFlags + csp cloudprovider.Provider + imageFetchStatus int + measurementsFetchStatus int + wantUpgrade bool + wantErr bool + }{ + "no compatible images": { + planner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v999-999-999", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantUpgrade: false, + }, + "upgrades gcp": { + planner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v1-0-0", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantUpgrade: true, + }, + "upgrades azure": { + planner: stubUpgradePlanner{ + image: "/CommunityGalleries/ConstellationCVM-b3782fa0-0df7-4f2f-963e-fc7fc42663df/Images/constellation/Versions/0.0.0", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.Azure, + wantUpgrade: true, + }, + "upgrade to stdout": { + planner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v1-0-0", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "-", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantUpgrade: true, + }, + "current image not valid": { + planner: stubUpgradePlanner{ + image: "not-valid", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantErr: true, + }, + "image fetch error": { + planner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v1-0-0", + }, + imageFetchStatus: http.StatusInternalServerError, + measurementsFetchStatus: http.StatusOK, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantErr: true, + }, + "measurements fetch error": { + planner: stubUpgradePlanner{ + image: "projects/constellation-images/global/images/constellation-v1-0-0", + }, + imageFetchStatus: http.StatusOK, + measurementsFetchStatus: http.StatusInternalServerError, + flags: upgradePlanFlags{ + configPath: constants.ConfigFilename, + filePath: "upgrade-plan.yaml", + cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----", + }, + csp: cloudprovider.GCP, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + fileHandler := file.NewHandler(afero.NewMemMapFs()) + cfg := config.Default() + cfg.RemoveProviderExcept(tc.csp) + require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg)) + + cmd := newUpgradePlanCmd() + cmd.SetContext(context.Background()) + var out bytes.Buffer + cmd.SetOut(&out) + + client := newTestClient(func(req *http.Request) *http.Response { + if req.URL.String() == imageReleaseURL { + return &http.Response{ + StatusCode: tc.imageFetchStatus, + Body: io.NopCloser(bytes.NewBuffer(mustMarshal(t, testImages))), + Header: make(http.Header), + } + } + if strings.HasSuffix(req.URL.String(), "/measurements.yaml") { + return &http.Response{ + StatusCode: tc.measurementsFetchStatus, + Body: io.NopCloser(strings.NewReader("0: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")), + Header: make(http.Header), + } + } + if strings.HasSuffix(req.URL.String(), "/measurements.yaml.sig") { + return &http.Response{ + StatusCode: tc.measurementsFetchStatus, + Body: io.NopCloser(strings.NewReader("MEUCIBs1g2/n0FsgPfJ+0uLD5TaunGhxwDcQcUGBroejKvg3AiEAzZtcLU9O6IiVhxB8tBS+ty6MXoPNwL8WRWMzyr35eKI=")), + Header: make(http.Header), + } + } + + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("Not found.")), + Header: make(http.Header), + } + }) + + err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.flags) + if tc.wantErr { + assert.Error(err) + return + } + + assert.NoError(err) + if !tc.wantUpgrade { + assert.Contains(out.String(), "No compatible images") + return + } + + var availableUpgrades map[string]config.UpgradeConfig + if tc.flags.filePath == "-" { + require.NoError(yaml.Unmarshal(out.Bytes(), &availableUpgrades)) + } else { + require.NoError(fileHandler.ReadYAMLStrict(tc.flags.filePath, &availableUpgrades)) + } + + assert.GreaterOrEqual(len(availableUpgrades), 1) + for _, upgrade := range availableUpgrades { + assert.NotEmpty(upgrade.Image) + assert.NotEmpty(upgrade.Measurements) + } + }) + } +} + +func mustMarshal(t *testing.T, v interface{}) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("failed to marshal: %s", err) + } + return b +} diff --git a/go.mod b/go.mod index d9d6831c9..acbf20292 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,7 @@ require ( github.com/googleapis/gax-go/v2 v2.4.0 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 github.com/hashicorp/go-multierror v1.1.1 + github.com/manifoldco/promptui v0.9.0 github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6 github.com/microsoft/ApplicationInsights-Go v0.4.4 github.com/schollz/progressbar/v3 v3.8.6 @@ -84,6 +85,7 @@ require ( go.uber.org/multierr v1.8.0 go.uber.org/zap v1.21.0 golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e + golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 google.golang.org/api v0.86.0 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f google.golang.org/grpc v1.48.0 @@ -128,6 +130,7 @@ require ( github.com/blang/semver/v4 v4.0.0 // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cyphar/filepath-securejoin v0.2.3 // indirect github.com/dnaeon/go-vcr v1.2.0 // indirect github.com/docker/cli v20.10.17+incompatible // indirect diff --git a/go.sum b/go.sum index 4698e7425..f176d7f15 100644 --- a/go.sum +++ b/go.sum @@ -366,8 +366,11 @@ github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1 github.com/charithe/durationcheck v0.0.9/go.mod h1:SSbRIBVfMjCi/kEB6K65XEA83D6prSM8ap1UCpNKtgg= github.com/chavacava/garif v0.0.0-20210405164556-e8a0a408d6af/go.mod h1:Qjyv4H3//PWVzTeCezG2b9IRn6myJxJSr4TD/xo6ojU= github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cilium/ebpf v0.4.0/go.mod h1:4tRaxcgiL706VnOzHOdBlY8IEAIdxINsQBcU4xJJXRs= github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= @@ -1061,6 +1064,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/malt3/go-attestation v0.0.0-20220816131639-92b6394e4e0e h1:WXYRe8U97J11rpmUAZgQtlQbkrrk5S+sIMK197RKJkU= github.com/malt3/go-attestation v0.0.0-20220816131639-92b6394e4e0e/go.mod h1:kA3RhI4h6nMuXW85izOMUNfDza/Yyd4tzRFiCHTkZbw= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/maratori/testpackage v1.0.1/go.mod h1:ddKdw+XG0Phzhx8BFDTKgpWP4i7MpApTE5fXSKAqwDU= github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= @@ -1731,6 +1736,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/hack/go.mod b/hack/go.mod index a34e5d63f..88fb65b91 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -121,6 +121,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.16.7 // indirect github.com/aws/smithy-go v1.11.3 // indirect github.com/benbjohnson/clock v1.3.0 // indirect + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect @@ -157,6 +158,7 @@ require ( github.com/leodido/go-urn v1.2.1 // indirect github.com/letsencrypt/boulder v0.0.0-20220331220046-b23ab962616e // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/manifoldco/promptui v0.9.0 // indirect github.com/matryer/is v1.4.0 // indirect github.com/microsoft/ApplicationInsights-Go v0.4.4 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect diff --git a/hack/go.sum b/hack/go.sum index adf1a06ea..f90017589 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -304,8 +304,11 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= @@ -807,6 +810,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= diff --git a/internal/config/config.go b/internal/config/config.go index 6e210000a..004879860 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -376,6 +376,20 @@ func (c *Config) IsImageDebug() bool { } } +// GetProvider returns the configured cloud provider. +func (c *Config) GetProvider() cloudprovider.Provider { + if c.Provider.Azure != nil { + return cloudprovider.Azure + } + if c.Provider.GCP != nil { + return cloudprovider.GCP + } + if c.Provider.QEMU != nil { + return cloudprovider.QEMU + } + return cloudprovider.Unknown +} + // IsAzureNonCVM checks whether the chosen provider is azure and confidential VMs are disabled. func (c *Config) IsAzureNonCVM() bool { return c.Provider.Azure != nil && c.Provider.Azure.ConfidentialVM != nil && !*c.Provider.Azure.ConfidentialVM diff --git a/internal/config/measurements.go b/internal/config/measurements.go index ebcd170c4..ff7fa41d8 100644 --- a/internal/config/measurements.go +++ b/internal/config/measurements.go @@ -41,11 +41,11 @@ var ( func (m *Measurements) FetchAndVerify(ctx context.Context, client *http.Client, measurementsURL *url.URL, signatureURL *url.URL, publicKey []byte) error { measurements, err := getFromURL(ctx, client, measurementsURL) if err != nil { - return err + return fmt.Errorf("failed to fetch measurements: %w", err) } signature, err := getFromURL(ctx, client, signatureURL) if err != nil { - return err + return fmt.Errorf("failed to fetch signature: %w", err) } if err := sigstore.VerifySignature(measurements, signature, publicKey); err != nil { return err