From f204c24174d01a275d55e72e49fff20cc9f39718 Mon Sep 17 00:00:00 2001 From: Otto Bittner Date: Tue, 31 Jan 2023 11:45:31 +0100 Subject: [PATCH] cli: add version validation and force flag Version validation checks that the configured versions are not more than one minor version below the CLI's version. The validation can be disabled using --force. This is necessary for now during development as the CLI does not have a prerelease version, as our images do. --- .../actions/constellation_create/action.yml | 7 +- CMakeLists.txt | 2 +- cli/cmd/root.go | 1 + cli/internal/cmd/configfetchmeasurements.go | 11 +- .../cmd/configfetchmeasurements_test.go | 4 + cli/internal/cmd/configvalidation.go | 28 --- cli/internal/cmd/create.go | 12 +- cli/internal/cmd/create_test.go | 2 + cli/internal/cmd/init.go | 12 +- cli/internal/cmd/init_test.go | 2 + cli/internal/cmd/miniup.go | 9 +- cli/internal/cmd/recover.go | 11 +- cli/internal/cmd/recover_test.go | 2 + cli/internal/cmd/upgradeexecute.go | 13 +- cli/internal/cmd/upgradeexecute_test.go | 1 + cli/internal/cmd/upgradeplan.go | 4 +- cli/internal/cmd/verify.go | 12 +- cli/internal/cmd/verify_test.go | 1 + debugd/internal/cdbg/cmd/deploy.go | 9 +- debugd/internal/cdbg/cmd/root.go | 2 + go.mod | 1 + go.sum | 1 + hack/go.sum | 1 + internal/compatibility/compatibility.go | 139 ++++++++++++ internal/compatibility/compatibility_test.go | 208 ++++++++++++++++++ internal/config/config.go | 24 +- internal/config/config_test.go | 4 +- internal/config/validation.go | 77 ++++++- internal/config/validation_test.go | 51 +++++ 29 files changed, 590 insertions(+), 61 deletions(-) delete mode 100644 cli/internal/cmd/configvalidation.go create mode 100644 internal/compatibility/compatibility.go create mode 100644 internal/compatibility/compatibility_test.go create mode 100644 internal/config/validation_test.go diff --git a/.github/actions/constellation_create/action.yml b/.github/actions/constellation_create/action.yml index 31c1e5899..17f57a270 100644 --- a/.github/actions/constellation_create/action.yml +++ b/.github/actions/constellation_create/action.yml @@ -155,7 +155,7 @@ runs: echo "Creating cluster using config:" cat constellation-conf.yaml sudo sh -c 'echo "127.0.0.1 license.confidential.cloud" >> /etc/hosts' || true - constellation create -c ${{ inputs.controlNodesCount }} -w ${{ inputs.workerNodesCount }} --name e2e-test -y + constellation create -c ${{ inputs.controlNodesCount }} -w ${{ inputs.workerNodesCount }} --name e2e-test -y --force - name: Cdbg deploy if: inputs.isDebugImage == 'true' @@ -173,14 +173,15 @@ runs: --info logcollect.github.run-attempt="${{ github.run_attempt }}" \ --info logcollect.github.ref-name="${{ github.ref_name }}" \ --info logcollect.github.sha="${{ github.sha }}" \ - --info logcollect.github.runner-os="${{ runner.os }}" + --info logcollect.github.runner-os="${{ runner.os }}" \ + --force echo "::endgroup::" - name: Constellation init id: constellation-init shell: bash run: | - constellation init + constellation init --force echo "KUBECONFIG=$(pwd)/constellation-admin.conf" >> $GITHUB_OUTPUT echo "MASTERSECRET=$(pwd)/constellation-mastersecret.json" >> $GITHUB_OUTPUT diff --git a/CMakeLists.txt b/CMakeLists.txt index 89f849a37..ef1209926 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,7 @@ add_custom_target(debugd ALL # cdbg # add_custom_target(cdbg ALL - CGO_ENABLED=0 go build -o ${CMAKE_BINARY_DIR}/cdbg -buildvcs=false -ldflags "-buildid=''" + CGO_ENABLED=0 go build -o ${CMAKE_BINARY_DIR}/cdbg -buildvcs=false -ldflags "-buildid='' -X github.com/edgelesssys/constellation/v2/internal/constants.VersionInfo=${PROJECT_VERSION}" WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/debugd/cmd/cdbg BYPRODUCTS cdbg ) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 37c419497..e837b9af4 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -47,6 +47,7 @@ func NewRootCmd() *cobra.Command { must(rootCmd.MarkPersistentFlagFilename("config", "yaml")) rootCmd.PersistentFlags().Bool("debug", false, "enable debug logging") + rootCmd.PersistentFlags().Bool("force", false, "disables version validation errors - might result in corrupted clusters") rootCmd.AddCommand(cmd.NewConfigCmd()) rootCmd.AddCommand(cmd.NewCreateCmd()) diff --git a/cli/internal/cmd/configfetchmeasurements.go b/cli/internal/cmd/configfetchmeasurements.go index 77642d142..1abf66c7f 100644 --- a/cli/internal/cmd/configfetchmeasurements.go +++ b/cli/internal/cmd/configfetchmeasurements.go @@ -45,6 +45,7 @@ type fetchMeasurementsFlags struct { measurementsURL *url.URL signatureURL *url.URL configPath string + force bool } type configFetchMeasurementsCmd struct { @@ -78,9 +79,9 @@ func (cfm *configFetchMeasurementsCmd) configFetchMeasurements( cfm.log.Debugf("Using flags %v", flags) cfm.log.Debugf("Loading configuration file from %q", flags.configPath) - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } if !conf.IsReleaseImage() { @@ -161,10 +162,16 @@ func (cfm *configFetchMeasurementsCmd) parseFetchMeasurementsFlags(cmd *cobra.Co } cfm.log.Debugf("Configuration path is %q", config) + force, err := cmd.Flags().GetBool("force") + if err != nil { + return &fetchMeasurementsFlags{}, fmt.Errorf("parsing force argument: %w", err) + } + return &fetchMeasurementsFlags{ measurementsURL: measurementsURL, signatureURL: measurementsSignatureURL, configPath: config, + force: force, }, nil } diff --git a/cli/internal/cmd/configfetchmeasurements_test.go b/cli/internal/cmd/configfetchmeasurements_test.go index e5a4bcec4..5cdc79eef 100644 --- a/cli/internal/cmd/configfetchmeasurements_test.go +++ b/cli/internal/cmd/configfetchmeasurements_test.go @@ -36,6 +36,7 @@ func TestParseFetchMeasurementsFlags(t *testing.T) { urlFlag string signatureURLFlag string configFlag string + forceFlag bool wantFlags *fetchMeasurementsFlags wantErr bool }{ @@ -74,6 +75,7 @@ func TestParseFetchMeasurementsFlags(t *testing.T) { cmd := newConfigFetchMeasurementsCmd() cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", false, "") // register persistent flag manually if tc.urlFlag != "" { require.NoError(cmd.Flags().Set("url", tc.urlFlag)) @@ -243,10 +245,12 @@ func TestConfigFetchMeasurements(t *testing.T) { cmd := newConfigFetchMeasurementsCmd() cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually fileHandler := file.NewHandler(afero.NewMemMapFs()) gcpConfig := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.GCP) gcpConfig.Image = "v999.999.999" + constants.VersionInfo = "v999.999.999" err := fileHandler.WriteYAML(constants.ConfigFilename, gcpConfig, file.OptMkdirAll) require.NoError(err) diff --git a/cli/internal/cmd/configvalidation.go b/cli/internal/cmd/configvalidation.go deleted file mode 100644 index c6874886e..000000000 --- a/cli/internal/cmd/configvalidation.go +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package cmd - -import ( - "errors" - "fmt" - "io" - - "go.uber.org/multierr" -) - -func displayConfigValidationErrors(errWriter io.Writer, configError error) error { - errs := multierr.Errors(configError) - if errs != nil { - fmt.Fprintln(errWriter, "Problems validating config file:") - for _, err := range errs { - fmt.Fprintln(errWriter, "\t"+err.Error()) - } - fmt.Fprintln(errWriter, "Fix the invalid entries or generate a new configuration using `constellation config generate`") - return errors.New("invalid configuration") - } - return nil -} diff --git a/cli/internal/cmd/create.go b/cli/internal/cmd/create.go index 0ff59d59c..9ff259106 100644 --- a/cli/internal/cmd/create.go +++ b/cli/internal/cmd/create.go @@ -73,9 +73,9 @@ func (c *createCmd) create(cmd *cobra.Command, creator cloudCreator, fileHandler } c.log.Debugf("Loading configuration file from %q", flags.configPath) - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } c.log.Debugf("Checking configuration for warnings") @@ -201,11 +201,18 @@ func (c *createCmd) parseCreateFlags(cmd *cobra.Command) (createFlags, error) { } c.log.Debugf("Configuration path flag is %q", configPath) + force, err := cmd.Flags().GetBool("force") + if err != nil { + return createFlags{}, fmt.Errorf("parsing force argument: %w", err) + } + c.log.Debugf("force flag is %t", force) + return createFlags{ controllerCount: controllerCount, workerCount: workerCount, name: name, configPath: configPath, + force: force, yes: yes, }, nil } @@ -216,6 +223,7 @@ type createFlags struct { workerCount int name string configPath string + force bool yes bool } diff --git a/cli/internal/cmd/create_test.go b/cli/internal/cmd/create_test.go index b559bfb48..025e99f3d 100644 --- a/cli/internal/cmd/create_test.go +++ b/cli/internal/cmd/create_test.go @@ -195,6 +195,8 @@ func TestCreate(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) cmd.SetIn(bytes.NewBufferString(tc.stdin)) cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually + if tc.yesFlag { require.NoError(cmd.Flags().Set("yes", "true")) } diff --git a/cli/internal/cmd/init.go b/cli/internal/cmd/init.go index c299df7f5..1e15229b6 100644 --- a/cli/internal/cmd/init.go +++ b/cli/internal/cmd/init.go @@ -93,9 +93,9 @@ func (i *initCmd) initialize(cmd *cobra.Command, newDialer func(validator *cloud } i.log.Debugf("Using flags: %+v", flags) i.log.Debugf("Loading configuration file from %q", flags.configPath) - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } i.log.Debugf("Checking cluster ID file") @@ -282,10 +282,17 @@ func (i *initCmd) evalFlagArgs(cmd *cobra.Command) (initFlags, error) { } i.log.Debugf("Configuration path flag is %q", configPath) + force, err := cmd.Flags().GetBool("force") + if err != nil { + return initFlags{}, fmt.Errorf("parsing force argument: %w", err) + } + i.log.Debugf("force flag is %t", configPath) + return initFlags{ configPath: configPath, conformance: conformance, masterSecretPath: masterSecretPath, + force: force, }, nil } @@ -294,6 +301,7 @@ type initFlags struct { configPath string masterSecretPath string conformance bool + force bool } // readOrGenerateMasterSecret reads a base64 encoded master secret from file or generates a new 32 byte secret. diff --git a/cli/internal/cmd/init_test.go b/cli/internal/cmd/init_test.go index f4b490460..fb33acefe 100644 --- a/cli/internal/cmd/init_test.go +++ b/cli/internal/cmd/init_test.go @@ -145,6 +145,7 @@ func TestInitialize(t *testing.T) { // Flags cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually // File system preparation fs := afero.NewMemMapFs() @@ -390,6 +391,7 @@ func TestAttestation(t *testing.T) { cmd := NewInitCmd() cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually var out bytes.Buffer cmd.SetOut(&out) var errOut bytes.Buffer diff --git a/cli/internal/cmd/miniup.go b/cli/internal/cmd/miniup.go index 21fddeb4f..4f6b458d0 100644 --- a/cli/internal/cmd/miniup.go +++ b/cli/internal/cmd/miniup.go @@ -179,11 +179,16 @@ func (m *miniUpCmd) prepareConfig(cmd *cobra.Command, fileHandler file.Handler) if err != nil { return nil, err } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return nil, fmt.Errorf("parsing force argument: %w", err) + } + // check for existing config if configPath != "" { - conf, err := config.New(fileHandler, configPath) + conf, err := config.New(fileHandler, configPath, force) if err != nil { - return nil, displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return nil, config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } if conf.GetProvider() != cloudprovider.QEMU { return nil, errors.New("invalid provider for MiniConstellation cluster") diff --git a/cli/internal/cmd/recover.go b/cli/internal/cmd/recover.go index 74d0738c1..bf7e985f7 100644 --- a/cli/internal/cmd/recover.go +++ b/cli/internal/cmd/recover.go @@ -79,9 +79,9 @@ func (r *recoverCmd) recover( } r.log.Debugf("Loading configuration file from %q", flags.configPath) - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } provider := conf.GetProvider() r.log.Debugf("Got provider %s", provider.String()) @@ -202,6 +202,7 @@ type recoverFlags struct { endpoint string secretPath string configPath string + force bool } func (r *recoverCmd) parseRecoverFlags(cmd *cobra.Command, fileHandler file.Handler) (recoverFlags, error) { @@ -232,10 +233,16 @@ func (r *recoverCmd) parseRecoverFlags(cmd *cobra.Command, fileHandler file.Hand } r.log.Debugf("Configuration path flag is %s", configPath) + force, err := cmd.Flags().GetBool("force") + if err != nil { + return recoverFlags{}, fmt.Errorf("parsing force argument: %w", err) + } + return recoverFlags{ endpoint: endpoint, secretPath: masterSecretPath, configPath: configPath, + force: force, }, nil } diff --git a/cli/internal/cmd/recover_test.go b/cli/internal/cmd/recover_test.go index de8f3bd41..51fd4d400 100644 --- a/cli/internal/cmd/recover_test.go +++ b/cli/internal/cmd/recover_test.go @@ -140,6 +140,7 @@ func TestRecover(t *testing.T) { cmd := NewRecoverCmd() cmd.SetContext(context.Background()) cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually out := &bytes.Buffer{} cmd.SetOut(out) cmd.SetErr(out) @@ -225,6 +226,7 @@ func TestParseRecoverFlags(t *testing.T) { cmd := NewRecoverCmd() cmd.Flags().String("config", "", "") // register persistent flag manually + cmd.Flags().Bool("force", false, "") // register persistent flag manually require.NoError(cmd.ParseFlags(tc.args)) fileHandler := file.NewHandler(afero.NewMemMapFs()) diff --git a/cli/internal/cmd/upgradeexecute.go b/cli/internal/cmd/upgradeexecute.go index 1fe931d5b..a0dd58e37 100644 --- a/cli/internal/cmd/upgradeexecute.go +++ b/cli/internal/cmd/upgradeexecute.go @@ -69,9 +69,9 @@ func upgradeExecute(cmd *cobra.Command, imageFetcher imageFetcher, upgrader clou if err != nil { return fmt.Errorf("parsing flags: %w", err) } - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } if flags.helmFlag { @@ -130,7 +130,13 @@ func parseUpgradeExecuteFlags(cmd *cobra.Command) (upgradeExecuteFlags, error) { if err != nil { return upgradeExecuteFlags{}, err } - return upgradeExecuteFlags{configPath: configPath, helmFlag: helmFlag, yes: yes, upgradeTimeout: timeout}, nil + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return upgradeExecuteFlags{}, fmt.Errorf("parsing force argument: %w", err) + } + + return upgradeExecuteFlags{configPath: configPath, helmFlag: helmFlag, yes: yes, upgradeTimeout: timeout, force: force}, nil } type upgradeExecuteFlags struct { @@ -138,6 +144,7 @@ type upgradeExecuteFlags struct { helmFlag bool yes bool upgradeTimeout time.Duration + force bool } type cloudUpgrader interface { diff --git a/cli/internal/cmd/upgradeexecute_test.go b/cli/internal/cmd/upgradeexecute_test.go index 19a6f9e54..6c56017e9 100644 --- a/cli/internal/cmd/upgradeexecute_test.go +++ b/cli/internal/cmd/upgradeexecute_test.go @@ -51,6 +51,7 @@ func TestUpgradeExecute(t *testing.T) { require := require.New(t) cmd := newUpgradeExecuteCmd() cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually handler := file.NewHandler(afero.NewMemMapFs()) cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.Azure) diff --git a/cli/internal/cmd/upgradeplan.go b/cli/internal/cmd/upgradeplan.go index f94a03d8f..79e33a6df 100644 --- a/cli/internal/cmd/upgradeplan.go +++ b/cli/internal/cmd/upgradeplan.go @@ -79,9 +79,9 @@ func (up *upgradePlanCmd) upgradePlan(cmd *cobra.Command, planner upgradePlanner fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags, cliVersion string, ) error { - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, true) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } up.log.Debugf("Read configuration from %q", flags.configPath) // get current image version of the cluster diff --git a/cli/internal/cmd/verify.go b/cli/internal/cmd/verify.go index 215239832..46fdc9159 100644 --- a/cli/internal/cmd/verify.go +++ b/cli/internal/cmd/verify.go @@ -75,9 +75,9 @@ func (v *verifyCmd) verify(cmd *cobra.Command, fileHandler file.Handler, verifyC v.log.Debugf("Using flags: %+v", flags) v.log.Debugf("Loading configuration file from %q", flags.configPath) - conf, err := config.New(fileHandler, flags.configPath) + conf, err := config.New(fileHandler, flags.configPath, flags.force) if err != nil { - return displayConfigValidationErrors(cmd.ErrOrStderr(), err) + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } provider := conf.GetProvider() @@ -133,6 +133,12 @@ func (v *verifyCmd) parseVerifyFlags(cmd *cobra.Command, fileHandler file.Handle } v.log.Debugf("'node-endpoint' flag is %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) + // Get empty values from ID file emptyEndpoint := endpoint == "" emptyIDs := ownerID == "" && clusterID == "" @@ -168,6 +174,7 @@ func (v *verifyCmd) parseVerifyFlags(cmd *cobra.Command, fileHandler file.Handle configPath: configPath, ownerID: ownerID, clusterID: clusterID, + force: force, }, nil } @@ -176,6 +183,7 @@ type verifyFlags struct { ownerID string clusterID string configPath string + force bool } func addPortIfMissing(endpoint string, defaultPort int) (string, error) { diff --git a/cli/internal/cmd/verify_test.go b/cli/internal/cmd/verify_test.go index dcd58b60a..d5f440eeb 100644 --- a/cli/internal/cmd/verify_test.go +++ b/cli/internal/cmd/verify_test.go @@ -141,6 +141,7 @@ func TestVerify(t *testing.T) { cmd := NewVerifyCmd() cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually + cmd.Flags().Bool("force", true, "") // register persistent flag manually out := &bytes.Buffer{} cmd.SetOut(out) cmd.SetErr(&bytes.Buffer{}) diff --git a/debugd/internal/cdbg/cmd/deploy.go b/debugd/internal/cdbg/cmd/deploy.go index d321ebf62..f15992f61 100644 --- a/debugd/internal/cdbg/cmd/deploy.go +++ b/debugd/internal/cdbg/cmd/deploy.go @@ -58,13 +58,18 @@ func runDeploy(cmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("parsing config path argument: %w", err) } + force, err := cmd.Flags().GetBool("force") + if err != nil { + return fmt.Errorf("getting force flag: %w", err) + } + fs := afero.NewOsFs() fileHandler := file.NewHandler(fs) streamer := streamer.New(fs) transfer := filetransfer.New(log, streamer, filetransfer.ShowProgress) - constellationConfig, err := config.FromFile(fileHandler, configName) + constellationConfig, err := config.New(fileHandler, configName, force) if err != nil { - return err + return config.DisplayValidationErrors(cmd.ErrOrStderr(), err) } return deploy(cmd, fileHandler, constellationConfig, transfer, log) diff --git a/debugd/internal/cdbg/cmd/root.go b/debugd/internal/cdbg/cmd/root.go index 1d79ae258..d9d809db6 100644 --- a/debugd/internal/cdbg/cmd/root.go +++ b/debugd/internal/cdbg/cmd/root.go @@ -22,6 +22,8 @@ func newRootCmd() *cobra.Command { It connects to Constellation instances running debugd and deploys a self-compiled version of the bootstrapper.`, } cmd.PersistentFlags().String("config", constants.ConfigFilename, "Constellation config file") + cmd.PersistentFlags().Bool("force", false, "disables version validation errors - might result in corrupted clusters") + cmd.AddCommand(newDeployCmd()) return cmd } diff --git a/go.mod b/go.mod index f11d810b1..55c2ae259 100644 --- a/go.mod +++ b/go.mod @@ -86,6 +86,7 @@ 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 diff --git a/go.sum b/go.sum index e80518717..44c1bc99d 100644 --- a/go.sum +++ b/go.sum @@ -1310,6 +1310,7 @@ 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.sum b/hack/go.sum index f1f1a201f..0df95db22 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -1305,6 +1305,7 @@ 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 new file mode 100644 index 000000000..d63b6c672 --- /dev/null +++ b/internal/compatibility/compatibility.go @@ -0,0 +1,139 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +/* +Package compatibility offers helper functions for comparing and filtering versions. +*/ +package compatibility + +import ( + "errors" + "fmt" + "strings" + + "github.com/edgelesssys/constellation/v2/internal/constants" + "golang.org/x/mod/semver" +) + +var ( + // ErrMajorMismatch signals that the major version of two compared versions don't match. + ErrMajorMismatch = errors.New("missmatching major version") + // 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") +) + +// 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 + } + return "v" + str +} + +// 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) + + if !semver.IsValid(a) || !semver.IsValid(b) { + return ErrSemVer + } + + if semver.Compare(a, b) >= 0 { + return errors.New("current version newer than new version") + } + + aMajor, aMinor, err := parseCanonicalSemver(a) + if err != nil { + return err + } + bMajor, bMinor, err := parseCanonicalSemver(b) + if err != nil { + return err + } + + if aMajor != bMajor { + return ErrMajorMismatch + } + + if bMinor-aMinor > 1 { + return ErrMinorDrift + } + + return nil +} + +// 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) + if !semver.IsValid(binaryVersion) || !semver.IsValid(target) { + return ErrSemVer + } + cliMajor, cliMinor, err := parseCanonicalSemver(binaryVersion) + if err != nil { + return err + } + targetMajor, targetMinor, err := parseCanonicalSemver(target) + if err != nil { + return err + } + + // Major versions always have to match. + if cliMajor != targetMajor { + return ErrMajorMismatch + } + if semver.Compare(binaryVersion, target) == -1 { + return ErrMinorDrift + } + // Abort if minor version drift between CLI and versionA value is greater than 1. + if cliMinor-targetMinor > 1 { + return ErrMinorDrift + } + + return nil +} + +// FilterNewerVersion filters the list of versions to only include versions newer than currentVersion. +func FilterNewerVersion(currentVersion string, newVersions []string) []string { + currentVersion = ensurePrefixV(currentVersion) + var result []string + + for _, image := range newVersions { + image = ensurePrefixV(image) + // check if image is newer than current version + if semver.Compare(image, currentVersion) <= 0 { + continue + } + result = append(result, image) + } + return result +} + +// 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)) + if err != nil { + return "", err + } + return fmt.Sprintf("v%d.%d", major, minor+1), nil +} + +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) + 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 new file mode 100644 index 000000000..6c19c4872 --- /dev/null +++ b/internal/compatibility/compatibility_test.go @@ -0,0 +1,208 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package compatibility + +import ( + "testing" + + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/tj/assert" +) + +func TestFilterNewerVersion(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 := FilterNewerVersion(tc.version, tc.images) + assert.EqualValues(tc.wantImages, compatibleImages) + }) + } +} + +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) + }) + } +} + +func TestCLIWith(t *testing.T) { + testCases := map[string]struct { + cli string + target string + wantError bool + }{ + "success": { + cli: "v0.0.0", + target: "v0.0.0", + }, + "different major version": { + cli: "v1", + target: "v2", + wantError: true, + }, + "major version diff too large": { + cli: "v1.0", + target: "v1.2", + wantError: true, + }, + "a has to be the newer version": { + cli: "v2.4.0", + target: "v2.5.0", + wantError: true, + }, + "pre prelease version ordering is correct": { + cli: "v2.5.0-pre", + target: "v2.4.0", + }, + "pre prelease version ordering is correct #2": { + cli: "v2.4.0", + target: "v2.5.0-pre", + wantError: true, + }, + "pre release versions match": { + cli: "v2.6.0-pre", + target: "v2.6.0-pre", + }, + "pseudo version is newer than first pre release": { + cli: "v2.6.0-pre", + target: "v2.6.0-pre.0.20230125085856-aaaaaaaaaaaa", + wantError: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + constants.VersionInfo = tc.cli + err := BinaryWith(tc.target) + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +} + +func TestIsValidUpgrade(t *testing.T) { + testCases := map[string]struct { + a string + b string + wantError bool + }{ + "success": { + a: "v0.0.0", + b: "v0.1.0", + }, + "different major version": { + a: "v1", + b: "v2", + wantError: true, + }, + "minor version diff too large": { + a: "v1.0", + b: "v1.2", + wantError: true, + }, + "b has to be the newer version": { + a: "v2.5.0", + b: "v2.4.0", + wantError: true, + }, + "pre prelease version ordering is correct": { + a: "v2.4.0", + b: "v2.5.0-pre", + }, + "wrong pre release ordering creates error": { + a: "v2.5.0-pre", + b: "v2.4.0", + wantError: true, + }, + "pre release versions are equal": { + a: "v2.6.0-pre", + b: "v2.6.0-pre", + wantError: true, + }, + "pseudo version is newer than first pre release": { + a: "v2.6.0-pre.0.20230125085856-aaaaaaaaaaaa", + b: "v2.6.0-pre", + wantError: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + err := IsValidUpgrade(tc.a, tc.b) + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0f04e9d64..c936c215f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,7 +58,7 @@ type Config struct { Version string `yaml:"version" validate:"eq=v2"` // description: | // Machine image used to create Constellation nodes. - Image string `yaml:"image" validate:"required"` + Image string `yaml:"image" validate:"required,version_compatibility"` // description: | // Size (in GB) of a node's disk to store the non-volatile state. StateDiskSizeGB int `yaml:"stateDiskSizeGB" validate:"min=0"` @@ -317,7 +317,7 @@ func FromFile(fileHandler file.Handler, name string) (*Config, error) { // 1. Reading config file via provided fileHandler from file with name. // 2. Read secrets from environment variables. // 3. Validate config. -func New(fileHandler file.Handler, name string) (*Config, error) { +func New(fileHandler file.Handler, name string, force bool) (*Config, error) { // Read config file c, err := FromFile(fileHandler, name) if err != nil { @@ -330,7 +330,7 @@ func New(fileHandler file.Handler, name string) (*Config, error) { c.Provider.Azure.ClientSecretValue = clientSecretValue } - return c, c.Validate() + return c, c.Validate(force) } // HasProvider checks whether the config contains the provider. @@ -456,7 +456,7 @@ func (c *Config) DeployCSIDriver() bool { } // Validate checks the config values and returns validation errors. -func (c *Config) Validate() error { +func (c *Config) Validate(force bool) error { trans := ut.New(en.New()).GetFallback() validate := validator.New() if err := en_translations.RegisterDefaultTranslations(validate, trans); err != nil { @@ -493,6 +493,14 @@ func (c *Config) Validate() error { return err } + if err := validate.RegisterTranslation("version_compatibility", trans, registerVersionCompatibilityError, translateVersionCompatibilityError); err != nil { + return err + } + + if err := validate.RegisterTranslation("supported_k8s_version", trans, registerInvalidK8sVersionError, translateInvalidK8sVersionError); err != nil { + return err + } + if err := validate.RegisterValidation("no_placeholders", validateNoPlaceholder); err != nil { return err } @@ -502,6 +510,14 @@ func (c *Config) Validate() error { return err } + versionCompatibilityValidator := validateVersionCompatibility + if force { + versionCompatibilityValidator = returnsTrue + } + if err := validate.RegisterValidation("version_compatibility", versionCompatibilityValidator); err != nil { + return err + } + // register custom validator with label aws_instance_type to validate the AWS instance type from config input. if err := validate.RegisterValidation("aws_instance_type", validateAWSInstanceType); err != nil { return err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ac94ed454..e2b32c52a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -173,7 +173,7 @@ func TestNewWithDefaultOptions(t *testing.T) { } // Test - c, err := New(fileHandler, constants.ConfigFilename) + c, err := New(fileHandler, constants.ConfigFilename, false) if tc.wantErr { assert.Error(err) return @@ -279,7 +279,7 @@ func TestValidate(t *testing.T) { t.Run(name, func(t *testing.T) { assert := assert.New(t) - err := tc.cnf.Validate() + err := tc.cnf.Validate(false) if tc.wantErr { assert.Error(err) assert.Len(multierr.Errors(err), tc.wantErrCount) diff --git a/internal/config/validation.go b/internal/config/validation.go index 1b8c41163..bdda81314 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -8,25 +8,41 @@ package config import ( "bytes" + "errors" "fmt" + "io" "sort" "strings" "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/instancetypes" + "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/versions" + "github.com/edgelesssys/constellation/v2/internal/versionsapi" ut "github.com/go-playground/universal-translator" "github.com/go-playground/validator/v10" + "go.uber.org/multierr" "golang.org/x/mod/semver" ) -func validateK8sVersion(fl validator.FieldLevel) bool { - return versions.IsSupportedK8sVersion(fl.Field().String()) +// DisplayValidationErrors shows all validation errors inside configError as one formatted string. +func DisplayValidationErrors(errWriter io.Writer, configError error) error { + errs := multierr.Errors(configError) + if errs != nil { + fmt.Fprintln(errWriter, "Problems validating config file:") + for _, err := range errs { + fmt.Fprintln(errWriter, "\t"+err.Error()) + } + fmt.Fprintln(errWriter, "Fix the invalid entries or generate a new configuration using `constellation config generate`") + return errors.New("invalid configuration") + } + return nil } func registerInvalidK8sVersionError(ut ut.Translator) error { - return ut.Add("invalid_k8s_version", "{0} specifies an unsupported Kubernetes version. {1}", true) + return ut.Add("supported_k8s_version", "{0} specifies an unsupported Kubernetes version. {1}", true) } func translateInvalidK8sVersionError(ut ut.Translator, fe validator.FieldError) string { @@ -55,7 +71,7 @@ func translateInvalidK8sVersionError(ut ut.Translator, fe validator.FieldError) errorMsg = fmt.Sprintf("The configured version %s is newer than the newest version supported by this CLI: %s.", configured, maxVersion) } - t, _ := ut.T("invalid_k8s_version", fe.Field(), errorMsg) + t, _ := ut.T("supported_k8s_version", fe.Field(), errorMsg) return t } @@ -281,3 +297,56 @@ func getPlaceholderEntries(m Measurements) []uint32 { return placeholders } + +func validateK8sVersion(fl validator.FieldLevel) bool { + return versions.IsSupportedK8sVersion(fl.Field().String()) +} + +func registerVersionCompatibilityError(ut ut.Translator) error { + return ut.Add("version_compatibility", "{0} specifies an invalid version: {1}", true) +} + +func translateVersionCompatibilityError(ut ut.Translator, fe validator.FieldError) string { + err := validateVersionCompatibilityHelper(fe.Field(), fe.Value().(string)) + var msg string + + switch err { + case compatibility.ErrSemVer: + msg = fmt.Sprintf("configured version (%s) does not adhere to SemVer syntax", fe.Value().(string)) + case 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: + 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() + } + + t, _ := ut.T("version_compatibility", fe.Field(), msg) + + return t +} + +// Check that the validated field and the CLI version are not more than one minor version apart. +func validateVersionCompatibility(fl validator.FieldLevel) bool { + if err := validateVersionCompatibilityHelper(fl.FieldName(), fl.Field().String()); err != nil { + return false + } + + return true +} + +func validateVersionCompatibilityHelper(fieldName string, configuredVersion string) error { + if fieldName == "Image" { + imageVersion, err := versionsapi.NewVersionFromShortPath(configuredVersion, versionsapi.VersionKindImage) + if err != nil { + return err + } + configuredVersion = imageVersion.Version + } + + return compatibility.BinaryWith(configuredVersion) +} + +func returnsTrue(fl validator.FieldLevel) bool { + return true +} diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go new file mode 100644 index 000000000..df804300a --- /dev/null +++ b/internal/config/validation_test.go @@ -0,0 +1,51 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package config + +import ( + "testing" + + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/stretchr/testify/assert" +) + +// TestValidateVersionCompatibilityHelper checks that basic version and image short paths are correctly validated. +func TestValidateVersionCompatibilityHelper(t *testing.T) { + testCases := map[string]struct { + cli string + target string + wantError bool + }{ + "full version works": { + cli: "v0.1.0", + target: "v0.0.0", + }, + "short path works": { + cli: "v0.1.0", + target: "ref/main/stream/debug/v0.0.0-pre.0.20230109121528-d24fac00f018", + }, + "minor version difference > 1": { + cli: "0.0.0", + target: "ref/main/stream/debug/v0.2.0-pre.0.20230109121528-d24fac00f018", + wantError: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + constants.VersionInfo = tc.cli + err := validateVersionCompatibilityHelper("Image", tc.target) + if tc.wantError { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +}