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) }