/* Copyright (c) Edgeless Systems GmbH SPDX-License-Identifier: AGPL-3.0-only */ package cmd import ( "context" "fmt" "io" "net/http" "net/url" "path" "strings" "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd" "github.com/edgelesssys/constellation/v2/internal/attestation/measurements" "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/file" "github.com/edgelesssys/constellation/v2/internal/sigstore" "github.com/edgelesssys/constellation/v2/internal/versionsapi" "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" ) 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 (omit 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 } patchLister := versionsapi.New() rekor, err := sigstore.NewRekor() if err != nil { return fmt.Errorf("constructing Rekor client: %w", err) } cliVersion := getCurrentCLIVersion() return upgradePlan(cmd, planner, patchLister, fileHandler, http.DefaultClient, rekor, flags, cliVersion) } // upgradePlan plans an upgrade of a Constellation cluster. func upgradePlan(cmd *cobra.Command, planner upgradePlanner, patchLister patchLister, fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags, cliVersion string, ) error { conf, err := config.New(fileHandler, flags.configPath) if err != nil { return displayConfigValidationErrors(cmd.ErrOrStderr(), err) } // get current image version of the cluster csp := conf.GetProvider() version, err := getCurrentImageVersion(cmd.Context(), planner) if err != nil { return fmt.Errorf("checking current image version: %w", err) } // find compatible images // image updates should always be possible for the current minor version of the cluster // (e.g. 0.1.0 -> 0.1.1, 0.1.2, 0.1.3, etc.) // additionally, we allow updates to the next minor version (e.g. 0.1.0 -> 0.2.0) // if the CLI minor version is newer than the cluster minor version currentImageMinorVer := semver.MajorMinor(version) currentCLIMinorVer := semver.MajorMinor(cliVersion) nextImageMinorVer, err := nextMinorVersion(currentImageMinorVer) if err != nil { return fmt.Errorf("calculating next image minor version: %w", err) } var allowedMinorVersions []string cliImageCompare := semver.Compare(currentCLIMinorVer, currentImageMinorVer) switch { case cliImageCompare < 0: cmd.PrintErrln("Warning: CLI version is older than cluster image version. This is not supported.") case cliImageCompare == 0: allowedMinorVersions = []string{currentImageMinorVer} case cliImageCompare > 0: allowedMinorVersions = []string{currentImageMinorVer, nextImageMinorVer} } var updateCandidates []string for _, minorVer := range allowedMinorVersions { versionList, err := patchLister.PatchVersionsOf(cmd.Context(), "stable", minorVer, "image") if err == nil { updateCandidates = append(updateCandidates, versionList.Versions...) } } // filter out versions that are not compatible with the current cluster compatibleImages := getCompatibleImages(version, updateCandidates) // get expected measurements for each image upgrades, err := getCompatibleImageMeasurements(cmd.Context(), cmd, client, rekor, []byte(flags.cosignPubKey), csp, compatibleImages) if err != nil { return fmt.Errorf("fetching measurements for compatible images: %w", err) } if len(upgrades) == 0 { cmd.PrintErrln("No compatible images found to upgrade to.") return nil } // interactive mode if flags.filePath == "" { cmd.Printf("Current version: %s\n", version) return upgradePlanInteractive( &nopWriteCloser{cmd.OutOrStdout()}, io.NopCloser(cmd.InOrStdin()), flags.configPath, conf, fileHandler, upgrades, ) } // write upgrade plan to stdout if flags.filePath == "-" { content, err := encoder.NewEncoder(upgrades).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, upgrades) } // getCompatibleImages trims the list of images to only ones compatible with the current cluster. func getCompatibleImages(currentImageVersion string, images []string) []string { var compatibleImages []string for _, image := range images { // check if image is newer than current version if semver.Compare(image, currentImageVersion) <= 0 { continue } compatibleImages = append(compatibleImages, image) } return compatibleImages } // getCompatibleImageMeasurements retrieves the expected measurements for each image. func getCompatibleImageMeasurements(ctx context.Context, cmd *cobra.Command, client *http.Client, rekor rekorVerifier, pubK []byte, csp cloudprovider.Provider, images []string, ) (map[string]config.UpgradeConfig, error) { upgrades := make(map[string]config.UpgradeConfig) for _, img := range images { measurementsURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/", constants.CDNMeasurementsPath, img, strings.ToLower(csp.String()), "measurements.json")) if err != nil { return nil, err } signatureURL, err := url.Parse(constants.CDNRepositoryURL + path.Join("/", constants.CDNMeasurementsPath, img, strings.ToLower(csp.String()), "measurements.json.sig")) if err != nil { return nil, err } var fetchedMeasurements measurements.M hash, err := fetchedMeasurements.FetchAndVerify( ctx, client, measurementsURL, signatureURL, pubK, measurements.WithMetadata{ CSP: csp, Image: img, }, ) if err != nil { cmd.PrintErrf("Skipping image %q: %s\n", img, err) continue } if err = verifyWithRekor(ctx, rekor, hash); err != nil { cmd.PrintErrf("Warning: Unable to verify '%s' in Rekor.\n", hash) cmd.PrintErrf("Make sure measurements are correct.\n") } upgrades[img] = config.UpgradeConfig{ Image: img, Measurements: fetchedMeasurements, CSP: csp, } } return upgrades, 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) (string, error) { _, imageVersion, err := planner.GetCurrentImage(ctx) if err != nil { return "", err } if !semver.IsValid(imageVersion) { return "", fmt.Errorf("current image version is not a release image version: %q", imageVersion) } return imageVersion, nil } func getCurrentCLIVersion() string { return "v" + constants.VersionInfo } 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, compatibleUpgrades map[string]config.UpgradeConfig, ) error { var imageVersions []string for k := range compatibleUpgrades { 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", compatibleUpgrades[res].Image) fmt.Fprintln(out, "Measurements:") content, err := encoder.NewEncoder(compatibleUpgrades[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 = compatibleUpgrades[res] return fileHandler.WriteYAML(configPath, config, file.OptOverwrite) } func nextMinorVersion(version string) (string, error) { major, minor, _, err := parseCanonicalSemver(version) if err != nil { return "", err } return fmt.Sprintf("v%d.%d", major, minor+1), nil } func parseCanonicalSemver(version string) (major int, minor int, patch int, err error) { version = semver.Canonical(version) // ensure version is in canonical form (vX.Y.Z) num, err := fmt.Sscanf(version, "v%d.%d.%d", &major, &minor, &patch) if err != nil { return 0, 0, 0, fmt.Errorf("parsing version: %w", err) } if num != 3 { return 0, 0, 0, fmt.Errorf("parsing version: expected 3 numbers, got %d", num) } return major, minor, patch, nil } type upgradePlanFlags struct { configPath string filePath string cosignPubKey string } type nopWriteCloser struct { io.Writer } func (c *nopWriteCloser) Close() error { return nil } type upgradePlanner interface { GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) } type patchLister interface { PatchVersionsOf(ctx context.Context, stream, minor, kind string) (*versionsapi.List, error) }