From c275464634b7cefd63e5a048a7a3e63aaf972c4d Mon Sep 17 00:00:00 2001 From: Otto Bittner Date: Tue, 31 Jan 2023 12:12:19 +0100 Subject: [PATCH] cli: change upgrade-plan to upgrade-check Upgrade check is used to find updates for the current cluster. Optionally the found upgrades can be persisted to the config for consumption by the upgrade-execute cmd. The old `upgrade execute` in this commit does not work with the new `upgrade plan`. The current versions are read from the cluster. Supported versions are read from the cli and the versionsapi. Adds a new config field MicroserviceVersion that will be used by `upgrade execute` to update the service versions. The field is optional until 2.7 A deprecation warning for the upgrade key is printed during config validation. Kubernetes versions now specify the patch version to make it explicit for users if an upgrade changes the k8s version. --- .../actions/constellation_create/action.yml | 3 + cli/internal/cloudcmd/upgrade.go | 24 +- cli/internal/cmd/init_test.go | 2 +- cli/internal/cmd/upgrade.go | 6 +- cli/internal/cmd/upgradecheck.go | 535 ++++++++++++++++++ cli/internal/cmd/upgradecheck_test.go | 304 ++++++++++ cli/internal/cmd/upgradeplan.go | 366 ------------ cli/internal/cmd/upgradeplan_test.go | 498 ---------------- cli/internal/cmd/verify.go | 8 +- cli/internal/helm/client.go | 11 + cli/internal/helm/loader.go | 11 + go.mod | 3 - go.sum | 6 - hack/go.mod | 2 - hack/go.sum | 6 - internal/compatibility/compatibility.go | 30 +- internal/compatibility/compatibility_test.go | 59 +- internal/config/config.go | 34 +- internal/config/config_doc.go | 37 +- internal/config/config_test.go | 4 +- internal/config/validation.go | 15 +- internal/versions/versions.go | 22 +- internal/versionsapi/cli/add.go | 20 +- internal/versionsapi/cli/latest.go | 6 +- internal/versionsapi/cli/list.go | 12 +- internal/versionsapi/cli/rm.go | 36 +- internal/versionsapi/client/client.go | 12 +- 27 files changed, 1080 insertions(+), 992 deletions(-) create mode 100644 cli/internal/cmd/upgradecheck.go create mode 100644 cli/internal/cmd/upgradecheck_test.go delete mode 100644 cli/internal/cmd/upgradeplan.go delete mode 100644 cli/internal/cmd/upgradeplan_test.go diff --git a/.github/actions/constellation_create/action.yml b/.github/actions/constellation_create/action.yml index 17f57a270..49bb547df 100644 --- a/.github/actions/constellation_create/action.yml +++ b/.github/actions/constellation_create/action.yml @@ -149,6 +149,9 @@ runs: run: | yq eval -i '(.debugCluster) = true' constellation-conf.yaml + # Uses --force flag since the CLI currently does not have a pre-release version and is always on the latest released version. + # However, many of our pipelines work on prerelease images. Thus the used images are newer than the CLI's version. + # This makes the version validation in the CLI fail. - name: Constellation create shell: bash run: | diff --git a/cli/internal/cloudcmd/upgrade.go b/cli/internal/cloudcmd/upgrade.go index 6770c28f4..7db01f762 100644 --- a/cli/internal/cloudcmd/upgrade.go +++ b/cli/internal/cloudcmd/upgrade.go @@ -82,30 +82,40 @@ func (u *Upgrader) Upgrade(ctx context.Context, imageReference, imageVersion str // GetCurrentImage returns the currently used image version of the cluster. func (u *Upgrader) GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) { - imageStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-version") + return u.getFromConstellationVersion(ctx, "imageVersion") +} + +// GetCurrentKubernetesVersion returns the currently used Kubernetes version. +func (u *Upgrader) GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error) { + return u.getFromConstellationVersion(ctx, "kubernetesClusterVersion") +} + +// getFromConstellationVersion queries the constellation-version object for a given field. +func (u *Upgrader) getFromConstellationVersion(ctx context.Context, fieldName string) (*unstructured.Unstructured, string, error) { + versionStruct, err := u.dynamicInterface.getCurrent(ctx, "constellation-version") if err != nil { return nil, "", err } - spec, ok := imageStruct.Object["spec"] + spec, ok := versionStruct.Object["spec"] if !ok { - return nil, "", errors.New("image spec missing") + return nil, "", errors.New("spec missing") } - retErr := errors.New("invalid image spec") + retErr := errors.New("invalid spec") specMap, ok := spec.(map[string]any) if !ok { return nil, "", retErr } - currentImageVersion, ok := specMap["imageVersion"] + fieldValue, ok := specMap[fieldName] if !ok { return nil, "", retErr } - imageVersion, ok := currentImageVersion.(string) + fieldValueString, ok := fieldValue.(string) if !ok { return nil, "", retErr } - return imageStruct, imageVersion, nil + return versionStruct, fieldValueString, nil } // UpgradeHelmServices upgrade helm services. diff --git a/cli/internal/cmd/init_test.go b/cli/internal/cmd/init_test.go index fb33acefe..90dfce565 100644 --- a/cli/internal/cmd/init_test.go +++ b/cli/internal/cmd/init_test.go @@ -478,7 +478,7 @@ 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" + conf.Image = constants.VersionInfo switch csp { case cloudprovider.Azure: diff --git a/cli/internal/cmd/upgrade.go b/cli/internal/cmd/upgrade.go index 14cd39208..4323250cc 100644 --- a/cli/internal/cmd/upgrade.go +++ b/cli/internal/cmd/upgrade.go @@ -14,12 +14,12 @@ import ( func NewUpgradeCmd() *cobra.Command { cmd := &cobra.Command{ Use: "upgrade", - Short: "Plan and perform an upgrade of a Constellation cluster", - Long: "Plan and perform an upgrade of a Constellation cluster.", + Short: "Find and execute upgrades to your Constellation cluster", + Long: "Find and execute upgrades to your Constellation cluster.", Args: cobra.ExactArgs(0), } - cmd.AddCommand(newUpgradePlanCmd()) + cmd.AddCommand(newUpgradeCheckCmd()) cmd.AddCommand(newUpgradeExecuteCmd()) return cmd diff --git a/cli/internal/cmd/upgradecheck.go b/cli/internal/cmd/upgradecheck.go new file mode 100644 index 000000000..f44dab466 --- /dev/null +++ b/cli/internal/cmd/upgradecheck.go @@ -0,0 +1,535 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "sort" + "strings" + + "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd" + "github.com/edgelesssys/constellation/v2/cli/internal/helm" + "github.com/edgelesssys/constellation/v2/internal/attestation/measurements" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/compatibility" + "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/kubernetes/kubectl" + "github.com/edgelesssys/constellation/v2/internal/sigstore" + "github.com/edgelesssys/constellation/v2/internal/versions" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" + "github.com/edgelesssys/constellation/v2/internal/versionsapi/fetcher" + "github.com/siderolabs/talos/pkg/machinery/config/encoder" + "github.com/spf13/afero" + "github.com/spf13/cobra" + "golang.org/x/mod/semver" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newUpgradeCheckCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "check", + Short: "Check for possible upgrades.", + Long: "Check which upgrades can be applied to your Constellation Cluster.", + Args: cobra.NoArgs, + RunE: runUpgradeCheck, + } + + cmd.Flags().BoolP("write-config", "w", false, "Update the specified config file with the suggested versions") + cmd.Flags().String("ref", versionsapi.ReleaseRef, "Specify the reference used when querying the versionsapi for new versions.") + cmd.Flags().String("stream", "stable", "Specify the stream used when querying the versionsapi for new versions.") + + return cmd +} + +func runUpgradeCheck(cmd *cobra.Command, args []string) error { + log, err := newCLILogger(cmd) + if err != nil { + return fmt.Errorf("creating logger: %w", err) + } + defer log.Sync() + fileHandler := file.NewHandler(afero.NewOsFs()) + flags, err := parseUpgradeCheckFlags(cmd) + if err != nil { + return err + } + checker, err := cloudcmd.NewUpgrader(cmd.OutOrStdout(), log) + if err != nil { + return err + } + versionListFetcher := fetcher.NewFetcher() + rekor, err := sigstore.NewRekor() + if err != nil { + return fmt.Errorf("constructing Rekor client: %w", err) + } + up := &upgradeCheckCmd{ + collect: &versionCollector{ + writer: cmd.OutOrStderr(), + checker: checker, + verListFetcher: versionListFetcher, + fileHandler: fileHandler, + client: http.DefaultClient, + rekor: rekor, + flags: flags, + cliVersion: constants.VersionInfo, + }, + log: log, + } + + return up.upgradeCheck(cmd, fileHandler, flags) +} + +func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) { + configPath, err := cmd.Flags().GetString("config") + if err != nil { + return upgradeCheckFlags{}, err + } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return upgradeCheckFlags{}, err + } + writeConfig, err := cmd.Flags().GetBool("write-config") + if err != nil { + return upgradeCheckFlags{}, err + } + ref, err := cmd.Flags().GetString("ref") + if err != nil { + return upgradeCheckFlags{}, err + } + stream, err := cmd.Flags().GetString("stream") + if err != nil { + return upgradeCheckFlags{}, err + } + return upgradeCheckFlags{ + configPath: configPath, + force: force, + writeConfig: writeConfig, + ref: ref, + stream: stream, + cosignPubKey: constants.CosignPublicKey, + }, nil +} + +type upgradeCheckCmd struct { + collect collector + log debugLog +} + +// upgradePlan plans an upgrade of a Constellation cluster. +func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fileHandler file.Handler, flags upgradeCheckFlags) error { + conf, err := config.New(fileHandler, flags.configPath, flags.force) + if err != nil { + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) + } + u.log.Debugf("Read configuration from %q", flags.configPath) + // get current image version of the cluster + csp := conf.GetProvider() + u.log.Debugf("Using provider %s", csp.String()) + + currentServices, currentImage, currentK8s, err := u.collect.currentVersions(cmd.Context()) + if err != nil { + return err + } + + supportedServices, supportedImages, supportedK8s, err := u.collect.supportedVersions(cmd.Context(), currentImage, csp) + if err != nil { + return err + } + u.log.Debugf("Current service version: %s", currentServices) + u.log.Debugf("Supported service version: %s", supportedServices) + u.log.Debugf("Current k8s version: %s", currentK8s) + u.log.Debugf("Supported k8s version: %s", supportedK8s) + + // Filter versions to only include upgrades + newServices := supportedServices + if err := compatibility.IsValidUpgrade(currentServices, supportedServices); err != nil { + newServices = "" + } + + newKubernetes := filterK8sUpgrades(currentK8s, supportedK8s) + sort.Strings(newKubernetes) + + supportedImages = filterImageUpgrades(currentImage, supportedImages) + newImages, err := u.collect.newMeasurementes(cmd.Context(), csp, supportedImages) + if err != nil { + return err + } + + upgrade := versionUpgrade{ + newServices: newServices, + newImages: newImages, + newKubernetes: newKubernetes, + currentServices: currentServices, + currentImage: currentImage, + currentKubernetes: currentK8s, + } + + updateMsg, err := upgrade.buildString() + if err != nil { + return err + } + // Using Print over Println as buildString already includes a trailing newline where necessary. + cmd.Print(updateMsg) + + if flags.writeConfig { + if err := upgrade.writeConfig(conf, fileHandler, flags.configPath); err != nil { + return fmt.Errorf("writing config: %w", err) + } + cmd.Println("Wrote config successfully.") + } + + return nil +} + +func sortedMapKeys[T any](a map[string]T) []string { + keys := []string{} + for k := range a { + keys = append(keys, k) + } + sort.Strings(keys) + + return keys +} + +func filterImageUpgrades(currentVersion string, newVersions []versionsapi.Version) []versionsapi.Version { + newImages := []versionsapi.Version{} + for i := range newVersions { + if err := compatibility.IsValidUpgrade(currentVersion, newVersions[i].Version); err != nil { + continue + } + newImages = append(newImages, newVersions[i]) + } + return newImages +} + +func filterK8sUpgrades(currentVersion string, newVersions []string) []string { + result := []string{} + for i := range newVersions { + if err := compatibility.IsValidUpgrade(currentVersion, newVersions[i]); err != nil { + continue + } + result = append(result, newVersions[i]) + } + + return result +} + +type collector interface { + currentVersions(ctx context.Context) (serviceVersions string, imageVersion string, k8sVersion string, err error) + supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersions string, imageVersions []versionsapi.Version, k8sVersions []string, err error) + newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error) + newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error) + newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error) +} + +type versionCollector struct { + writer io.Writer + checker upgradeChecker + verListFetcher versionListFetcher + fileHandler file.Handler + client *http.Client + rekor rekorVerifier + flags upgradeCheckFlags + cliVersion string + log debugLog +} + +func (v *versionCollector) newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error) { + // get expected measurements for each image + upgrades, err := getCompatibleImageMeasurements(ctx, v.writer, v.client, v.rekor, []byte(v.flags.cosignPubKey), csp, images, v.log) + if err != nil { + return nil, fmt.Errorf("fetching measurements for compatible images: %w", err) + } + v.log.Debugf("Compatible image measurements are %v", upgrades) + + return upgrades, nil +} + +func (v *versionCollector) currentVersions(ctx context.Context) (serviceVersion string, imageVersion string, k8sVersion string, err error) { + helmClient, err := helm.NewClient(kubectl.New(), constants.AdminConfFilename, constants.HelmNamespace, v.log) + if err != nil { + return "", "", "", fmt.Errorf("setting up helm client: %w", err) + } + + serviceVersion, err = helmClient.Versions() + if err != nil { + return "", "", "", fmt.Errorf("getting service versions: %w", err) + } + + imageVersion, err = getCurrentImageVersion(ctx, v.checker) + if err != nil { + return "", "", "", fmt.Errorf("getting image version: %w", err) + } + + k8sVersion, err = getCurrentKubernetesVersion(ctx, v.checker) + if err != nil { + return "", "", "", fmt.Errorf("getting image version: %w", err) + } + + return serviceVersion, imageVersion, k8sVersion, nil +} + +// supportedVersions returns slices of supported versions. +func (v *versionCollector) supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersion string, imageVersions []versionsapi.Version, k8sVersions []string, err error) { + k8sVersions = versions.SupportedK8sVersions() + serviceVersion, err = helm.AvailableServiceVersions() + if err != nil { + return "", nil, nil, fmt.Errorf("loading service versions: %w", err) + } + imageVersions, err = v.newImages(ctx, version, csp) + if err != nil { + return "", nil, nil, fmt.Errorf("loading image versions: %w", err) + } + + return serviceVersion, imageVersions, k8sVersions, nil +} + +func (v *versionCollector) newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error) { + // 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(v.cliVersion) + nextImageMinorVer, err := compatibility.NextMinorVersion(currentImageMinorVer) + if err != nil { + return nil, fmt.Errorf("calculating next image minor version: %w", err) + } + v.log.Debugf("Current image minor version is %s", currentImageMinorVer) + v.log.Debugf("Current CLI minor version is %s", currentCLIMinorVer) + v.log.Debugf("Next image minor version is %s", nextImageMinorVer) + + allowedMinorVersions := []string{currentImageMinorVer, nextImageMinorVer} + switch cliImageCompare := semver.Compare(currentCLIMinorVer, currentImageMinorVer); { + case cliImageCompare < 0: + if !v.flags.force { + return nil, fmt.Errorf("cluster image version (%s) newer than CLI version (%s)", currentImageMinorVer, currentCLIMinorVer) + } + if _, err := fmt.Fprintf(v.writer, "WARNING: CLI version is older than cluster image version. Continuing due to force flag."); err != nil { + return nil, fmt.Errorf("writing to buffer: %w", err) + } + case cliImageCompare == 0: + allowedMinorVersions = []string{currentImageMinorVer} + case cliImageCompare > 0: + allowedMinorVersions = []string{currentImageMinorVer, nextImageMinorVer} + } + v.log.Debugf("Allowed minor versions are %#v", allowedMinorVersions) + + newerImages, err := v.newerVersions(ctx, currentImageMinorVer, allowedMinorVersions) + if err != nil { + return nil, fmt.Errorf("newer versions: %w", err) + } + + return newerImages, nil +} + +func (v *versionCollector) newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error) { + var updateCandidates []versionsapi.Version + for _, minorVer := range allowedVersions { + patchList := versionsapi.List{ + Ref: v.flags.ref, + Stream: v.flags.stream, + Base: minorVer, + Granularity: versionsapi.GranularityMinor, + Kind: versionsapi.VersionKindImage, + } + patchList, err := v.verListFetcher.FetchVersionList(ctx, patchList) + var notFound *fetcher.NotFoundError + if errors.As(err, ¬Found) { + v.log.Debugf("Skipping version: %s", err) + continue + } + if err != nil { + return nil, fmt.Errorf("fetching version list: %w", err) + } + updateCandidates = append(updateCandidates, patchList.StructuredVersions()...) + } + v.log.Debugf("Update candidates are %v", updateCandidates) + + return updateCandidates, nil +} + +type versionUpgrade struct { + newServices string + newImages map[string]measurements.M + newKubernetes []string + currentServices string + currentImage string + currentKubernetes string +} + +func (v *versionUpgrade) buildString() (string, error) { + upgradeMsg := strings.Builder{} + + if len(v.newKubernetes) > 0 { + upgradeMsg.WriteString(fmt.Sprintf(" Kubernetes: %s --> %s\n", v.currentKubernetes, strings.Join(v.newKubernetes, " "))) + } + + if len(v.newImages) > 0 { + imageMsgs := strings.Builder{} + newImagesSorted := sortedMapKeys(v.newImages) + for i, image := range newImagesSorted { + // prevent trailing newlines + if i > 0 { + imageMsgs.WriteString("\n") + } + content, err := encoder.NewEncoder(v.newImages[image]).Encode() + contentFormated := strings.ReplaceAll(string(content), "\n", "\n ") + if err != nil { + return "", fmt.Errorf("marshalling measurements: %w", err) + } + imageMsgs.WriteString(fmt.Sprintf(" %s --> %s\n Includes these measurements:\n %s", v.currentImage, image, contentFormated)) + } + upgradeMsg.WriteString(" Images:\n") + upgradeMsg.WriteString(imageMsgs.String()) + fmt.Fprintln(&upgradeMsg, "") + } + + if v.newServices != "" { + upgradeMsg.WriteString(fmt.Sprintf(" Services: %s --> %s\n", v.currentServices, v.newServices)) + } + + result := strings.Builder{} + if upgradeMsg.Len() > 0 { + result.WriteString("The following updates are available with this CLI:\n") + result.WriteString(upgradeMsg.String()) + return result.String(), nil + } + + result.WriteString("No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n") + + return result.String(), nil +} + +func (v *versionUpgrade) writeConfig(conf *config.Config, fileHandler file.Handler, configPath string) error { + // can't sort image map because maps are unsorted. services is only one string, k8s versions are sorted. + + if v.newServices != "" { + conf.MicroserviceVersion = v.newServices + } + if len(v.newServices) > 0 { + conf.KubernetesVersion = v.newKubernetes[0] + } + if len(v.newImages) > 0 { + imageUpgrade := sortedMapKeys(v.newImages)[0] + conf.Image = imageUpgrade + conf.UpdateMeasurements(v.newImages[imageUpgrade]) + } + + if err := fileHandler.WriteYAML(configPath, conf, file.OptOverwrite); err != nil { + return err + } + + 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, checker upgradeChecker) (string, error) { + _, imageVersion, err := checker.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 +} + +// getCurrentKubernetesVersion retrieves the semantic version of Kubernetes currently installed in the cluster. +func getCurrentKubernetesVersion(ctx context.Context, checker upgradeChecker) (string, error) { + _, k8sVersion, err := checker.GetCurrentKubernetesVersion(ctx) + if err != nil { + return "", err + } + + if !semver.IsValid(k8sVersion) { + return "", fmt.Errorf("current kubernetes version is not a valid semver string: %q", k8sVersion) + } + + return k8sVersion, nil +} + +// getCompatibleImageMeasurements retrieves the expected measurements for each image. +func getCompatibleImageMeasurements(ctx context.Context, writer io.Writer, client *http.Client, rekor rekorVerifier, pubK []byte, + csp cloudprovider.Provider, versions []versionsapi.Version, log debugLog, +) (map[string]measurements.M, error) { + upgrades := make(map[string]measurements.M) + for _, version := range versions { + log.Debugf("Fetching measurements for image: %s", version) + shortPath := version.ShortPath() + measurementsURL, err := measurementURL(csp, shortPath, "measurements.json") + if err != nil { + return nil, err + } + + signatureURL, err := measurementURL(csp, shortPath, "measurements.json.sig") + if err != nil { + return nil, err + } + + var fetchedMeasurements measurements.M + log.Debugf("Fetching for measurement url: %s", measurementsURL) + hash, err := fetchedMeasurements.FetchAndVerify( + ctx, client, + measurementsURL, + signatureURL, + pubK, + measurements.WithMetadata{ + CSP: csp, + Image: shortPath, + }, + ) + if err != nil { + if _, err := fmt.Fprintf(writer, "Skipping compatible image %q: %s\n", shortPath, err); err != nil { + return nil, fmt.Errorf("writing to buffer: %w", err) + } + continue + } + + if err = verifyWithRekor(ctx, rekor, hash); err != nil { + if _, err := fmt.Fprintf(writer, "Warning: Unable to verify '%s' in Rekor.\n", hash); err != nil { + return nil, fmt.Errorf("writing to buffer: %w", err) + } + if _, err := fmt.Fprintf(writer, "Make sure measurements are correct.\n"); err != nil { + return nil, fmt.Errorf("writing to buffer: %w", err) + } + } + + upgrades[shortPath] = fetchedMeasurements + + } + + return upgrades, nil +} + +type upgradeCheckFlags struct { + configPath string + force bool + writeConfig bool + ref string + stream string + cosignPubKey string +} + +type upgradeChecker interface { + GetCurrentImage(ctx context.Context) (*unstructured.Unstructured, string, error) + GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error) +} + +type versionListFetcher interface { + FetchVersionList(ctx context.Context, list versionsapi.List) (versionsapi.List, error) +} diff --git a/cli/internal/cmd/upgradecheck_test.go b/cli/internal/cmd/upgradecheck_test.go new file mode 100644 index 000000000..11ad43954 --- /dev/null +++ b/cli/internal/cmd/upgradecheck_test.go @@ -0,0 +1,304 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cmd + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "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/logger" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/mod/semver" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// TestBuildString checks that the resulting user output is as expected. Slow part is the Sscanf in parseCanonicalSemver(). +func TestBuildString(t *testing.T) { + testCases := map[string]struct { + upgrade versionUpgrade + expected string + wantError bool + }{ + "update everything": { + upgrade: versionUpgrade{ + newServices: "v2.5.0", + newImages: map[string]measurements.M{ + "v2.5.0": measurements.DefaultsFor(cloudprovider.QEMU), + }, + newKubernetes: []string{"v1.24.12", "v1.25.6"}, + currentServices: "v2.4.0", + currentImage: "v2.4.0", + currentKubernetes: "v1.24.5", + }, + expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n Images:\n v2.4.0 --> v2.5.0\n Includes these measurements:\n 4:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 8:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 9:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 11:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 12:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 13:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 15:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n \n Services: v2.4.0 --> v2.5.0\n", + }, + "no upgrades": { + upgrade: versionUpgrade{ + newServices: "", + newImages: map[string]measurements.M{}, + newKubernetes: []string{}, + currentServices: "v2.5.0", + currentImage: "v2.5.0", + currentKubernetes: "v1.25.6", + }, + expected: "No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n", + }, + "no upgrades #2": { + upgrade: versionUpgrade{}, + expected: "No upgrades available with this CLI.\nNewer versions may be available at: https://github.com/edgelesssys/constellation/releases\n", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + result, err := tc.upgrade.buildString() + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + assert.Equal(tc.expected, result) + }) + } +} + +func TestGetCurrentImageVersion(t *testing.T) { + testCases := map[string]struct { + stubUpgradeChecker stubUpgradeChecker + wantErr bool + }{ + "valid version": { + stubUpgradeChecker: stubUpgradeChecker{ + image: "v1.0.0", + }, + }, + "invalid version": { + stubUpgradeChecker: stubUpgradeChecker{ + image: "invalid", + }, + wantErr: true, + }, + "GetCurrentImage error": { + stubUpgradeChecker: stubUpgradeChecker{ + err: errors.New("error"), + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + version, err := getCurrentImageVersion(context.Background(), tc.stubUpgradeChecker) + if tc.wantErr { + assert.Error(err) + return + } + + assert.NoError(err) + assert.True(semver.IsValid(version)) + }) + } +} + +func TestGetCompatibleImageMeasurements(t *testing.T) { + assert := assert.New(t) + + csp := cloudprovider.Azure + zero := versionsapi.Version{ + Ref: "-", + Stream: "stable", + Version: "v0.0.0", + Kind: versionsapi.VersionKindImage, + } + one := versionsapi.Version{ + Ref: "-", + Stream: "stable", + Version: "v1.0.0", + Kind: versionsapi.VersionKindImage, + } + images := []versionsapi.Version{zero, one} + + client := newTestClient(func(req *http.Request) *http.Response { + if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v0.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), + Header: make(http.Header), + } + } + if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json.sig") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("MEQCIGRR7RaSMs892Ta06/Tz7LqPUxI05X4wQcP+nFFmZtmaAiBNl9X8mUKmUBfxg13LQBfmmpw6JwYQor5hOwM3NFVPAg==")), + Header: make(http.Header), + } + } + + if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), + Header: make(http.Header), + } + } + if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json.sig") { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("MEQCIFh8CVELp/Da2U2Jt404OXsUeDfqtrf3pqGRuvxnxhI8AiBTHF9tHEPwFedYG3Jgn2ELOxss+Ybc6135vEtClBrbpg==")), + 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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----") + + upgrades, err := getCompatibleImageMeasurements(context.Background(), &bytes.Buffer{}, client, singleUUIDVerifier(), pubK, csp, images, logger.NewTest(t)) + assert.NoError(err) + + for _, measurement := range upgrades { + assert.NotEmpty(measurement) + } +} + +func TestUpgradeCheck(t *testing.T) { + v2_3 := versionsapi.Version{ + Ref: "-", + Stream: "stable", + Version: "v2.3.0", + Kind: versionsapi.VersionKindImage, + } + v2_5 := versionsapi.Version{ + Ref: "-", + Stream: "stable", + Version: "v2.5.0", + Kind: versionsapi.VersionKindImage, + } + testCases := map[string]struct { + collector stubVersionCollector + flags upgradeCheckFlags + csp cloudprovider.Provider + cliVersion string + wantError bool + }{ + "upgrades gcp": { + collector: stubVersionCollector{ + supportedServicesVersions: "v2.5.0", + supportedImages: []versionsapi.Version{v2_3}, + supportedImageVersions: map[string]measurements.M{ + "v2.3.0": measurements.DefaultsFor(cloudprovider.QEMU), + }, + supportedK8sVersions: []string{"v1.24.5", "v1.24.12", "v1.25.6"}, + currentServicesVersions: "v2.4.0", + currentImageVersion: "v2.4.0", + currentK8sVersion: "v1.24.5", + images: []versionsapi.Version{v2_5}, + newCLIVersions: []string{"v2.5.0", "v2.6.0"}, + }, + flags: upgradeCheckFlags{ + configPath: constants.ConfigFilename, + }, + csp: cloudprovider.GCP, + cliVersion: "v1.0.0", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + constants.VersionInfo = "v0.0.0" + fileHandler := file.NewHandler(afero.NewMemMapFs()) + cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), tc.csp) + require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg)) + + checkCmd := upgradeCheckCmd{ + collect: &tc.collector, + log: logger.NewTest(t), + } + + cmd := newUpgradeCheckCmd() + + err := checkCmd.upgradeCheck(cmd, fileHandler, tc.flags) + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +} + +type stubVersionCollector struct { + supportedServicesVersions string + supportedImages []versionsapi.Version + supportedImageVersions map[string]measurements.M + supportedK8sVersions []string + currentServicesVersions string + currentImageVersion string + currentK8sVersion string + images []versionsapi.Version + newCLIVersions []string + someErr error +} + +func (s *stubVersionCollector) newMeasurementes(ctx context.Context, csp cloudprovider.Provider, images []versionsapi.Version) (map[string]measurements.M, error) { + return s.supportedImageVersions, nil +} + +func (s *stubVersionCollector) currentVersions(ctx context.Context) (serviceVersions string, imageVersion string, k8sVersion string, err error) { + return s.currentServicesVersions, s.currentImageVersion, s.currentK8sVersion, s.someErr +} + +func (s *stubVersionCollector) supportedVersions(ctx context.Context, version string, csp cloudprovider.Provider) (serviceVersions string, imageVersions []versionsapi.Version, k8sVersions []string, err error) { + return s.supportedServicesVersions, s.supportedImages, s.supportedK8sVersions, s.someErr +} + +func (s *stubVersionCollector) newImages(ctx context.Context, version string, csp cloudprovider.Provider) ([]versionsapi.Version, error) { + return s.images, nil +} + +func (s *stubVersionCollector) newerVersions(ctx context.Context, currentVersion string, allowedVersions []string) ([]versionsapi.Version, error) { + return s.images, nil +} + +type stubUpgradeChecker struct { + image string + k8sVersion string + err error +} + +func (u stubUpgradeChecker) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) { + return nil, u.image, u.err +} + +func (u stubUpgradeChecker) GetCurrentKubernetesVersion(ctx context.Context) (*unstructured.Unstructured, string, error) { + return nil, u.k8sVersion, u.err +} diff --git a/cli/internal/cmd/upgradeplan.go b/cli/internal/cmd/upgradeplan.go deleted file mode 100644 index 79e33a6df..000000000 --- a/cli/internal/cmd/upgradeplan.go +++ /dev/null @@ -1,366 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package cmd - -import ( - "context" - "fmt" - "io" - "net/http" - "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/edgelesssys/constellation/v2/internal/versionsapi/fetcher" - "github.com/manifoldco/promptui" - "github.com/siderolabs/talos/pkg/machinery/config/encoder" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "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 -} - -type upgradePlanCmd struct { - log debugLog -} - -func runUpgradePlan(cmd *cobra.Command, args []string) error { - log, err := newCLILogger(cmd) - if err != nil { - return fmt.Errorf("creating logger: %w", err) - } - defer log.Sync() - fileHandler := file.NewHandler(afero.NewOsFs()) - flags, err := parseUpgradePlanFlags(cmd) - if err != nil { - return err - } - planner, err := cloudcmd.NewUpgrader(cmd.OutOrStdout(), log) - if err != nil { - return err - } - versionListFetcher := fetcher.NewFetcher() - rekor, err := sigstore.NewRekor() - if err != nil { - return fmt.Errorf("constructing Rekor client: %w", err) - } - cliVersion := getCurrentCLIVersion() - up := &upgradePlanCmd{log: log} - - return up.upgradePlan(cmd, planner, versionListFetcher, fileHandler, http.DefaultClient, rekor, flags, cliVersion) -} - -// upgradePlan plans an upgrade of a Constellation cluster. -func (up *upgradePlanCmd) upgradePlan(cmd *cobra.Command, planner upgradePlanner, verListFetcher versionListFetcher, - fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags, - cliVersion string, -) error { - conf, err := config.New(fileHandler, flags.configPath, true) - if err != nil { - return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) - } - up.log.Debugf("Read configuration from %q", flags.configPath) - // get current image version of the cluster - csp := conf.GetProvider() - up.log.Debugf("Using provider %s", csp.String()) - - version, err := getCurrentImageVersion(cmd.Context(), planner) - if err != nil { - return fmt.Errorf("checking current image version: %w", err) - } - up.log.Debugf("Using image version %s", version) - - // 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) - } - up.log.Debugf("Current image minor version is %s", currentImageMinorVer) - up.log.Debugf("Current CLI minor version is %s", currentCLIMinorVer) - up.log.Debugf("Next image minor version is %s", nextImageMinorVer) - 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} - } - up.log.Debugf("Allowed minor versions are %#v", allowedMinorVersions) - - var updateCandidates []string - for _, minorVer := range allowedMinorVersions { - patchList := versionsapi.List{ - Ref: versionsapi.ReleaseRef, - Stream: "stable", - Base: minorVer, - Granularity: versionsapi.GranularityMinor, - Kind: versionsapi.VersionKindImage, - } - patchList, err = verListFetcher.FetchVersionList(cmd.Context(), patchList) - if err == nil { - updateCandidates = append(updateCandidates, patchList.Versions...) - } - } - up.log.Debugf("Update candidates are %v", updateCandidates) - - // filter out versions that are not compatible with the current cluster - compatibleImages := getCompatibleImages(version, updateCandidates) - up.log.Debugf("Of those images, these ones are compatible %v", compatibleImages) - - // 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) - } - up.log.Debugf("Compatible image measurements are %v", upgrades) - - if len(upgrades) == 0 { - cmd.PrintErrln("No compatible images found to upgrade to.") - return nil - } - - // interactive mode - if flags.filePath == "" { - up.log.Debugf("Writing upgrade plan in interactive mode") - 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 == "-" { - up.log.Debugf("Writing upgrade plan to stdout") - 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 - up.log.Debugf("Writing 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 := measurementURL(csp, img, "measurements.json") - if err != nil { - return nil, err - } - - signatureURL, err := measurementURL(csp, img, "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 versionListFetcher interface { - FetchVersionList(ctx context.Context, list versionsapi.List) (versionsapi.List, error) -} diff --git a/cli/internal/cmd/upgradeplan_test.go b/cli/internal/cmd/upgradeplan_test.go deleted file mode 100644 index 08f42c908..000000000 --- a/cli/internal/cmd/upgradeplan_test.go +++ /dev/null @@ -1,498 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package cmd - -import ( - "bytes" - "context" - "errors" - "io" - "net/http" - "strings" - "testing" - - "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/logger" - "github.com/edgelesssys/constellation/v2/internal/versionsapi" - "github.com/spf13/afero" - "github.com/spf13/cobra" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/mod/semver" - "gopkg.in/yaml.v3" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" -) - -func TestGetCurrentImageVersion(t *testing.T) { - testCases := map[string]struct { - stubUpgradePlanner stubUpgradePlanner - wantErr bool - }{ - "valid version": { - stubUpgradePlanner: stubUpgradePlanner{ - image: "v1.0.0", - }, - }, - "invalid version": { - stubUpgradePlanner: stubUpgradePlanner{ - image: "invalid", - }, - wantErr: true, - }, - "GetCurrentImage error": { - stubUpgradePlanner: stubUpgradePlanner{ - err: errors.New("error"), - }, - 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) - if tc.wantErr { - assert.Error(err) - return - } - - assert.NoError(err) - assert.True(semver.IsValid(version)) - }) - } -} - -func TestGetCompatibleImages(t *testing.T) { - imageList := []string{ - "v0.0.0", - "v1.0.0", - "v1.0.1", - "v1.0.2", - "v1.1.0", - } - - testCases := map[string]struct { - images []string - version string - wantImages []string - }{ - "filters <= v1.0.0": { - images: imageList, - version: "v1.0.0", - wantImages: []string{ - "v1.0.1", - "v1.0.2", - "v1.1.0", - }, - }, - "no compatible images": { - images: imageList, - version: "v999.999.999", - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - assert := assert.New(t) - - compatibleImages := getCompatibleImages(tc.version, tc.images) - assert.EqualValues(tc.wantImages, compatibleImages) - }) - } -} - -func TestGetCompatibleImageMeasurements(t *testing.T) { - assert := assert.New(t) - - csp := cloudprovider.Azure - images := []string{"v0.0.0", "v1.0.0"} - - client := newTestClient(func(req *http.Request) *http.Response { - if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json") { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v0.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), - Header: make(http.Header), - } - } - if strings.HasSuffix(req.URL.String(), "v0.0.0/azure/measurements.json.sig") { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("MEQCIGRR7RaSMs892Ta06/Tz7LqPUxI05X4wQcP+nFFmZtmaAiBNl9X8mUKmUBfxg13LQBfmmpw6JwYQor5hOwM3NFVPAg==")), - Header: make(http.Header), - } - } - - if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json") { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.0","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), - Header: make(http.Header), - } - } - if strings.HasSuffix(req.URL.String(), "v1.0.0/azure/measurements.json.sig") { - return &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader("MEQCIFh8CVELp/Da2U2Jt404OXsUeDfqtrf3pqGRuvxnxhI8AiBTHF9tHEPwFedYG3Jgn2ELOxss+Ybc6135vEtClBrbpg==")), - 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-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----") - - upgrades, err := getCompatibleImageMeasurements(context.Background(), &cobra.Command{}, client, singleUUIDVerifier(), pubK, csp, images) - assert.NoError(err) - - for _, image := range upgrades { - assert.NotEmpty(image.Measurements) - } -} - -func TestUpgradePlan(t *testing.T) { - availablePatches := versionsapi.List{ - Versions: []string{"v1.0.0", "v1.0.1"}, - } - - // Cosign private key used to sign the measurements. - // Generated with: cosign generate-key-pair - // Password left empty. - // - // -----BEGIN ENCRYPTED COSIGN PRIVATE KEY----- - // eyJrZGYiOnsibmFtZSI6InNjcnlwdCIsInBhcmFtcyI6eyJOIjozMjc2OCwiciI6 - // OCwicCI6MX0sInNhbHQiOiJlRHVYMWRQMGtIWVRnK0xkbjcxM0tjbFVJaU92eFVX - // VXgvNi9BbitFVk5BPSJ9LCJjaXBoZXIiOnsibmFtZSI6Im5hY2wvc2VjcmV0Ym94 - // Iiwibm9uY2UiOiJwaWhLL2txNmFXa2hqSVVHR3RVUzhTVkdHTDNIWWp4TCJ9LCJj - // aXBoZXJ0ZXh0Ijoidm81SHVWRVFWcUZ2WFlQTTVPaTVaWHM5a255bndZU2dvcyth - // VklIeHcrOGFPamNZNEtvVjVmL3lHRHR0K3BHV2toanJPR1FLOWdBbmtsazFpQ0c5 - // a2czUXpPQTZsU2JRaHgvZlowRVRZQ0hLeElncEdPRVRyTDlDenZDemhPZXVSOXJ6 - // TDcvRjBBVy9vUDVqZXR3dmJMNmQxOEhjck9kWE8yVmYxY2w0YzNLZjVRcnFSZzlN - // dlRxQWFsNXJCNHNpY1JaMVhpUUJjb0YwNHc9PSJ9 - // -----END ENCRYPTED COSIGN PRIVATE KEY----- - pubK := "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEu78QgxOOcao6U91CSzEXxrKhvFTt\nJHNy+eX6EMePtDm8CnDF9HSwnTlD0itGJ/XHPQA5YX10fJAqI1y+ehlFMw==\n-----END PUBLIC KEY-----" - - testCases := map[string]struct { - patchLister stubVersionListFetcher - planner stubUpgradePlanner - flags upgradePlanFlags - cliVersion string - csp cloudprovider.Provider - verifier rekorVerifier - measurementsFetchStatus int - wantUpgrade bool - wantErr bool - }{ - "upgrades gcp": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - cliVersion: "v1.0.0", - csp: cloudprovider.GCP, - verifier: singleUUIDVerifier(), - wantUpgrade: true, - }, - "upgrades azure": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.Azure, - cliVersion: "v999.999.999", - verifier: singleUUIDVerifier(), - wantUpgrade: true, - }, - "current image newer than updates": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v999.999.999", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - verifier: singleUUIDVerifier(), - wantUpgrade: false, - }, - "current image newer than cli": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v999.999.999", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: singleUUIDVerifier(), - wantUpgrade: false, - }, - "upgrade to stdout": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "-", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: singleUUIDVerifier(), - wantUpgrade: true, - }, - "current image not valid": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "not-valid", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: singleUUIDVerifier(), - wantErr: true, - }, - "image fetch error": { - patchLister: stubVersionListFetcher{err: errors.New("error")}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: singleUUIDVerifier(), - }, - "measurements fetch error": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusInternalServerError, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: singleUUIDVerifier(), - }, - "failing search should not result in error": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: &stubRekorVerifier{ - SearchByHashUUIDs: []string{}, - SearchByHashError: errors.New("some error"), - }, - wantUpgrade: true, - }, - "failing verify should not result in error": { - patchLister: stubVersionListFetcher{list: availablePatches}, - planner: stubUpgradePlanner{ - image: "v1.0.0", - }, - measurementsFetchStatus: http.StatusOK, - flags: upgradePlanFlags{ - configPath: constants.ConfigFilename, - filePath: "upgrade-plan.yaml", - cosignPubKey: pubK, - }, - csp: cloudprovider.GCP, - cliVersion: "v1.0.0", - verifier: &stubRekorVerifier{ - SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"}, - VerifyEntryError: errors.New("some error"), - }, - wantUpgrade: 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 := defaultConfigWithExpectedMeasurements(t, config.Default(), tc.csp) - - require.NoError(fileHandler.WriteYAML(tc.flags.configPath, cfg)) - - cmd := newUpgradePlanCmd() - cmd.SetContext(context.Background()) - var outTarget bytes.Buffer - cmd.SetOut(&outTarget) - var errTarget bytes.Buffer - cmd.SetErr(&errTarget) - - client := newTestClient(func(req *http.Request) *http.Response { - if strings.HasSuffix(req.URL.String(), "azure/measurements.json") { - return &http.Response{ - StatusCode: tc.measurementsFetchStatus, - Body: io.NopCloser(strings.NewReader(`{"csp":"azure","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), - Header: make(http.Header), - } - } - if strings.HasSuffix(req.URL.String(), "azure/measurements.json.sig") { - return &http.Response{ - StatusCode: tc.measurementsFetchStatus, - Body: io.NopCloser(strings.NewReader("MEYCIQDu2Sft91FjN278uP+r/HFMms6IH/tRtaHzYvIN0xPgdwIhAJhiFxVsHCa0NK6bZOGLE9c4miZHIqFTKvgpTf3rJ9dW")), - Header: make(http.Header), - } - } - - if strings.HasSuffix(req.URL.String(), "gcp/measurements.json") { - return &http.Response{ - StatusCode: tc.measurementsFetchStatus, - Body: io.NopCloser(strings.NewReader(`{"csp":"gcp","image":"v1.0.1","measurements":{"0":{"expected":"0000000000000000000000000000000000000000000000000000000000000000","warnOnly":false}}}`)), - Header: make(http.Header), - } - } - if strings.HasSuffix(req.URL.String(), "gcp/measurements.json.sig") { - return &http.Response{ - StatusCode: tc.measurementsFetchStatus, - Body: io.NopCloser(strings.NewReader("MEQCIBUssv92LpSMiXE1UAVf2fW8J9pZHiLseo2tdZjxv2OMAiB6K8e8yL0768jWjlFnRe3Rc2x/dX34uzX3h0XUrlYt1A==")), - Header: make(http.Header), - } - } - - return &http.Response{ - StatusCode: http.StatusNotFound, - Body: io.NopCloser(strings.NewReader("Not found.")), - Header: make(http.Header), - } - }) - up := &upgradePlanCmd{log: logger.NewTest(t)} - err := up.upgradePlan(cmd, tc.planner, tc.patchLister, fileHandler, client, tc.verifier, tc.flags, tc.cliVersion) - if tc.wantErr { - assert.Error(err) - return - } - - assert.NoError(err) - if !tc.wantUpgrade { - assert.Contains(errTarget.String(), "No compatible images") - return - } - - var availableUpgrades map[string]config.UpgradeConfig - if tc.flags.filePath == "-" { - require.NoError(yaml.Unmarshal(outTarget.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 TestNextMinorVersion(t *testing.T) { - testCases := map[string]struct { - version string - wantNextMinorVersion string - wantErr bool - }{ - "gets next": { - version: "v1.0.0", - wantNextMinorVersion: "v1.1", - }, - "gets next from minor version": { - version: "v1.0", - wantNextMinorVersion: "v1.1", - }, - "empty version": { - wantErr: true, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - assert := assert.New(t) - - gotNext, err := nextMinorVersion(tc.version) - if tc.wantErr { - assert.Error(err) - return - } - - assert.NoError(err) - assert.Equal(tc.wantNextMinorVersion, gotNext) - }) - } -} - -type stubUpgradePlanner struct { - image string - err error -} - -func (u stubUpgradePlanner) GetCurrentImage(context.Context) (*unstructured.Unstructured, string, error) { - return nil, u.image, u.err -} - -type stubVersionListFetcher struct { - list versionsapi.List - err error -} - -func (s stubVersionListFetcher) FetchVersionList(context.Context, versionsapi.List) (versionsapi.List, error) { - return s.list, s.err -} diff --git a/cli/internal/cmd/verify.go b/cli/internal/cmd/verify.go index 46fdc9159..4514a506f 100644 --- a/cli/internal/cmd/verify.go +++ b/cli/internal/cmd/verify.go @@ -118,26 +118,26 @@ func (v *verifyCmd) parseVerifyFlags(cmd *cobra.Command, fileHandler file.Handle if err != nil { return verifyFlags{}, fmt.Errorf("parsing config path argument: %w", err) } - v.log.Debugf("Configuration file flag is %q", configPath) + v.log.Debugf("Flag 'config' set to %q", configPath) ownerID := "" clusterID, err := cmd.Flags().GetString("cluster-id") if err != nil { return verifyFlags{}, fmt.Errorf("parsing cluster-id argument: %w", err) } - v.log.Debugf("Cluster ID flag is %q", clusterID) + v.log.Debugf("Flag 'cluster-id' set to %q", clusterID) endpoint, err := cmd.Flags().GetString("node-endpoint") if err != nil { return verifyFlags{}, fmt.Errorf("parsing node-endpoint argument: %w", err) } - v.log.Debugf("'node-endpoint' flag is %q", endpoint) + v.log.Debugf("Flag 'node-endpoint' set to %q", endpoint) force, err := cmd.Flags().GetBool("force") if err != nil { return verifyFlags{}, fmt.Errorf("parsing force argument: %w", err) } - v.log.Debugf("'force' flag is %t", force) + v.log.Debugf("Flag 'force' set to %t", force) // Get empty values from ID file emptyEndpoint := endpoint == "" diff --git a/cli/internal/helm/client.go b/cli/internal/helm/client.go index f20200dd7..7bedc3115 100644 --- a/cli/internal/helm/client.go +++ b/cli/internal/helm/client.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/edgelesssys/constellation/v2/internal/compatibility" "github.com/edgelesssys/constellation/v2/internal/config" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/deploy/helm" @@ -91,6 +92,16 @@ func (c *Client) Upgrade(ctx context.Context, config *config.Config, timeout tim return nil } +// Versions queries the cluster for running versions and returns a map of releaseName -> version. +func (c *Client) Versions() (string, error) { + serviceVersion, err := c.currentVersion(conServicesReleaseName) + if err != nil { + return "", fmt.Errorf("getting constellation-services version: %w", err) + } + + return compatibility.EnsurePrefixV(serviceVersion), nil +} + // currentVersion returns the version of the currently installed helm release. func (c *Client) currentVersion(release string) (string, error) { rel, err := c.actions.listAction(release) diff --git a/cli/internal/helm/loader.go b/cli/internal/helm/loader.go index 6f9b7e15e..3935ac027 100644 --- a/cli/internal/helm/loader.go +++ b/cli/internal/helm/loader.go @@ -18,6 +18,7 @@ import ( "strings" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/compatibility" "github.com/edgelesssys/constellation/v2/internal/config" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/deploy/helm" @@ -90,6 +91,16 @@ func NewLoader(csp cloudprovider.Provider, k8sVersion versions.ValidK8sVersion) } } +// AvailableServiceVersions returns the chart version number of the bundled service versions. +func AvailableServiceVersions() (string, error) { + servicesChart, err := loadChartsDir(helmFS, conServicesPath) + if err != nil { + return "", fmt.Errorf("loading constellation-services chart: %w", err) + } + + return compatibility.EnsurePrefixV(servicesChart.Metadata.Version), nil +} + // Load the embedded helm charts. func (i *ChartLoader) Load(config *config.Config, conformanceMode bool, masterSecret, salt []byte) ([]byte, error) { ciliumRelease, err := i.loadCilium(config.GetProvider(), conformanceMode) diff --git a/go.mod b/go.mod index 55c2ae259..ceeb6b49d 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,6 @@ require ( github.com/hashicorp/hc-install v0.4.0 github.com/hashicorp/terraform-exec v0.17.3 github.com/hashicorp/terraform-json v0.14.0 - github.com/manifoldco/promptui v0.9.0 github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6 github.com/mattn/go-isatty v0.0.17 github.com/microsoft/ApplicationInsights-Go v0.4.4 @@ -86,7 +85,6 @@ require ( github.com/spf13/afero v1.9.3 github.com/spf13/cobra v1.6.1 github.com/stretchr/testify v1.8.1 - github.com/tj/assert v0.0.0-20171129193455-018094318fb0 go.uber.org/goleak v1.2.0 go.uber.org/multierr v1.9.0 go.uber.org/zap v1.24.0 @@ -162,7 +160,6 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/containerd/cgroups v1.0.4 // indirect github.com/containerd/containerd v1.6.12 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20210303052042-6bc126869bf4 // indirect diff --git a/go.sum b/go.sum index 44c1bc99d..fd58af465 100644 --- a/go.sum +++ b/go.sum @@ -315,11 +315,8 @@ github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cb github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -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= @@ -946,8 +943,6 @@ 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/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= @@ -1310,7 +1305,6 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= -github.com/tj/assert v0.0.0-20171129193455-018094318fb0 h1:Rw8kxzWo1mr6FSaYXjQELRe88y2KdfynXdnK72rdjtA= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= diff --git a/hack/go.mod b/hack/go.mod index 9c9ec6f72..321490152 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -107,7 +107,6 @@ require ( github.com/blang/semver v3.5.1+incompatible // indirect github.com/cespare/xxhash/v2 v2.1.2 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/cloudflare/circl v1.1.0 // indirect github.com/containerd/containerd v1.6.12 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect @@ -203,7 +202,6 @@ require ( github.com/lib/pq v1.10.6 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/manifoldco/promptui v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect diff --git a/hack/go.sum b/hack/go.sum index 0df95db22..fde5363ef 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -290,11 +290,8 @@ github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cb github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -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= @@ -940,8 +937,6 @@ 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/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= @@ -1305,7 +1300,6 @@ github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhV github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= -github.com/tj/assert v0.0.0-20171129193455-018094318fb0 h1:Rw8kxzWo1mr6FSaYXjQELRe88y2KdfynXdnK72rdjtA= github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0= github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0= github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao= diff --git a/internal/compatibility/compatibility.go b/internal/compatibility/compatibility.go index d63b6c672..d97bf7d23 100644 --- a/internal/compatibility/compatibility.go +++ b/internal/compatibility/compatibility.go @@ -24,11 +24,11 @@ var ( // ErrMinorDrift signals that the minor version of two compared versions are further apart than one. ErrMinorDrift = errors.New("target version needs to be equal or up to one minor version higher") // ErrSemVer signals that a given version does not adhere to the Semver syntax. - ErrSemVer = errors.New("invalid semver") + ErrSemVer = errors.New("invalid semantic version") ) -// ensurePrefixV returns the input string prefixed with the letter "v", if the string doesn't already start with that letter. -func ensurePrefixV(str string) string { +// EnsurePrefixV returns the input string prefixed with the letter "v", if the string doesn't already start with that letter. +func EnsurePrefixV(str string) string { if strings.HasPrefix(str, "v") { return str } @@ -37,8 +37,8 @@ func ensurePrefixV(str string) string { // IsValidUpgrade checks that a and b adhere to a version drift of 1 and b is greater than a. func IsValidUpgrade(a, b string) error { - a = ensurePrefixV(a) - b = ensurePrefixV(b) + a = EnsurePrefixV(a) + b = EnsurePrefixV(b) if !semver.IsValid(a) || !semver.IsValid(b) { return ErrSemVer @@ -70,8 +70,8 @@ func IsValidUpgrade(a, b string) error { // BinaryWith tests that this binarie's version is greater or equal than some target version, but not further away than one minor version. func BinaryWith(target string) error { - binaryVersion := ensurePrefixV(constants.VersionInfo) - target = ensurePrefixV(target) + binaryVersion := EnsurePrefixV(constants.VersionInfo) + target = EnsurePrefixV(target) if !semver.IsValid(binaryVersion) || !semver.IsValid(target) { return ErrSemVer } @@ -101,11 +101,11 @@ func BinaryWith(target string) error { // FilterNewerVersion filters the list of versions to only include versions newer than currentVersion. func FilterNewerVersion(currentVersion string, newVersions []string) []string { - currentVersion = ensurePrefixV(currentVersion) + currentVersion = EnsurePrefixV(currentVersion) var result []string for _, image := range newVersions { - image = ensurePrefixV(image) + image = EnsurePrefixV(image) // check if image is newer than current version if semver.Compare(image, currentVersion) <= 0 { continue @@ -118,7 +118,7 @@ func FilterNewerVersion(currentVersion string, newVersions []string) []string { // NextMinorVersion returns the next minor version for a given canonical semver. // The returned format is vMAJOR.MINOR. func NextMinorVersion(version string) (string, error) { - major, minor, err := parseCanonicalSemver(ensurePrefixV(version)) + major, minor, err := parseCanonicalSemver(EnsurePrefixV(version)) if err != nil { return "", err } @@ -126,14 +126,14 @@ func NextMinorVersion(version string) (string, error) { } func parseCanonicalSemver(version string) (major int, minor int, err error) { - version = semver.Canonical(version) // ensure version is in canonical form (vX.Y.Z) - num, err := fmt.Sscanf(version, "v%d.%d", &major, &minor) + version = semver.MajorMinor(version) // ensure version is in canonical form (vX.Y.Z) + if version == "" { + return 0, 0, fmt.Errorf("invalid semver: '%s'", version) + } + _, err = fmt.Sscanf(version, "v%d.%d", &major, &minor) if err != nil { return 0, 0, fmt.Errorf("parsing version: %w", err) } - if num != 2 { - return 0, 0, fmt.Errorf("parsing version: expected 3 numbers, got %d", num) - } return major, minor, nil } diff --git a/internal/compatibility/compatibility_test.go b/internal/compatibility/compatibility_test.go index 6c19c4872..6c3682c65 100644 --- a/internal/compatibility/compatibility_test.go +++ b/internal/compatibility/compatibility_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/edgelesssys/constellation/v2/internal/constants" - "github.com/tj/assert" + "github.com/stretchr/testify/assert" ) func TestFilterNewerVersion(t *testing.T) { @@ -206,3 +206,60 @@ func TestIsValidUpgrade(t *testing.T) { }) } } + +func TestParseCanonicalSemver(t *testing.T) { + testCases := map[string]struct { + version string + major int + minor int + wantError bool + }{ + "canonical input": { + version: "v1.1.1", + major: 1, + minor: 1, + }, + "vMAJOR.MINOR input": { + version: "v1.1", + major: 1, + minor: 1, + }, + "vMAJOR input": { + version: "v1", + major: 1, + minor: 0, + }, + "invalid (go)semver": { + version: "1.1", // valid semver, but invalid according to go's semver + wantError: true, + }, + "invalid (go)semver #2": { + version: "asdf", + wantError: true, + }, + "invalid (go)semver #3": { + version: "v1.1.1.1.1", + wantError: true, + }, + "pseudoversion": { + version: "v2.6.0-pre.0.20230125085856-aaaaaaaaaaaa", + major: 2, + minor: 6, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + major, minor, err := parseCanonicalSemver(tc.version) + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + assert.Equal(tc.major, major) + assert.Equal(tc.minor, minor) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index c936c215f..7ea61e6b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,6 +28,7 @@ import ( "github.com/edgelesssys/constellation/v2/internal/attestation/idkeydigest" "github.com/edgelesssys/constellation/v2/internal/attestation/measurements" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/compatibility" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/versions" @@ -63,9 +64,12 @@ type Config struct { // Size (in GB) of a node's disk to store the non-volatile state. StateDiskSizeGB int `yaml:"stateDiskSizeGB" validate:"min=0"` // description: | - // Kubernetes version to be installed in the cluster. + // Kubernetes version to be installed into the cluster. KubernetesVersion string `yaml:"kubernetesVersion" validate:"supported_k8s_version"` // description: | + // Microservice version to be installed into the cluster. Setting this value is optional until v2.7. Defaults to the version of the CLI. + MicroserviceVersion string `yaml:"microserviceVersion" validate:"omitempty,version_compatibility"` + // description: | // DON'T USE IN PRODUCTION: enable debug mode and use debug images. For usage, see: https://github.com/edgelesssys/constellation/blob/main/debugd/README.md DebugCluster *bool `yaml:"debugCluster" validate:"required"` // description: | @@ -75,7 +79,7 @@ type Config struct { // Configuration to apply during constellation upgrade. // examples: // - value: 'UpgradeConfig{ Image: "", Measurements: Measurements{} }' - Upgrade UpgradeConfig `yaml:"upgrade,omitempty"` + Upgrade UpgradeConfig `yaml:"upgrade,omitempty" validate:"required"` } // UpgradeConfig defines configuration used during constellation upgrade. @@ -246,10 +250,12 @@ type QEMUConfig struct { // Default returns a struct with the default config. func Default() *Config { return &Config{ - Version: Version2, - Image: defaultImage, - StateDiskSizeGB: 30, - DebugCluster: func() *bool { b := false; return &b }(), + Version: Version2, + Image: defaultImage, + MicroserviceVersion: compatibility.EnsurePrefixV(constants.VersionInfo), + KubernetesVersion: string(versions.Default), + StateDiskSizeGB: 30, + DebugCluster: func() *bool { b := false; return &b }(), Provider: ProviderConfig{ AWS: &AWSConfig{ Region: "", @@ -295,14 +301,13 @@ func Default() *Config { Measurements: measurements.DefaultsFor(cloudprovider.QEMU), }, }, - KubernetesVersion: string(versions.Default), } } -// FromFile returns config file with `name` read from `fileHandler` by parsing +// fromFile returns config file with `name` read from `fileHandler` by parsing // it as YAML. You should prefer config.New to read env vars and validate // config in a consistent manner. -func FromFile(fileHandler file.Handler, name string) (*Config, error) { +func fromFile(fileHandler file.Handler, name string) (*Config, error) { var conf Config if err := fileHandler.ReadYAMLStrict(name, &conf); err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -316,10 +321,10 @@ func FromFile(fileHandler file.Handler, name string) (*Config, error) { // New creates a new config by: // 1. Reading config file via provided fileHandler from file with name. // 2. Read secrets from environment variables. -// 3. Validate config. +// 3. Validate config. If `--force` is set the version validation will be disabled and any version combination is allowed. func New(fileHandler file.Handler, name string, force bool) (*Config, error) { // Read config file - c, err := FromFile(fileHandler, name) + c, err := fromFile(fileHandler, name) if err != nil { return nil, err } @@ -330,6 +335,10 @@ func New(fileHandler file.Handler, name string, force bool) (*Config, error) { c.Provider.Azure.ClientSecretValue = clientSecretValue } + // Backwards compatibility: configs without the field `microserviceVersion` are valid in version 2.6. + // In case the field is not set in an old config we prefil it with the default value. + c.MicroserviceVersion = Default().MicroserviceVersion + return c, c.Validate(force) } @@ -536,6 +545,9 @@ func (c *Config) Validate(force bool) error { // Register provider validation validate.RegisterStructValidation(validateProvider, ProviderConfig{}) + // register custom validator that prints a deprecation warning. + validate.RegisterStructValidation(validateUpgradeConfig, UpgradeConfig{}) + err := validate.Struct(c) if err == nil { return nil diff --git a/internal/config/config_doc.go b/internal/config/config_doc.go index 4ab6c3b10..ccb48cdd5 100644 --- a/internal/config/config_doc.go +++ b/internal/config/config_doc.go @@ -24,7 +24,7 @@ func init() { ConfigDoc.Type = "Config" ConfigDoc.Comments[encoder.LineComment] = "Config defines configuration used by CLI." ConfigDoc.Description = "Config defines configuration used by CLI." - ConfigDoc.Fields = make([]encoder.Doc, 7) + ConfigDoc.Fields = make([]encoder.Doc, 8) ConfigDoc.Fields[0].Name = "version" ConfigDoc.Fields[0].Type = "string" ConfigDoc.Fields[0].Note = "" @@ -43,25 +43,30 @@ func init() { ConfigDoc.Fields[3].Name = "kubernetesVersion" ConfigDoc.Fields[3].Type = "string" ConfigDoc.Fields[3].Note = "" - ConfigDoc.Fields[3].Description = "Kubernetes version to be installed in the cluster." - ConfigDoc.Fields[3].Comments[encoder.LineComment] = "Kubernetes version to be installed in the cluster." - ConfigDoc.Fields[4].Name = "debugCluster" - ConfigDoc.Fields[4].Type = "bool" + ConfigDoc.Fields[3].Description = "Kubernetes version to be installed into the cluster." + ConfigDoc.Fields[3].Comments[encoder.LineComment] = "Kubernetes version to be installed into the cluster." + ConfigDoc.Fields[4].Name = "microserviceVersion" + ConfigDoc.Fields[4].Type = "string" ConfigDoc.Fields[4].Note = "" - ConfigDoc.Fields[4].Description = "DON'T USE IN PRODUCTION: enable debug mode and use debug images. For usage, see: https://github.com/edgelesssys/constellation/blob/main/debugd/README.md" - ConfigDoc.Fields[4].Comments[encoder.LineComment] = "DON'T USE IN PRODUCTION: enable debug mode and use debug images. For usage, see: https://github.com/edgelesssys/constellation/blob/main/debugd/README.md" - ConfigDoc.Fields[5].Name = "provider" - ConfigDoc.Fields[5].Type = "ProviderConfig" + ConfigDoc.Fields[4].Description = "Microservice version to be installed into the cluster. Setting this value is optional until v2.7. Defaults to the version of the CLI." + ConfigDoc.Fields[4].Comments[encoder.LineComment] = "Microservice version to be installed into the cluster. Setting this value is optional until v2.7. Defaults to the version of the CLI." + ConfigDoc.Fields[5].Name = "debugCluster" + ConfigDoc.Fields[5].Type = "bool" ConfigDoc.Fields[5].Note = "" - ConfigDoc.Fields[5].Description = "Supported cloud providers and their specific configurations." - ConfigDoc.Fields[5].Comments[encoder.LineComment] = "Supported cloud providers and their specific configurations." - ConfigDoc.Fields[6].Name = "upgrade" - ConfigDoc.Fields[6].Type = "UpgradeConfig" + ConfigDoc.Fields[5].Description = "DON'T USE IN PRODUCTION: enable debug mode and use debug images. For usage, see: https://github.com/edgelesssys/constellation/blob/main/debugd/README.md" + ConfigDoc.Fields[5].Comments[encoder.LineComment] = "DON'T USE IN PRODUCTION: enable debug mode and use debug images. For usage, see: https://github.com/edgelesssys/constellation/blob/main/debugd/README.md" + ConfigDoc.Fields[6].Name = "provider" + ConfigDoc.Fields[6].Type = "ProviderConfig" ConfigDoc.Fields[6].Note = "" - ConfigDoc.Fields[6].Description = "Configuration to apply during constellation upgrade." - ConfigDoc.Fields[6].Comments[encoder.LineComment] = "Configuration to apply during constellation upgrade." + ConfigDoc.Fields[6].Description = "Supported cloud providers and their specific configurations." + ConfigDoc.Fields[6].Comments[encoder.LineComment] = "Supported cloud providers and their specific configurations." + ConfigDoc.Fields[7].Name = "upgrade" + ConfigDoc.Fields[7].Type = "UpgradeConfig" + ConfigDoc.Fields[7].Note = "" + ConfigDoc.Fields[7].Description = "Configuration to apply during constellation upgrade." + ConfigDoc.Fields[7].Comments[encoder.LineComment] = "Configuration to apply during constellation upgrade." - ConfigDoc.Fields[6].AddExample("", UpgradeConfig{Image: "", Measurements: Measurements{}}) + ConfigDoc.Fields[7].AddExample("", UpgradeConfig{Image: "", Measurements: Measurements{}}) UpgradeConfigDoc.Type = "UpgradeConfig" UpgradeConfigDoc.Comments[encoder.LineComment] = "UpgradeConfig defines configuration used during constellation upgrade." diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e2b32c52a..0629feb4f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -99,7 +99,7 @@ func TestFromFile(t *testing.T) { require.NoError(fileHandler.WriteYAML(tc.configName, tc.config, file.OptNone)) } - result, err := FromFile(fileHandler, tc.configName) + result, err := fromFile(fileHandler, tc.configName) if tc.wantErr { assert.Error(err) @@ -802,7 +802,7 @@ func TestConfigVersionCompatibility(t *testing.T) { fileHandler := file.NewHandler(afero.NewOsFs()) - config, err := FromFile(fileHandler, tc.config) + config, err := fromFile(fileHandler, tc.config) assert.NoError(err) assert.Equal(tc.expectedConfig, config) diff --git a/internal/config/validation.go b/internal/config/validation.go index bdda81314..061af45e8 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -55,7 +55,7 @@ func translateInvalidK8sVersionError(ut ut.Translator, fe validator.FieldError) validVersionsSorted := semver.ByVersion(validVersions) sort.Sort(validVersionsSorted) - var errorMsg string + errorMsg := fmt.Sprintf("Supported versions: %s", strings.Join(validVersionsSorted, " ")) configured, ok := fe.Value().(string) if !ok { errorMsg = "The configured version is not a valid string" @@ -310,12 +310,12 @@ func translateVersionCompatibilityError(ut ut.Translator, fe validator.FieldErro err := validateVersionCompatibilityHelper(fe.Field(), fe.Value().(string)) var msg string - switch err { - case compatibility.ErrSemVer: + switch { + case errors.Is(err, compatibility.ErrSemVer): msg = fmt.Sprintf("configured version (%s) does not adhere to SemVer syntax", fe.Value().(string)) - case compatibility.ErrMajorMismatch: + case errors.Is(err, compatibility.ErrMajorMismatch): msg = fmt.Sprintf("the CLI's major version (%s) has to match your configured major version (%s)", constants.VersionInfo, fe.Value().(string)) - case compatibility.ErrMinorDrift: + case errors.Is(err, compatibility.ErrMinorDrift): msg = fmt.Sprintf("only the CLI (%s) can be up to one minor version newer than the configured version (%s)", constants.VersionInfo, fe.Value().(string)) default: msg = err.Error() @@ -350,3 +350,8 @@ func validateVersionCompatibilityHelper(fieldName string, configuredVersion stri func returnsTrue(fl validator.FieldLevel) bool { return true } + +// validateUpgradeConfig prints a warning to STDERR and validates the field successfully. +func validateUpgradeConfig(sl validator.StructLevel) { + fmt.Printf("WARNING: the config key `upgrade` will be deprecated in an upcoming version. Please check the documentation for more information.\n") +} diff --git a/internal/versions/versions.go b/internal/versions/versions.go index 726b037c8..cbfedb4bc 100644 --- a/internal/versions/versions.go +++ b/internal/versions/versions.go @@ -13,12 +13,28 @@ package versions import ( "fmt" + "sort" "strings" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/versions/components" + "golang.org/x/mod/semver" ) +// SupportedK8sVersions returns a list (sorted) of supported Kubernetes versions. +func SupportedK8sVersions() []string { + validVersions := make([]string, len(VersionConfigs)) + i := 0 + for _, conf := range VersionConfigs { + validVersions[i] = conf.ClusterVersion + i++ + } + validVersionsSorted := semver.ByVersion(validVersions) + sort.Sort(validVersionsSorted) + + return validVersionsSorted +} + // ValidK8sVersion represents any of the three currently supported k8s versions. type ValidK8sVersion string @@ -85,11 +101,11 @@ const ( // currently supported versions. //nolint:revive - V1_24 ValidK8sVersion = "1.24" + V1_24 ValidK8sVersion = "v1.24.9" //nolint:revive - V1_25 ValidK8sVersion = "1.25" + V1_25 ValidK8sVersion = "v1.25.6" //nolint:revive - V1_26 ValidK8sVersion = "1.26" + V1_26 ValidK8sVersion = "v1.26.1" // Default k8s version deployed by Constellation. Default ValidK8sVersion = V1_25 diff --git a/internal/versionsapi/cli/add.go b/internal/versionsapi/cli/add.go index 4e4257704..8076880a4 100644 --- a/internal/versionsapi/cli/add.go +++ b/internal/versionsapi/cli/add.go @@ -54,12 +54,12 @@ func runAdd(cmd *cobra.Command, args []string) (retErr error) { log := logger.New(logger.PlainLog, flags.logLevel) log.Debugf("Parsed flags: %+v", flags) - log.Debugf("Validating flags.") + log.Debugf("Validating flags") if err := flags.validate(log); err != nil { return err } - log.Debugf("Creating version struct.") + log.Debugf("Creating version struct") ver := versionsapi.Version{ Ref: flags.ref, Stream: flags.stream, @@ -70,19 +70,19 @@ func runAdd(cmd *cobra.Command, args []string) (retErr error) { return err } - log.Debugf("Creating versions API client.") + log.Debugf("Creating versions API client") client, err := verclient.NewClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, flags.dryRun, log) if err != nil { return fmt.Errorf("creating client: %w", err) } defer func(retErr *error) { - log.Infof("Invalidating cache. This may take some time.") + log.Infof("Invalidating cache. This may take some time") if err := client.InvalidateCache(cmd.Context()); err != nil && retErr == nil { *retErr = fmt.Errorf("invalidating cache: %w", err) } }(&retErr) - log.Infof("Adding version.") + log.Infof("Adding version") if err := ensureVersion(cmd.Context(), client, ver, versionsapi.GranularityMajor, log); err != nil { return err } @@ -127,19 +127,19 @@ func ensureVersion(ctx context.Context, client *verclient.Client, ver versionsap insertVersion := ver.WithGranularity(insertGran) if verList.Contains(insertVersion) { - log.Infof("Version %q already exists in list %v.", insertVersion, verList.Versions) + log.Infof("Version %q already exists in list %v", insertVersion, verList.Versions) return nil } - log.Infof("Inserting %s version %q into list.", insertGran.String(), insertVersion) + log.Infof("Inserting %s version %q into list", insertGran.String(), insertVersion) verList.Versions = append(verList.Versions, insertVersion) - log.Debugf("New %s version list: %v.", gran.String(), verList) + log.Debugf("New %s version list: %v", gran.String(), verList) if err := client.UpdateVersionList(ctx, verList); err != nil { return fmt.Errorf("failed to add %s version: %w", gran.String(), err) } - log.Infof("Added %q to list.", insertVersion) + log.Infof("Added %q to list", insertVersion) return nil } @@ -162,7 +162,7 @@ func updateLatest(ctx context.Context, client *verclient.Client, ver versionsapi return nil } - log.Infof("Setting %q as latest version.", ver) + log.Infof("Setting %q as latest version", ver) latest = versionsapi.Latest{ Ref: ver.Ref, Stream: ver.Stream, diff --git a/internal/versionsapi/cli/latest.go b/internal/versionsapi/cli/latest.go index 042dddd6e..68ca73519 100644 --- a/internal/versionsapi/cli/latest.go +++ b/internal/versionsapi/cli/latest.go @@ -41,18 +41,18 @@ func runLatest(cmd *cobra.Command, args []string) error { log := logger.New(logger.PlainLog, flags.logLevel) log.Debugf("Parsed flags: %+v", flags) - log.Debugf("Validating flags.") + log.Debugf("Validating flags") if err := flags.validate(); err != nil { return err } - log.Debugf("Creating versions API client.") + log.Debugf("Creating versions API client") client, err := verclient.NewReadOnlyClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, log) if err != nil { return fmt.Errorf("creating client: %w", err) } - log.Debugf("Requesting latest version.") + log.Debugf("Requesting latest version") latest := versionsapi.Latest{ Ref: flags.ref, Stream: flags.stream, diff --git a/internal/versionsapi/cli/list.go b/internal/versionsapi/cli/list.go index 395540775..afc7927f8 100644 --- a/internal/versionsapi/cli/list.go +++ b/internal/versionsapi/cli/list.go @@ -45,12 +45,12 @@ func runList(cmd *cobra.Command, args []string) error { log := logger.New(logger.PlainLog, flags.logLevel) log.Debugf("Parsed flags: %+v", flags) - log.Debugf("Validating flags.") + log.Debugf("Validating flags") if err := flags.validate(); err != nil { return err } - log.Debugf("Creating versions API client.") + log.Debugf("Creating versions API client") client, err := verclient.NewReadOnlyClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, log) if err != nil { return fmt.Errorf("creating client: %w", err) @@ -60,7 +60,7 @@ func runList(cmd *cobra.Command, args []string) error { if flags.minorVersion != "" { minorVersions = []string{flags.minorVersion} } else { - log.Debugf("Getting minor versions.") + log.Debugf("Getting minor versions") minorVersions, err = listMinorVersions(cmd.Context(), client, flags.ref, flags.stream) var errNotFound *verclient.NotFoundError if err != nil && errors.As(err, &errNotFound) { @@ -71,7 +71,7 @@ func runList(cmd *cobra.Command, args []string) error { } } - log.Debugf("Getting patch versions.") + log.Debugf("Getting patch versions") patchVersions, err := listPatchVersions(cmd.Context(), client, flags.ref, flags.stream, minorVersions) var errNotFound *verclient.NotFoundError if err != nil && errors.As(err, &errNotFound) { @@ -82,7 +82,7 @@ func runList(cmd *cobra.Command, args []string) error { } if flags.json { - log.Debugf("Printing versions as JSON.") + log.Debugf("Printing versions as JSON") var vers []string for _, v := range patchVersions { vers = append(vers, v.Version) @@ -95,7 +95,7 @@ func runList(cmd *cobra.Command, args []string) error { return nil } - log.Debugf("Printing versions.") + log.Debugf("Printing versions") for _, v := range patchVersions { fmt.Println(v.ShortPath()) } diff --git a/internal/versionsapi/cli/rm.go b/internal/versionsapi/cli/rm.go index d5d4d65fa..811d9aa2a 100644 --- a/internal/versionsapi/cli/rm.go +++ b/internal/versionsapi/cli/rm.go @@ -78,36 +78,36 @@ func runRemove(cmd *cobra.Command, args []string) (retErr error) { log := logger.New(logger.PlainLog, flags.logLevel) log.Debugf("Parsed flags: %+v", flags) - log.Debugf("Validating flags.") + log.Debugf("Validating flags") if err := flags.validate(); err != nil { return err } - log.Debugf("Creating GCP client.") + log.Debugf("Creating GCP client") gcpClient, err := newGCPClient(cmd.Context(), flags.gcpProject) if err != nil { return fmt.Errorf("creating GCP client: %w", err) } - log.Debugf("Creating AWS client.") + log.Debugf("Creating AWS client") awsClient, err := newAWSClient(cmd.Context(), flags.region) if err != nil { return fmt.Errorf("creating AWS client: %w", err) } - log.Debugf("Creating Azure client.") + log.Debugf("Creating Azure client") azClient, err := newAzureClient(flags.azSubscription, flags.azLocation, flags.azResourceGroup) if err != nil { return fmt.Errorf("creating Azure client: %w", err) } - log.Debugf("Creating versions API client.") + log.Debugf("Creating versions API client") verclient, err := verclient.NewClient(cmd.Context(), flags.region, flags.bucket, flags.distributionID, flags.dryrun, log) if err != nil { return fmt.Errorf("creating client: %w", err) } defer func(retErr *error) { - log.Infof("Invalidating cache. This may take some time.") + log.Infof("Invalidating cache. This may take some time") if err := verclient.InvalidateCache(cmd.Context()); err != nil && retErr == nil { *retErr = fmt.Errorf("invalidating cache: %w", err) } @@ -205,8 +205,8 @@ func deleteImage(ctx context.Context, clients rmImageClients, ver versionsapi.Ve imageInfo, err := clients.version.FetchImageInfo(ctx, imageInfo) var notFound *verclient.NotFoundError if errors.As(err, ¬Found) { - log.Warnf("Image info for %s not found.", ver.Version) - log.Warnf("Skipping image deletion.") + log.Warnf("Image info for %s not found", ver.Version) + log.Warnf("Skipping image deletion") return nil } else if err != nil { return fmt.Errorf("fetching image info: %w", err) @@ -550,7 +550,7 @@ func (g *gcpClient) deleteImage(ctx context.Context, image string, dryrun bool, return fmt.Errorf("deleting image %s: %w", image, err) } - log.Debugf("Waiting for operation to finish.") + log.Debugf("Waiting for operation to finish") if err := op.Wait(ctx); err != nil { return fmt.Errorf("waiting for operation: %w", err) } @@ -643,7 +643,7 @@ func (a *azureClient) deleteImage(ctx context.Context, image string, dryrun bool return fmt.Errorf("begin delete image version: %w", err) } - log.Debugf("Waiting for operation to finish.") + log.Debugf("Waiting for operation to finish") if _, err := poller.PollUntilDone(ctx, nil); err != nil { return fmt.Errorf("waiting for operation: %w", err) } @@ -670,7 +670,7 @@ func (a *azureClient) deleteImage(ctx context.Context, image string, dryrun bool return fmt.Errorf("deleting image definition %s: %w", azImage.imageDefinition, err) } - log.Debugf("Waiting for operation to finish.") + log.Debugf("Waiting for operation to finish") if _, err := op.PollUntilDone(ctx, nil); err != nil { return fmt.Errorf("waiting for operation: %w", err) } @@ -688,7 +688,7 @@ type azImage struct { func (a *azureClient) parseImage(ctx context.Context, image string, log *logger.Logger) (azImage, error) { if m := azImageRegex.FindStringSubmatch(image); len(m) == 5 { log.Debugf( - "Image matches local image format, resource group: %s, gallery: %s, image definition: %s, version: %s.", + "Image matches local image format, resource group: %s, gallery: %s, image definition: %s, version: %s", m[1], m[2], m[3], m[4], ) return azImage{ @@ -709,7 +709,7 @@ func (a *azureClient) parseImage(ctx context.Context, image string, log *logger. version := m[3] log.Debugf( - "Image matches community image format, gallery public name: %s, image definition: %s, version: %s.", + "Image matches community image format, gallery public name: %s, image definition: %s, version: %s", galleryPublicName, imageDefinition, version, ) @@ -722,24 +722,24 @@ func (a *azureClient) parseImage(ctx context.Context, image string, log *logger. } for _, v := range nextResult.Value { if v.Name == nil { - log.Debugf("Skipping gallery with nil name.") + log.Debugf("Skipping gallery with nil name") continue } if v.Properties.SharingProfile == nil { - log.Debugf("Skipping gallery %s with nil sharing profile.", *v.Name) + log.Debugf("Skipping gallery %s with nil sharing profile", *v.Name) continue } if v.Properties.SharingProfile.CommunityGalleryInfo == nil { - log.Debugf("Skipping gallery %s with nil community gallery info.", *v.Name) + log.Debugf("Skipping gallery %s with nil community gallery info", *v.Name) continue } if v.Properties.SharingProfile.CommunityGalleryInfo.PublicNames == nil { - log.Debugf("Skipping gallery %s with nil public names.", *v.Name) + log.Debugf("Skipping gallery %s with nil public names", *v.Name) continue } for _, publicName := range v.Properties.SharingProfile.CommunityGalleryInfo.PublicNames { if publicName == nil { - log.Debugf("Skipping nil public name.") + log.Debugf("Skipping nil public name") continue } if *publicName == galleryPublicName { diff --git a/internal/versionsapi/client/client.go b/internal/versionsapi/client/client.go index 351e8e3d1..46a6c873e 100644 --- a/internal/versionsapi/client/client.go +++ b/internal/versionsapi/client/client.go @@ -187,7 +187,7 @@ func (c *Client) DeleteVersion(ctx context.Context, ver versionsapi.Version) err // The function should be deferred after the client has been created. func (c *Client) InvalidateCache(ctx context.Context) error { if len(c.dirtyPaths) == 0 { - c.log.Debugf("No dirty paths, skipping cache invalidation.") + c.log.Debugf("No dirty paths, skipping cache invalidation") return nil } @@ -213,7 +213,7 @@ func (c *Client) InvalidateCache(ctx context.Context) error { return fmt.Errorf("creating invalidation: %w", err) } - c.log.Debugf("Waiting for invalidation %s to complete.", *invalidation.Invalidation.Id) + c.log.Debugf("Waiting for invalidation %s to complete", *invalidation.Invalidation.Id) waiter := cloudfront.NewInvalidationCompletedWaiter(c.cloudfrontClient) waitIn := &cloudfront.GetInvalidationInput{ DistributionId: &c.distributionID, @@ -361,23 +361,23 @@ func (c *Client) deleteVersionFromLatest(ctx context.Context, ver versionsapi.Ve Stream: ver.Stream, Kind: versionsapi.VersionKindImage, } - c.log.Debugf("Fetching latest version from %s.", latest.JSONPath()) + c.log.Debugf("Fetching latest version from %s", latest.JSONPath()) latest, err := c.FetchVersionLatest(ctx, latest) var notFoundErr *NotFoundError if errors.As(err, ¬FoundErr) { - c.log.Warnf("Latest version for %s not found.", latest.JSONPath()) + c.log.Warnf("Latest version for %s not found", latest.JSONPath()) return nil } else if err != nil { return fmt.Errorf("fetching latest version: %w", err) } if latest.Version != ver.Version { - c.log.Debugf("Latest version is %s, not the deleted version %s.", latest.Version, ver.Version) + c.log.Debugf("Latest version is %s, not the deleted version %s", latest.Version, ver.Version) return nil } if possibleNewLatest == nil { - c.log.Errorf("Latest version is %s, but no new latest version was found.", latest.Version) + c.log.Errorf("Latest version is %s, but no new latest version was found", latest.Version) c.log.Errorf("A manual update of latest at %s might be needed", latest.JSONPath()) return fmt.Errorf("latest version is %s, but no new latest version was found", latest.Version) }