diff --git a/cli/internal/cloudcmd/BUILD.bazel b/cli/internal/cloudcmd/BUILD.bazel index a9fd1b1712..115e37fd5d 100644 --- a/cli/internal/cloudcmd/BUILD.bazel +++ b/cli/internal/cloudcmd/BUILD.bazel @@ -6,13 +6,16 @@ go_library( srcs = [ "clients.go", "cloudcmd.go", + "clusterupgrade.go", "create.go", "iam.go", + "iamupgrade.go", "patch.go", "rollback.go", "serviceaccount.go", "terminate.go", - "terraform.go", + "tfupgrade.go", + "tfvars.go", "validators.go", ], importpath = "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd", @@ -31,6 +34,7 @@ go_library( "//internal/cloud/gcpshared", "//internal/cloud/openstack", "//internal/config", + "//internal/constants", "//internal/file", "//internal/imagefetcher", "//internal/role", @@ -45,11 +49,14 @@ go_test( name = "cloudcmd_test", srcs = [ "clients_test.go", + "clusterupgrade_test.go", "create_test.go", "iam_test.go", + "iamupgrade_test.go", "patch_test.go", "rollback_test.go", "terminate_test.go", + "tfupgrade_test.go", "validators_test.go", ], embed = [":cloudcmd"], @@ -60,6 +67,9 @@ go_test( "//internal/cloud/cloudprovider", "//internal/cloud/gcpshared", "//internal/config", + "//internal/constants", + "//internal/file", + "@com_github_spf13_afero//:afero", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", "@org_uber_go_goleak//:goleak", diff --git a/cli/internal/cloudcmd/clients.go b/cli/internal/cloudcmd/clients.go index 9544b9dc06..068fb4eccf 100644 --- a/cli/internal/cloudcmd/clients.go +++ b/cli/internal/cloudcmd/clients.go @@ -42,6 +42,22 @@ type tfIAMClient interface { ShowIAM(ctx context.Context, provider cloudprovider.Provider) (terraform.IAMOutput, error) } +type tfUpgradePlanner interface { + ShowPlan(ctx context.Context, logLevel terraform.LogLevel, output io.Writer) error + Plan(ctx context.Context, logLevel terraform.LogLevel) (bool, error) + PrepareUpgradeWorkspace(embeddedPath, oldWorkingDir, backupDir string, vars terraform.Variables) error +} + +type tfIAMUpgradeClient interface { + tfUpgradePlanner + ApplyIAM(ctx context.Context, csp cloudprovider.Provider, logLevel terraform.LogLevel) (terraform.IAMOutput, error) +} + +type tfClusterUpgradeClient interface { + tfUpgradePlanner + ApplyCluster(ctx context.Context, provider cloudprovider.Provider, logLevel terraform.LogLevel) (terraform.ApplyOutput, error) +} + type libvirtRunner interface { Start(ctx context.Context, containerName, imageName string) error Stop(ctx context.Context) error diff --git a/cli/internal/cloudcmd/clusterupgrade.go b/cli/internal/cloudcmd/clusterupgrade.go new file mode 100644 index 0000000000..b1d305b9c0 --- /dev/null +++ b/cli/internal/cloudcmd/clusterupgrade.go @@ -0,0 +1,87 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/edgelesssys/constellation/v2/internal/file" +) + +// ClusterUpgrader is responsible for performing Terraform migrations on cluster upgrades. +type ClusterUpgrader struct { + tf tfClusterUpgradeClient + policyPatcher policyPatcher + fileHandler file.Handler + existingWorkspace string + upgradeWorkspace string + logLevel terraform.LogLevel +} + +// NewClusterUpgrader initializes and returns a new ClusterUpgrader. +// existingWorkspace is the directory holding the existing Terraform resources. +// upgradeWorkspace is the directory to use for holding temporary files and resources required to apply the upgrade. +func NewClusterUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string, + logLevel terraform.LogLevel, fileHandler file.Handler, +) (*ClusterUpgrader, error) { + tfClient, err := terraform.New(ctx, filepath.Join(upgradeWorkspace, constants.TerraformUpgradeWorkingDir)) + if err != nil { + return nil, fmt.Errorf("setting up terraform client: %w", err) + } + + return &ClusterUpgrader{ + tf: tfClient, + policyPatcher: NewAzurePolicyPatcher(), + fileHandler: fileHandler, + existingWorkspace: existingWorkspace, + upgradeWorkspace: upgradeWorkspace, + logLevel: logLevel, + }, nil +} + +// PlanClusterUpgrade prepares the upgrade workspace and plans the possible Terraform migrations for Constellation's cluster resources (Loadbalancers, VMs, networks etc.). +// In case of possible migrations, the diff is written to outWriter and this function returns true. +func (u *ClusterUpgrader) PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider, +) (bool, error) { + return planUpgrade( + ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars, + filepath.Join("terraform", strings.ToLower(csp.String())), + u.existingWorkspace, + filepath.Join(u.upgradeWorkspace, constants.TerraformUpgradeBackupDir), + ) +} + +// ApplyClusterUpgrade applies the Terraform migrations planned by PlanClusterUpgrade. +// On success, the workspace of the Upgrader replaces the existing Terraform workspace. +func (u *ClusterUpgrader) ApplyClusterUpgrade(ctx context.Context, csp cloudprovider.Provider) (terraform.ApplyOutput, error) { + tfOutput, err := u.tf.ApplyCluster(ctx, csp, u.logLevel) + if err != nil { + return tfOutput, fmt.Errorf("terraform apply: %w", err) + } + if tfOutput.Azure != nil { + if err := u.policyPatcher.Patch(ctx, tfOutput.Azure.AttestationURL); err != nil { + return tfOutput, fmt.Errorf("patching policies: %w", err) + } + } + + if err := moveUpgradeToCurrent( + u.fileHandler, + u.existingWorkspace, + filepath.Join(u.upgradeWorkspace, constants.TerraformUpgradeWorkingDir), + ); err != nil { + return tfOutput, fmt.Errorf("promoting upgrade workspace to current workspace: %w", err) + } + + return tfOutput, nil +} diff --git a/cli/internal/cloudcmd/clusterupgrade_test.go b/cli/internal/cloudcmd/clusterupgrade_test.go new file mode 100644 index 0000000000..8551af8b2e --- /dev/null +++ b/cli/internal/cloudcmd/clusterupgrade_test.go @@ -0,0 +1,203 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "io" + "path/filepath" + "testing" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/edgelesssys/constellation/v2/internal/file" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlanClusterUpgrade(t *testing.T) { + setUpFilesystem := func(existingFiles []string) file.Handler { + fs := afero.NewMemMapFs() + for _, f := range existingFiles { + require.NoError(t, afero.WriteFile(fs, f, []byte{}, 0o644)) + } + + return file.NewHandler(fs) + } + + testCases := map[string]struct { + upgradeID string + tf *tfClusterUpgradeStub + fs file.Handler + want bool + wantErr bool + }{ + "success no diff": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{}, + fs: setUpFilesystem([]string{}), + }, + "success diff": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{ + planDiff: true, + }, + fs: setUpFilesystem([]string{}), + want: true, + }, + "prepare workspace error": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{ + prepareWorkspaceErr: assert.AnError, + }, + fs: setUpFilesystem([]string{}), + wantErr: true, + }, + "plan error": { + tf: &tfClusterUpgradeStub{ + planErr: assert.AnError, + }, + fs: setUpFilesystem([]string{}), + wantErr: true, + }, + "show plan error no diff": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{ + showErr: assert.AnError, + }, + fs: setUpFilesystem([]string{}), + }, + "show plan error diff": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{ + showErr: assert.AnError, + planDiff: true, + }, + fs: setUpFilesystem([]string{}), + wantErr: true, + }, + "workspace not clean": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{}, + fs: setUpFilesystem([]string{filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeBackupDir)}), + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + + u := &ClusterUpgrader{ + tf: tc.tf, + policyPatcher: stubPolicyPatcher{}, + fileHandler: tc.fs, + upgradeWorkspace: filepath.Join(constants.UpgradeDir, tc.upgradeID), + existingWorkspace: "test", + logLevel: terraform.LogLevelDebug, + } + + diff, err := u.PlanClusterUpgrade(context.Background(), io.Discard, &terraform.QEMUVariables{}, cloudprovider.Unknown) + if tc.wantErr { + require.Error(err) + } else { + require.NoError(err) + require.Equal(tc.want, diff) + } + }) + } +} + +func TestApplyClusterUpgrade(t *testing.T) { + setUpFilesystem := func(upgradeID string, existingFiles ...string) file.Handler { + fh := file.NewHandler(afero.NewMemMapFs()) + + require.NoError(t, + fh.Write( + filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformUpgradeWorkingDir, "someFile"), + []byte("some content"), + )) + for _, f := range existingFiles { + require.NoError(t, fh.Write(f, []byte("some content"))) + } + return fh + } + + testCases := map[string]struct { + upgradeID string + tf *tfClusterUpgradeStub + policyPatcher stubPolicyPatcher + fs file.Handler + wantErr bool + }{ + "success": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{}, + fs: setUpFilesystem("1234"), + policyPatcher: stubPolicyPatcher{}, + }, + "apply error": { + upgradeID: "1234", + tf: &tfClusterUpgradeStub{ + applyErr: assert.AnError, + }, + fs: setUpFilesystem("1234"), + policyPatcher: stubPolicyPatcher{}, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := require.New(t) + + tc.tf.file = tc.fs + u := &ClusterUpgrader{ + tf: tc.tf, + policyPatcher: stubPolicyPatcher{}, + fileHandler: tc.fs, + upgradeWorkspace: filepath.Join(constants.UpgradeDir, tc.upgradeID), + existingWorkspace: "test", + logLevel: terraform.LogLevelDebug, + } + + _, err := u.ApplyClusterUpgrade(context.Background(), cloudprovider.Unknown) + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + } + }) + } +} + +type tfClusterUpgradeStub struct { + file file.Handler + applyErr error + planErr error + planDiff bool + showErr error + prepareWorkspaceErr error +} + +func (t *tfClusterUpgradeStub) Plan(_ context.Context, _ terraform.LogLevel) (bool, error) { + return t.planDiff, t.planErr +} + +func (t *tfClusterUpgradeStub) ShowPlan(_ context.Context, _ terraform.LogLevel, _ io.Writer) error { + return t.showErr +} + +func (t *tfClusterUpgradeStub) ApplyCluster(_ context.Context, _ cloudprovider.Provider, _ terraform.LogLevel) (terraform.ApplyOutput, error) { + return terraform.ApplyOutput{}, t.applyErr +} + +func (t *tfClusterUpgradeStub) PrepareUpgradeWorkspace(_, _, _ string, _ terraform.Variables) error { + return t.prepareWorkspaceErr +} diff --git a/cli/internal/cloudcmd/iamupgrade.go b/cli/internal/cloudcmd/iamupgrade.go new file mode 100644 index 0000000000..952efb35aa --- /dev/null +++ b/cli/internal/cloudcmd/iamupgrade.go @@ -0,0 +1,78 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/edgelesssys/constellation/v2/internal/file" +) + +// IAMUpgrader handles upgrades to IAM resources required by Constellation. +type IAMUpgrader struct { + tf tfIAMUpgradeClient + existingWorkspace string + upgradeWorkspace string + fileHandler file.Handler + logLevel terraform.LogLevel +} + +// NewIAMUpgrader creates and initializes a new IAMUpgrader. +// existingWorkspace is the directory holding the existing Terraform resources. +// upgradeWorkspace is the directory to use for holding temporary files and resources required to apply the upgrade. +func NewIAMUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string, + logLevel terraform.LogLevel, fileHandler file.Handler, +) (*IAMUpgrader, error) { + tfClient, err := terraform.New(ctx, filepath.Join(upgradeWorkspace, constants.TerraformIAMUpgradeWorkingDir)) + if err != nil { + return nil, fmt.Errorf("setting up terraform client: %w", err) + } + + return &IAMUpgrader{ + tf: tfClient, + existingWorkspace: existingWorkspace, + upgradeWorkspace: upgradeWorkspace, + fileHandler: fileHandler, + logLevel: logLevel, + }, nil +} + +// PlanIAMUpgrade prepares the upgrade workspace and plans the possible Terraform migrations for Constellation's IAM resources (service accounts, permissions etc.). +// In case of possible migrations, the diff is written to outWriter and this function returns true. +func (u *IAMUpgrader) PlanIAMUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) { + return planUpgrade( + ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars, + filepath.Join("terraform", "iam", strings.ToLower(csp.String())), + u.existingWorkspace, + filepath.Join(u.upgradeWorkspace, constants.TerraformIAMUpgradeBackupDir), + ) +} + +// ApplyIAMUpgrade applies the Terraform IAM migrations planned by PlanIAMUpgrade. +// On success, the workspace of the Upgrader replaces the existing Terraform workspace. +func (u *IAMUpgrader) ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error { + if _, err := u.tf.ApplyIAM(ctx, csp, u.logLevel); err != nil { + return fmt.Errorf("terraform apply: %w", err) + } + + if err := moveUpgradeToCurrent( + u.fileHandler, + u.existingWorkspace, + filepath.Join(u.upgradeWorkspace, constants.TerraformIAMUpgradeWorkingDir), + ); err != nil { + return fmt.Errorf("promoting upgrade workspace to current workspace: %w", err) + } + + return nil +} diff --git a/cli/internal/cloudcmd/iamupgrade_test.go b/cli/internal/cloudcmd/iamupgrade_test.go new file mode 100644 index 0000000000..ef973d8172 --- /dev/null +++ b/cli/internal/cloudcmd/iamupgrade_test.go @@ -0,0 +1,136 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "io" + "path/filepath" + "testing" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/constants" + "github.com/edgelesssys/constellation/v2/internal/file" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIAMMigrate(t *testing.T) { + assert := assert.New(t) + upgradeID := "test-upgrade" + upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformIAMUpgradeWorkingDir) + fs, file := setupMemFSAndFileHandler(t, []string{"terraform.tfvars", "terraform.tfstate"}, []byte("OLD")) + csp := cloudprovider.AWS + + // act + fakeTfClient := &tfIAMUpgradeStub{upgradeID: upgradeID, file: file} + sut := &IAMUpgrader{ + tf: fakeTfClient, + logLevel: terraform.LogLevelDebug, + existingWorkspace: constants.TerraformIAMWorkingDir, + upgradeWorkspace: filepath.Join(constants.UpgradeDir, upgradeID), + fileHandler: file, + } + hasDiff, err := sut.PlanIAMUpgrade(context.Background(), io.Discard, &terraform.QEMUVariables{}, csp) + + // assert + assert.NoError(err) + assert.False(hasDiff) + assertFileExists(t, fs, filepath.Join(upgradeDir, "terraform.tfvars")) + assertFileExists(t, fs, filepath.Join(upgradeDir, "terraform.tfstate")) + + // act + err = sut.ApplyIAMUpgrade(context.Background(), csp) + assert.NoError(err) + + // assert + assertFileReadsContent(t, file, filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfvars"), "NEW") + assertFileReadsContent(t, file, filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfstate"), "NEW") + assertFileDoesntExist(t, fs, filepath.Join(upgradeDir)) +} + +func assertFileReadsContent(t *testing.T, file file.Handler, path string, expectedContent string) { + t.Helper() + bt, err := file.Read(path) + assert.NoError(t, err) + assert.Equal(t, expectedContent, string(bt)) +} + +func assertFileExists(t *testing.T, fs afero.Fs, path string) { + t.Helper() + res, err := fs.Stat(path) + assert.NoError(t, err) + assert.NotNil(t, res) +} + +func assertFileDoesntExist(t *testing.T, fs afero.Fs, path string) { + t.Helper() + res, err := fs.Stat(path) + assert.Error(t, err) + assert.Nil(t, res) +} + +// setupMemFSAndFileHandler sets up a file handler with a memory file system and writes the given files with the given content. +func setupMemFSAndFileHandler(t *testing.T, files []string, content []byte) (afero.Fs, file.Handler) { + fs := afero.NewMemMapFs() + file := file.NewHandler(fs) + err := file.MkdirAll(constants.TerraformIAMWorkingDir) + require.NoError(t, err) + + for _, f := range files { + err := file.Write(filepath.Join(constants.TerraformIAMWorkingDir, f), content) + require.NoError(t, err) + } + return fs, file +} + +type tfIAMUpgradeStub struct { + upgradeID string + file file.Handler + applyErr error + planErr error + planDiff bool + showErr error + prepareWorkspaceErr error +} + +func (t *tfIAMUpgradeStub) Plan(_ context.Context, _ terraform.LogLevel) (bool, error) { + return t.planDiff, t.planErr +} + +func (t *tfIAMUpgradeStub) ShowPlan(_ context.Context, _ terraform.LogLevel, _ io.Writer) error { + return t.showErr +} + +func (t *tfIAMUpgradeStub) ApplyIAM(_ context.Context, _ cloudprovider.Provider, _ terraform.LogLevel) (terraform.IAMOutput, error) { + if t.applyErr != nil { + return terraform.IAMOutput{}, t.applyErr + } + + upgradeDir := filepath.Join(constants.UpgradeDir, t.upgradeID, constants.TerraformIAMUpgradeWorkingDir) + if err := t.file.Write(filepath.Join(upgradeDir, "terraform.tfvars"), []byte("NEW"), file.OptOverwrite); err != nil { + return terraform.IAMOutput{}, err + } + if err := t.file.Write(filepath.Join(upgradeDir, "terraform.tfstate"), []byte("NEW"), file.OptOverwrite); err != nil { + return terraform.IAMOutput{}, err + } + return terraform.IAMOutput{}, nil +} + +func (t *tfIAMUpgradeStub) PrepareUpgradeWorkspace(_, _, _ string, _ terraform.Variables) error { + if t.prepareWorkspaceErr != nil { + return t.prepareWorkspaceErr + } + + upgradeDir := filepath.Join(constants.UpgradeDir, t.upgradeID, constants.TerraformIAMUpgradeWorkingDir) + if err := t.file.Write(filepath.Join(upgradeDir, "terraform.tfvars"), []byte("OLD")); err != nil { + return err + } + return t.file.Write(filepath.Join(upgradeDir, "terraform.tfstate"), []byte("OLD")) +} diff --git a/cli/internal/cloudcmd/tfupgrade.go b/cli/internal/cloudcmd/tfupgrade.go new file mode 100644 index 0000000000..dcc7769915 --- /dev/null +++ b/cli/internal/cloudcmd/tfupgrade.go @@ -0,0 +1,82 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/file" +) + +// planUpgrade prepares a workspace and plans the possible Terraform migrations. +// In case of possible migrations, the diff is written to outWriter and this function returns true. +func planUpgrade( + ctx context.Context, tfClient tfUpgradePlanner, fileHandler file.Handler, + outWriter io.Writer, logLevel terraform.LogLevel, vars terraform.Variables, + templateDir, existingWorkspace, backupDir string, +) (bool, error) { + if err := ensureFileNotExist(fileHandler, backupDir); err != nil { + return false, fmt.Errorf("workspace is not clean: %w", err) + } + + // Prepare the new Terraform workspace and backup the old one + err := tfClient.PrepareUpgradeWorkspace( + templateDir, + existingWorkspace, + backupDir, + vars, + ) + if err != nil { + return false, fmt.Errorf("preparing terraform workspace: %w", err) + } + + hasDiff, err := tfClient.Plan(ctx, logLevel) + if err != nil { + return false, fmt.Errorf("terraform plan: %w", err) + } + + if hasDiff { + if err := tfClient.ShowPlan(ctx, logLevel, outWriter); err != nil { + return false, fmt.Errorf("terraform show plan: %w", err) + } + } + + return hasDiff, nil +} + +// moveUpgradeToCurrent replaces the an existing Terraform workspace with a workspace holding migrated Terraform resources. +func moveUpgradeToCurrent(fileHandler file.Handler, existingWorkspace, upgradeWorkingDir string) error { + if err := fileHandler.RemoveAll(existingWorkspace); err != nil { + return fmt.Errorf("removing old terraform directory: %w", err) + } + if err := fileHandler.CopyDir( + upgradeWorkingDir, + existingWorkspace, + ); err != nil { + return fmt.Errorf("replacing old terraform directory with new one: %w", err) + } + + if err := fileHandler.RemoveAll(upgradeWorkingDir); err != nil { + return fmt.Errorf("removing terraform upgrade directory: %w", err) + } + return nil +} + +// ensureFileNotExist checks if a single file or directory does not exist, returning an error if it does. +func ensureFileNotExist(fileHandler file.Handler, fileName string) error { + if _, err := fileHandler.Stat(fileName); err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("checking %q: %w", fileName, err) + } + return nil + } + return fmt.Errorf("%q already exists", fileName) +} diff --git a/cli/internal/cloudcmd/tfupgrade_test.go b/cli/internal/cloudcmd/tfupgrade_test.go new file mode 100644 index 0000000000..b8869e2511 --- /dev/null +++ b/cli/internal/cloudcmd/tfupgrade_test.go @@ -0,0 +1,222 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package cloudcmd + +import ( + "context" + "io" + "testing" + + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" + "github.com/edgelesssys/constellation/v2/internal/file" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPlanUpgrade(t *testing.T) { + testCases := map[string]struct { + prepareFs func(require *require.Assertions) file.Handler + tf *stubUpgradePlanner + wantDiff bool + wantErr bool + }{ + "success no diff": { + prepareFs: func(require *require.Assertions) file.Handler { + return file.NewHandler(afero.NewMemMapFs()) + }, + tf: &stubUpgradePlanner{}, + }, + "success diff": { + prepareFs: func(require *require.Assertions) file.Handler { + return file.NewHandler(afero.NewMemMapFs()) + }, + tf: &stubUpgradePlanner{ + planDiff: true, + }, + wantDiff: true, + }, + "workspace not clean": { + prepareFs: func(require *require.Assertions) file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + require.NoError(fs.MkdirAll("backup")) + return fs + }, + tf: &stubUpgradePlanner{}, + wantErr: true, + }, + "prepare workspace error": { + prepareFs: func(require *require.Assertions) file.Handler { + return file.NewHandler(afero.NewMemMapFs()) + }, + tf: &stubUpgradePlanner{ + prepareWorkspaceErr: assert.AnError, + }, + wantErr: true, + }, + "plan error": { + prepareFs: func(require *require.Assertions) file.Handler { + return file.NewHandler(afero.NewMemMapFs()) + }, + tf: &stubUpgradePlanner{ + planErr: assert.AnError, + }, + wantErr: true, + }, + "show plan error": { + prepareFs: func(require *require.Assertions) file.Handler { + return file.NewHandler(afero.NewMemMapFs()) + }, + tf: &stubUpgradePlanner{ + planDiff: true, + showPlanErr: assert.AnError, + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + fs := tc.prepareFs(require.New(t)) + + hasDiff, err := planUpgrade( + context.Background(), tc.tf, fs, io.Discard, terraform.LogLevelDebug, + &terraform.QEMUVariables{}, + "existing", "upgrade", "backup", + ) + if tc.wantErr { + assert.Error(err) + return + } + assert.NoError(err) + assert.Equal(tc.wantDiff, hasDiff) + }) + } +} + +func TestMoveUpgradeToCurrent(t *testing.T) { + existingWorkspace := "foo" + upgradeWorkingDir := "bar" + + testCases := map[string]struct { + prepareFs func(require *require.Assertions) file.Handler + wantErr bool + }{ + "success": { + prepareFs: func(require *require.Assertions) file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + require.NoError(fs.MkdirAll(existingWorkspace)) + require.NoError(fs.MkdirAll(upgradeWorkingDir)) + return fs + }, + }, + "old workspace does not exist": { + prepareFs: func(require *require.Assertions) file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + require.NoError(fs.MkdirAll(upgradeWorkingDir)) + return fs + }, + }, + "upgrade working dir does not exist": { + prepareFs: func(require *require.Assertions) file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + require.NoError(fs.MkdirAll(existingWorkspace)) + return fs + }, + wantErr: true, + }, + "read only file system": { + prepareFs: func(require *require.Assertions) file.Handler { + memFS := afero.NewMemMapFs() + fs := file.NewHandler(memFS) + require.NoError(fs.MkdirAll(existingWorkspace)) + require.NoError(fs.MkdirAll(upgradeWorkingDir)) + + return file.NewHandler(afero.NewReadOnlyFs(memFS)) + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + fs := tc.prepareFs(require.New(t)) + + err := moveUpgradeToCurrent(fs, existingWorkspace, upgradeWorkingDir) + if tc.wantErr { + assert.Error(err) + return + } + assert.NoError(err) + }) + } +} + +func TestEnsureFileNotExist(t *testing.T) { + testCases := map[string]struct { + fs file.Handler + fileName string + wantErr bool + }{ + "file does not exist": { + fs: file.NewHandler(afero.NewMemMapFs()), + fileName: "foo", + }, + "file exists": { + fs: func() file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + err := fs.Write("foo", []byte{}) + require.NoError(t, err) + return fs + }(), + fileName: "foo", + wantErr: true, + }, + "directory exists": { + fs: func() file.Handler { + fs := file.NewHandler(afero.NewMemMapFs()) + err := fs.MkdirAll("foo/bar") + require.NoError(t, err) + return fs + }(), + fileName: "foo/bar", + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + err := ensureFileNotExist(tc.fs, tc.fileName) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +type stubUpgradePlanner struct { + prepareWorkspaceErr error + planDiff bool + planErr error + showPlanErr error +} + +func (s *stubUpgradePlanner) PrepareUpgradeWorkspace(_, _ string, _ string, _ terraform.Variables) error { + return s.prepareWorkspaceErr +} + +func (s *stubUpgradePlanner) Plan(_ context.Context, _ terraform.LogLevel) (bool, error) { + return s.planDiff, s.planErr +} + +func (s *stubUpgradePlanner) ShowPlan(_ context.Context, _ terraform.LogLevel, _ io.Writer) error { + return s.showPlanErr +} diff --git a/cli/internal/cloudcmd/terraform.go b/cli/internal/cloudcmd/tfvars.go similarity index 73% rename from cli/internal/cloudcmd/terraform.go rename to cli/internal/cloudcmd/tfvars.go index 98ba015f70..c2e08f4527 100644 --- a/cli/internal/cloudcmd/terraform.go +++ b/cli/internal/cloudcmd/tfvars.go @@ -8,12 +8,15 @@ package cloudcmd import ( "fmt" + "path/filepath" "strings" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" "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/role" ) @@ -25,19 +28,54 @@ func TerraformUpgradeVars(conf *config.Config) (terraform.Variables, error) { // For AWS, we enforce some basic constraints on the image variable. // For Azure, the provider enforces the format below. // For GCP, any placeholder works. + var vars terraform.Variables switch conf.GetProvider() { case cloudprovider.AWS: - vars := awsTerraformVars(conf, "ami-placeholder") - return vars, nil + vars = awsTerraformVars(conf, "ami-placeholder") case cloudprovider.Azure: - vars := azureTerraformVars(conf, "/communityGalleries/myGalleryName/images/myImageName/versions/latest") - return vars, nil + vars = azureTerraformVars(conf, "/communityGalleries/myGalleryName/images/myImageName/versions/latest") case cloudprovider.GCP: - vars := gcpTerraformVars(conf, "placeholder") - return vars, nil + vars = gcpTerraformVars(conf, "placeholder") default: return nil, fmt.Errorf("unsupported provider: %s", conf.GetProvider()) } + return vars, nil +} + +// TerraformIAMUpgradeVars returns variables required to execute IAM upgrades with Terraform. +func TerraformIAMUpgradeVars(conf *config.Config, fileHandler file.Handler) (terraform.Variables, error) { + // Load the tfvars of the existing IAM workspace. + // Ideally we would only load values from the config file, but this currently does not hold all values required. + // This should be refactored in the future. + oldVarBytes, err := fileHandler.Read(filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfvars")) + if err != nil { + return nil, fmt.Errorf("reading existing IAM workspace: %w", err) + } + + var vars terraform.Variables + switch conf.GetProvider() { + case cloudprovider.AWS: + var oldVars terraform.AWSIAMVariables + if err := terraform.VariablesFromBytes(oldVarBytes, &oldVars); err != nil { + return nil, fmt.Errorf("parsing existing IAM workspace: %w", err) + } + vars = awsTerraformIAMVars(conf, oldVars) + case cloudprovider.Azure: + var oldVars terraform.AzureIAMVariables + if err := terraform.VariablesFromBytes(oldVarBytes, &oldVars); err != nil { + return nil, fmt.Errorf("parsing existing IAM workspace: %w", err) + } + vars = azureTerraformIAMVars(conf, oldVars) + case cloudprovider.GCP: + var oldVars terraform.GCPIAMVariables + if err := terraform.VariablesFromBytes(oldVarBytes, &oldVars); err != nil { + return nil, fmt.Errorf("parsing existing IAM workspace: %w", err) + } + vars = gcpTerraformIAMVars(conf, oldVars) + default: + return nil, fmt.Errorf("unsupported provider: %s", conf.GetProvider()) + } + return vars, nil } // awsTerraformVars provides variables required to execute the Terraform scripts. @@ -68,6 +106,13 @@ func awsTerraformVars(conf *config.Config, imageRef string) *terraform.AWSCluste } } +func awsTerraformIAMVars(conf *config.Config, oldVars terraform.AWSIAMVariables) *terraform.AWSIAMVariables { + return &terraform.AWSIAMVariables{ + Region: conf.Provider.AWS.Region, + Prefix: oldVars.Prefix, + } +} + // azureTerraformVars provides variables required to execute the Terraform scripts. // It should be the only place to declare the Azure variables. func azureTerraformVars(conf *config.Config, imageRef string) *terraform.AzureClusterVariables { @@ -104,6 +149,14 @@ func azureTerraformVars(conf *config.Config, imageRef string) *terraform.AzureCl return vars } +func azureTerraformIAMVars(conf *config.Config, oldVars terraform.AzureIAMVariables) *terraform.AzureIAMVariables { + return &terraform.AzureIAMVariables{ + Region: conf.Provider.Azure.Location, + ServicePrincipal: oldVars.ServicePrincipal, + ResourceGroup: conf.Provider.Azure.ResourceGroup, + } +} + // gcpTerraformVars provides variables required to execute the Terraform scripts. // It should be the only place to declare the GCP variables. func gcpTerraformVars(conf *config.Config, imageRef string) *terraform.GCPClusterVariables { @@ -130,6 +183,15 @@ func gcpTerraformVars(conf *config.Config, imageRef string) *terraform.GCPCluste } } +func gcpTerraformIAMVars(conf *config.Config, oldVars terraform.GCPIAMVariables) *terraform.GCPIAMVariables { + return &terraform.GCPIAMVariables{ + Project: conf.Provider.GCP.Project, + Region: conf.Provider.GCP.Region, + Zone: conf.Provider.GCP.Zone, + ServiceAccountID: oldVars.ServiceAccountID, + } +} + // openStackTerraformVars provides variables required to execute the Terraform scripts. // It should be the only place to declare the OpenStack variables. func openStackTerraformVars(conf *config.Config, imageRef string) *terraform.OpenStackClusterVariables { diff --git a/cli/internal/cmd/BUILD.bazel b/cli/internal/cmd/BUILD.bazel index e7a1c69bc5..ef598f8ccf 100644 --- a/cli/internal/cmd/BUILD.bazel +++ b/cli/internal/cmd/BUILD.bazel @@ -27,7 +27,6 @@ go_library( "spinner.go", "status.go", "terminate.go", - "tfmigrationclient.go", "upgrade.go", "upgradeapply.go", "upgradecheck.go", @@ -48,7 +47,6 @@ go_library( "//cli/internal/kubecmd", "//cli/internal/libvirt", "//cli/internal/terraform", - "//cli/internal/upgrade", "//disk-mapper/recoverproto", "//internal/api/attestationconfigapi", "//internal/api/fetcher", @@ -139,7 +137,6 @@ go_test( "//cli/internal/helm", "//cli/internal/kubecmd", "//cli/internal/terraform", - "//cli/internal/upgrade", "//disk-mapper/recoverproto", "//internal/api/attestationconfigapi", "//internal/api/versionsapi", diff --git a/cli/internal/cmd/iamupgradeapply.go b/cli/internal/cmd/iamupgradeapply.go index 6cf26c53eb..07436d883d 100644 --- a/cli/internal/cmd/iamupgradeapply.go +++ b/cli/internal/cmd/iamupgradeapply.go @@ -6,11 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only package cmd import ( + "context" "errors" "fmt" + "io" + "path/filepath" + "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/config" @@ -50,6 +53,12 @@ func newIAMUpgradeApplyCmd() *cobra.Command { return cmd } +type iamUpgradeApplyCmd struct { + fileHandler file.Handler + configFetcher attestationconfigapi.Fetcher + log debugLog +} + func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error { force, err := cmd.Flags().GetBool("force") if err != nil { @@ -57,17 +66,16 @@ func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error { } fileHandler := file.NewHandler(afero.NewOsFs()) configFetcher := attestationconfigapi.NewFetcher() - conf, err := config.New(fileHandler, constants.ConfigFilename, configFetcher, force) - var configValidationErr *config.ValidationError - if errors.As(err, &configValidationErr) { - cmd.PrintErrln(configValidationErr.LongMessage()) - } - if err != nil { - return err - } upgradeID := generateUpgradeID(upgradeCmdKindIAM) - iamMigrateCmd, err := upgrade.NewIAMMigrateCmd(cmd.Context(), constants.TerraformIAMWorkingDir, constants.UpgradeDir, upgradeID, conf.GetProvider(), terraform.LogLevelDebug) + upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID) + iamMigrateCmd, err := cloudcmd.NewIAMUpgrader( + cmd.Context(), + constants.TerraformIAMWorkingDir, + upgradeDir, + terraform.LogLevelDebug, + fileHandler, + ) if err != nil { return fmt.Errorf("setting up IAM migration command: %w", err) } @@ -76,16 +84,71 @@ func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("setting up logger: %w", err) } - migrator := &tfMigrationClient{log} yes, err := cmd.Flags().GetBool("yes") if err != nil { return err } - if err := migrator.applyMigration(cmd, constants.UpgradeDir, file.NewHandler(afero.NewOsFs()), iamMigrateCmd, yes); err != nil { - return fmt.Errorf("applying IAM migration: %w", err) + + i := iamUpgradeApplyCmd{ + fileHandler: fileHandler, + configFetcher: configFetcher, + log: log, + } + + return i.iamUpgradeApply(cmd, iamMigrateCmd, upgradeDir, force, yes) +} + +func (i iamUpgradeApplyCmd) iamUpgradeApply(cmd *cobra.Command, iamUpgrader iamUpgrader, upgradeDir string, force, yes bool) error { + conf, err := config.New(i.fileHandler, constants.ConfigFilename, i.configFetcher, force) + var configValidationErr *config.ValidationError + if errors.As(err, &configValidationErr) { + cmd.PrintErrln(configValidationErr.LongMessage()) + } + if err != nil { + return err + } + + vars, err := cloudcmd.TerraformIAMUpgradeVars(conf, i.fileHandler) + if err != nil { + return fmt.Errorf("getting terraform variables: %w", err) + } + hasDiff, err := iamUpgrader.PlanIAMUpgrade(cmd.Context(), cmd.OutOrStderr(), vars, conf.GetProvider()) + if err != nil { + return err + } + if !hasDiff && !force { + cmd.Println("No IAM migrations necessary.") + return nil + } + + // If there are any Terraform migrations to apply, ask for confirmation + cmd.Println("The IAM upgrade requires a migration by applying an updated Terraform template. Please manually review the suggested changes.") + if !yes { + ok, err := askToConfirm(cmd, "Do you want to apply the IAM upgrade?") + if err != nil { + return fmt.Errorf("asking for confirmation: %w", err) + } + if !ok { + cmd.Println("Aborting upgrade.") + // Remove the upgrade directory + if err := i.fileHandler.RemoveAll(upgradeDir); err != nil { + return fmt.Errorf("cleaning up upgrade directory %s: %w", upgradeDir, err) + } + return errors.New("IAM upgrade aborted by user") + } + } + i.log.Debugf("Applying Terraform IAM migrations") + if err := iamUpgrader.ApplyIAMUpgrade(cmd.Context(), conf.GetProvider()); err != nil { + return fmt.Errorf("applying terraform migrations: %w", err) } cmd.Println("IAM profile successfully applied.") + return nil } + +type iamUpgrader interface { + PlanIAMUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) + ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error +} diff --git a/cli/internal/cmd/status.go b/cli/internal/cmd/status.go index 6a64c78ebd..20e6f57c57 100644 --- a/cli/internal/cmd/status.go +++ b/cli/internal/cmd/status.go @@ -53,10 +53,8 @@ func runStatus(cmd *cobra.Command, _ []string) error { fileHandler := file.NewHandler(afero.NewOsFs()) - // need helm client to fetch service versions. - // The client used here, doesn't need to know the current workspace. - // It may be refactored in the future for easier usage. - helmClient, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.UpgradeDir, constants.AdminConfFilename, constants.HelmNamespace, log) + // set up helm client to fetch service versions + helmClient, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.AdminConfFilename, constants.HelmNamespace, log) if err != nil { return fmt.Errorf("setting up helm client: %w", err) } diff --git a/cli/internal/cmd/tfmigrationclient.go b/cli/internal/cmd/tfmigrationclient.go deleted file mode 100644 index 06722e50ba..0000000000 --- a/cli/internal/cmd/tfmigrationclient.go +++ /dev/null @@ -1,79 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package cmd - -import ( - "context" - "fmt" - "io" - - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" - "github.com/edgelesssys/constellation/v2/internal/file" - "github.com/spf13/cobra" -) - -// tfMigrationClient is a client for planning and applying Terraform migrations. -type tfMigrationClient struct { - log debugLog -} - -// planMigration checks for Terraform migrations and asks for confirmation if there are any. The user input is returned as confirmedDiff. -// adapted from migrateTerraform(). -func (u *tfMigrationClient) planMigration(cmd *cobra.Command, file file.Handler, migrateCmd tfMigrationCmd) (hasDiff bool, err error) { - u.log.Debugf("Planning %s", migrateCmd.String()) - if err := migrateCmd.CheckTerraformMigrations(file); err != nil { - return false, fmt.Errorf("checking workspace: %w", err) - } - hasDiff, err = migrateCmd.Plan(cmd.Context(), file, cmd.OutOrStdout()) - if err != nil { - return hasDiff, fmt.Errorf("planning terraform migrations: %w", err) - } - return hasDiff, nil -} - -// applyMigration plans and then applies the Terraform migration. The user is asked for confirmation if there are any changes. -// adapted from migrateTerraform(). -func (u *tfMigrationClient) applyMigration(cmd *cobra.Command, upgradeWorkspace string, file file.Handler, migrateCmd tfMigrationCmd, yesFlag bool) error { - hasDiff, err := u.planMigration(cmd, file, migrateCmd) - if err != nil { - return err - } - if hasDiff { - // If there are any Terraform migrations to apply, ask for confirmation - fmt.Fprintf(cmd.OutOrStdout(), "The %s upgrade requires a migration by applying an updated Terraform template. Please manually review the suggested changes.\n", migrateCmd.String()) - if !yesFlag { - ok, err := askToConfirm(cmd, fmt.Sprintf("Do you want to apply the %s?", migrateCmd.String())) - if err != nil { - return fmt.Errorf("asking for confirmation: %w", err) - } - if !ok { - cmd.Println("Aborting upgrade.") - if err := upgrade.CleanUpTerraformMigrations(upgradeWorkspace, migrateCmd.UpgradeID(), file); err != nil { - return fmt.Errorf("cleaning up workspace: %w", err) - } - return fmt.Errorf("aborted by user") - } - } - u.log.Debugf("Applying Terraform %s migrations", migrateCmd.String()) - err := migrateCmd.Apply(cmd.Context(), file) - if err != nil { - return fmt.Errorf("applying terraform migrations: %w", err) - } - } else { - u.log.Debugf("No Terraform diff detected") - } - return nil -} - -// tfMigrationCmd is an interface for all terraform upgrade / migration commands. -type tfMigrationCmd interface { - CheckTerraformMigrations(file file.Handler) error - Plan(ctx context.Context, file file.Handler, outWriter io.Writer) (bool, error) - Apply(ctx context.Context, fileHandler file.Handler) error - String() string - UpgradeID() string -} diff --git a/cli/internal/cmd/upgradeapply.go b/cli/internal/cmd/upgradeapply.go index 4b04980b38..f8723e13d0 100644 --- a/cli/internal/cmd/upgradeapply.go +++ b/cli/internal/cmd/upgradeapply.go @@ -10,6 +10,7 @@ import ( "context" "errors" "fmt" + "io" "path/filepath" "time" @@ -19,7 +20,6 @@ import ( "github.com/edgelesssys/constellation/v2/cli/internal/helm" "github.com/edgelesssys/constellation/v2/cli/internal/kubecmd" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" @@ -61,6 +61,11 @@ func newUpgradeApplyCmd() *cobra.Command { } func runUpgradeApply(cmd *cobra.Command, _ []string) error { + flags, err := parseUpgradeApplyFlags(cmd) + if err != nil { + return fmt.Errorf("parsing flags: %w", err) + } + log, err := newCLILogger(cmd) if err != nil { return fmt.Errorf("creating logger: %w", err) @@ -75,53 +80,55 @@ func runUpgradeApply(cmd *cobra.Command, _ []string) error { return err } - helmUpgrader, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.UpgradeDir, constants.AdminConfFilename, constants.HelmNamespace, log) + helmUpgrader, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.AdminConfFilename, constants.HelmNamespace, log) if err != nil { return fmt.Errorf("setting up helm client: %w", err) } configFetcher := attestationconfigapi.NewFetcher() - // Set up two Terraform clients. They need to be configured with different workspaces - // One for upgrading existing resources - tfUpgrader, err := terraform.New(cmd.Context(), filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformUpgradeWorkingDir)) + // Set up terraform upgrader + upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID) + clusterUpgrader, err := cloudcmd.NewClusterUpgrader( + cmd.Context(), + constants.TerraformWorkingDir, + upgradeDir, + flags.terraformLogLevel, + fileHandler, + ) if err != nil { - return fmt.Errorf("setting up terraform client: %w", err) + return fmt.Errorf("setting up cluster upgrader: %w", err) } - // And one for showing existing resources + + // Set up terraform client to show existing cluster resources and information required for Helm upgrades tfShower, err := terraform.New(cmd.Context(), constants.TerraformWorkingDir) if err != nil { return fmt.Errorf("setting up terraform client: %w", err) } applyCmd := upgradeApplyCmd{ - helmUpgrader: helmUpgrader, - kubeUpgrader: kubeUpgrader, - terraformUpgrader: upgrade.NewTerraformUpgrader(tfUpgrader, cmd.OutOrStdout(), fileHandler, upgradeID), - configFetcher: configFetcher, - clusterShower: tfShower, - fileHandler: fileHandler, - log: log, - } - return applyCmd.upgradeApply(cmd) + helmUpgrader: helmUpgrader, + kubeUpgrader: kubeUpgrader, + clusterUpgrader: clusterUpgrader, + configFetcher: configFetcher, + clusterShower: tfShower, + fileHandler: fileHandler, + log: log, + } + return applyCmd.upgradeApply(cmd, upgradeDir, flags) } type upgradeApplyCmd struct { - helmUpgrader helmUpgrader - kubeUpgrader kubernetesUpgrader - terraformUpgrader terraformUpgrader - configFetcher attestationconfigapi.Fetcher - clusterShower clusterShower - fileHandler file.Handler - log debugLog + helmUpgrader helmUpgrader + kubeUpgrader kubernetesUpgrader + clusterUpgrader clusterUpgrader + configFetcher attestationconfigapi.Fetcher + clusterShower clusterShower + fileHandler file.Handler + log debugLog } -func (u *upgradeApplyCmd) upgradeApply(cmd *cobra.Command) error { - flags, err := parseUpgradeApplyFlags(cmd) - if err != nil { - return fmt.Errorf("parsing flags: %w", err) - } - +func (u *upgradeApplyCmd) upgradeApply(cmd *cobra.Command, upgradeDir string, flags upgradeApplyFlags) error { conf, err := config.New(u.fileHandler, constants.ConfigFilename, u.configFetcher, flags.force) var configValidationErr *config.ValidationError if errors.As(err, &configValidationErr) { @@ -166,8 +173,7 @@ func (u *upgradeApplyCmd) upgradeApply(cmd *cobra.Command) error { return fmt.Errorf("upgrading measurements: %w", err) } - // not moving existing Terraform migrator because of planned apply refactor - tfOutput, err := u.migrateTerraform(cmd, conf, flags) + tfOutput, err := u.migrateTerraform(cmd, conf, upgradeDir, flags) if err != nil { return fmt.Errorf("performing Terraform migrations: %w", err) } @@ -192,7 +198,7 @@ func (u *upgradeApplyCmd) upgradeApply(cmd *cobra.Command) error { } var upgradeErr *compatibility.InvalidUpgradeError - err = u.handleServiceUpgrade(cmd, conf, idFile, tfOutput, validK8sVersion, flags) + err = u.handleServiceUpgrade(cmd, conf, idFile, tfOutput, validK8sVersion, upgradeDir, flags) switch { case errors.As(err, &upgradeErr): cmd.PrintErrln(err) @@ -231,29 +237,16 @@ func diffAttestationCfg(currentAttestationCfg config.AttestationCfg, newAttestat // migrateTerraform checks if the Constellation version the cluster is being upgraded to requires a migration // of cloud resources with Terraform. If so, the migration is performed. -func (u *upgradeApplyCmd) migrateTerraform( - cmd *cobra.Command, conf *config.Config, flags upgradeApplyFlags, +func (u *upgradeApplyCmd) migrateTerraform(cmd *cobra.Command, conf *config.Config, upgradeDir string, flags upgradeApplyFlags, ) (res terraform.ApplyOutput, err error) { u.log.Debugf("Planning Terraform migrations") - if err := u.terraformUpgrader.CheckTerraformMigrations(constants.UpgradeDir); err != nil { - return res, fmt.Errorf("checking workspace: %w", err) - } - vars, err := cloudcmd.TerraformUpgradeVars(conf) if err != nil { return res, fmt.Errorf("parsing upgrade variables: %w", err) } u.log.Debugf("Using Terraform variables:\n%v", vars) - opts := upgrade.TerraformUpgradeOptions{ - LogLevel: flags.terraformLogLevel, - CSP: conf.GetProvider(), - Vars: vars, - TFWorkspace: constants.TerraformWorkingDir, - UpgradeWorkspace: constants.UpgradeDir, - } - // Check if there are any Terraform migrations to apply // Add manual migrations here if required @@ -264,7 +257,7 @@ func (u *upgradeApplyCmd) migrateTerraform( // u.upgrader.AddManualStateMigration(migration) // } - hasDiff, err := u.terraformUpgrader.PlanTerraformMigrations(cmd.Context(), opts) + hasDiff, err := u.clusterUpgrader.PlanClusterUpgrade(cmd.Context(), cmd.OutOrStdout(), vars, conf.GetProvider()) if err != nil { return res, fmt.Errorf("planning terraform migrations: %w", err) } @@ -279,28 +272,28 @@ func (u *upgradeApplyCmd) migrateTerraform( } if !ok { cmd.Println("Aborting upgrade.") - if err := u.terraformUpgrader.CleanUpTerraformMigrations(constants.UpgradeDir); err != nil { - return res, fmt.Errorf("cleaning up workspace: %w", err) + // Remove the upgrade directory + if err := u.fileHandler.RemoveAll(upgradeDir); err != nil { + return res, fmt.Errorf("cleaning up upgrade directory %s: %w", upgradeDir, err) } - return res, fmt.Errorf("aborted by user") + return res, fmt.Errorf("cluster upgrade aborted by user") } } u.log.Debugf("Applying Terraform migrations") - tfOutput, err := u.terraformUpgrader.ApplyTerraformMigrations(cmd.Context(), opts) + tfOutput, err := u.clusterUpgrader.ApplyClusterUpgrade(cmd.Context(), conf.GetProvider()) if err != nil { return tfOutput, fmt.Errorf("applying terraform migrations: %w", err) } - // Patch MAA policy if we applied an Azure upgrade. - newIDFile := newIDFile(opts, tfOutput) - if err := mergeClusterIDFile(constants.ClusterIDsFilename, newIDFile, u.fileHandler); err != nil { + // Apply possible updates to cluster ID file + if err := updateClusterIDFile(tfOutput, u.fileHandler); err != nil { return tfOutput, fmt.Errorf("merging cluster ID files: %w", err) } cmd.Printf("Terraform migrations applied successfully and output written to: %s\n"+ "A backup of the pre-upgrade state has been written to: %s\n", flags.pf.PrefixPrintablePath(constants.ClusterIDsFilename), - flags.pf.PrefixPrintablePath(filepath.Join(opts.UpgradeWorkspace, u.terraformUpgrader.UpgradeID(), constants.TerraformUpgradeBackupDir)), + flags.pf.PrefixPrintablePath(filepath.Join(upgradeDir, constants.TerraformUpgradeBackupDir)), ) } else { u.log.Debugf("No Terraform diff detected") @@ -313,20 +306,6 @@ func (u *upgradeApplyCmd) migrateTerraform( return tfOutput, nil } -func newIDFile(opts upgrade.TerraformUpgradeOptions, tfOutput terraform.ApplyOutput) clusterid.File { - newIDFile := clusterid.File{ - CloudProvider: opts.CSP, - InitSecret: []byte(tfOutput.Secret), - IP: tfOutput.IP, - APIServerCertSANs: tfOutput.APIServerCertSANs, - UID: tfOutput.UID, - } - if tfOutput.Azure != nil { - newIDFile.AttestationURL = tfOutput.Azure.AttestationURL - } - return newIDFile -} - // validK8sVersion checks if the Kubernetes patch version is supported and asks for confirmation if not. func validK8sVersion(cmd *cobra.Command, version string, yes bool) (validVersion versions.ValidK8sVersion, err error) { validVersion, err = versions.NewValidK8sVersion(version, true) @@ -390,7 +369,10 @@ func (u *upgradeApplyCmd) confirmAndUpgradeAttestationConfig( return nil } -func (u *upgradeApplyCmd) handleServiceUpgrade(cmd *cobra.Command, conf *config.Config, idFile clusterid.File, tfOutput terraform.ApplyOutput, validK8sVersion versions.ValidK8sVersion, flags upgradeApplyFlags) error { +func (u *upgradeApplyCmd) handleServiceUpgrade( + cmd *cobra.Command, conf *config.Config, idFile clusterid.File, tfOutput terraform.ApplyOutput, + validK8sVersion versions.ValidK8sVersion, upgradeDir string, flags upgradeApplyFlags, +) error { var secret uri.MasterSecret if err := u.fileHandler.ReadJSON(constants.MasterSecretFilename, &secret); err != nil { return fmt.Errorf("reading master secret: %w", err) @@ -401,7 +383,7 @@ func (u *upgradeApplyCmd) handleServiceUpgrade(cmd *cobra.Command, conf *config. } err = u.helmUpgrader.Upgrade( cmd.Context(), conf, idFile, - flags.upgradeTimeout, helm.DenyDestructive, flags.force, u.terraformUpgrader.UpgradeID(), + flags.upgradeTimeout, helm.DenyDestructive, flags.force, upgradeDir, flags.conformance, flags.helmWaitMode, secret, serviceAccURI, validK8sVersion, tfOutput, ) if errors.Is(err, helm.ErrConfirmationMissing) { @@ -418,7 +400,7 @@ func (u *upgradeApplyCmd) handleServiceUpgrade(cmd *cobra.Command, conf *config. } err = u.helmUpgrader.Upgrade( cmd.Context(), conf, idFile, - flags.upgradeTimeout, helm.AllowDestructive, flags.force, u.terraformUpgrader.UpgradeID(), + flags.upgradeTimeout, helm.AllowDestructive, flags.force, upgradeDir, flags.conformance, flags.helmWaitMode, secret, serviceAccURI, validK8sVersion, tfOutput, ) } @@ -507,14 +489,24 @@ func parseUpgradeApplyFlags(cmd *cobra.Command) (upgradeApplyFlags, error) { }, nil } -func mergeClusterIDFile(clusterIDPath string, newIDFile clusterid.File, fileHandler file.Handler) error { +func updateClusterIDFile(tfOutput terraform.ApplyOutput, fileHandler file.Handler) error { + newIDFile := clusterid.File{ + InitSecret: []byte(tfOutput.Secret), + IP: tfOutput.IP, + APIServerCertSANs: tfOutput.APIServerCertSANs, + UID: tfOutput.UID, + } + if tfOutput.Azure != nil { + newIDFile.AttestationURL = tfOutput.Azure.AttestationURL + } + idFile := &clusterid.File{} - if err := fileHandler.ReadJSON(clusterIDPath, idFile); err != nil { - return fmt.Errorf("reading %s: %w", clusterIDPath, err) + if err := fileHandler.ReadJSON(constants.ClusterIDsFilename, idFile); err != nil { + return fmt.Errorf("reading %s: %w", constants.ClusterIDsFilename, err) } - if err := fileHandler.WriteJSON(clusterIDPath, idFile.Merge(newIDFile), file.OptOverwrite); err != nil { - return fmt.Errorf("writing %s: %w", clusterIDPath, err) + if err := fileHandler.WriteJSON(constants.ClusterIDsFilename, idFile.Merge(newIDFile), file.OptOverwrite); err != nil { + return fmt.Errorf("writing %s: %w", constants.ClusterIDsFilename, err) } return nil @@ -544,15 +536,12 @@ type kubernetesUpgrader interface { type helmUpgrader interface { Upgrade( ctx context.Context, config *config.Config, idFile clusterid.File, timeout time.Duration, - allowDestructive, force bool, upgradeID string, conformance bool, helmWaitMode helm.WaitMode, + allowDestructive, force bool, upgradeDir string, conformance bool, helmWaitMode helm.WaitMode, masterSecret uri.MasterSecret, serviceAccURI string, validK8sVersion versions.ValidK8sVersion, tfOutput terraform.ApplyOutput, ) error } -type terraformUpgrader interface { - PlanTerraformMigrations(ctx context.Context, opts upgrade.TerraformUpgradeOptions) (bool, error) - ApplyTerraformMigrations(ctx context.Context, opts upgrade.TerraformUpgradeOptions) (terraform.ApplyOutput, error) - CheckTerraformMigrations(upgradeWorkspace string) error - CleanUpTerraformMigrations(upgradeWorkspace string) error - UpgradeID() string +type clusterUpgrader interface { + PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) + ApplyClusterUpgrade(ctx context.Context, csp cloudprovider.Provider) (terraform.ApplyOutput, error) } diff --git a/cli/internal/cmd/upgradeapply_test.go b/cli/internal/cmd/upgradeapply_test.go index 874861f25b..5fd2c02fe8 100644 --- a/cli/internal/cmd/upgradeapply_test.go +++ b/cli/internal/cmd/upgradeapply_test.go @@ -9,6 +9,7 @@ package cmd import ( "bytes" "context" + "io" "testing" "time" @@ -16,7 +17,6 @@ import ( "github.com/edgelesssys/constellation/v2/cli/internal/helm" "github.com/edgelesssys/constellation/v2/cli/internal/kubecmd" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/config" @@ -35,15 +35,15 @@ func TestUpgradeApply(t *testing.T) { helmUpgrader *stubHelmUpgrader kubeUpgrader *stubKubernetesUpgrader terraformUpgrader *stubTerraformUpgrader + flags upgradeApplyFlags wantErr bool - yesFlag bool stdin string }{ "success": { kubeUpgrader: &stubKubernetesUpgrader{currentConfig: config.DefaultForAzureSEVSNP()}, helmUpgrader: &stubHelmUpgrader{}, terraformUpgrader: &stubTerraformUpgrader{}, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, "nodeVersion some error": { kubeUpgrader: &stubKubernetesUpgrader{ @@ -53,7 +53,7 @@ func TestUpgradeApply(t *testing.T) { helmUpgrader: &stubHelmUpgrader{}, terraformUpgrader: &stubTerraformUpgrader{}, wantErr: true, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, "nodeVersion in progress error": { kubeUpgrader: &stubKubernetesUpgrader{ @@ -62,7 +62,7 @@ func TestUpgradeApply(t *testing.T) { }, helmUpgrader: &stubHelmUpgrader{}, terraformUpgrader: &stubTerraformUpgrader{}, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, "helm other error": { kubeUpgrader: &stubKubernetesUpgrader{ @@ -71,16 +71,7 @@ func TestUpgradeApply(t *testing.T) { helmUpgrader: &stubHelmUpgrader{err: assert.AnError}, terraformUpgrader: &stubTerraformUpgrader{}, wantErr: true, - yesFlag: true, - }, - "check terraform error": { - kubeUpgrader: &stubKubernetesUpgrader{ - currentConfig: config.DefaultForAzureSEVSNP(), - }, - helmUpgrader: &stubHelmUpgrader{}, - terraformUpgrader: &stubTerraformUpgrader{checkTerraformErr: assert.AnError}, - wantErr: true, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, "abort": { kubeUpgrader: &stubKubernetesUpgrader{ @@ -91,18 +82,6 @@ func TestUpgradeApply(t *testing.T) { wantErr: true, stdin: "no\n", }, - "clean terraform error": { - kubeUpgrader: &stubKubernetesUpgrader{ - currentConfig: config.DefaultForAzureSEVSNP(), - }, - helmUpgrader: &stubHelmUpgrader{}, - terraformUpgrader: &stubTerraformUpgrader{ - cleanTerraformErr: assert.AnError, - terraformDiff: true, - }, - wantErr: true, - stdin: "no\n", - }, "plan terraform error": { kubeUpgrader: &stubKubernetesUpgrader{ currentConfig: config.DefaultForAzureSEVSNP(), @@ -110,7 +89,7 @@ func TestUpgradeApply(t *testing.T) { helmUpgrader: &stubHelmUpgrader{}, terraformUpgrader: &stubTerraformUpgrader{planTerraformErr: assert.AnError}, wantErr: true, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, "apply terraform error": { kubeUpgrader: &stubKubernetesUpgrader{ @@ -122,15 +101,7 @@ func TestUpgradeApply(t *testing.T) { terraformDiff: true, }, wantErr: true, - yesFlag: true, - }, - "do no backup join-config when remote attestation config is the same": { - kubeUpgrader: &stubKubernetesUpgrader{ - currentConfig: fakeAzureAttestationConfigFromCluster(context.Background(), t, cloudprovider.Azure), - }, - helmUpgrader: &stubHelmUpgrader{}, - terraformUpgrader: &stubTerraformUpgrader{}, - yesFlag: true, + flags: upgradeApplyFlags{yes: true}, }, } @@ -140,14 +111,6 @@ func TestUpgradeApply(t *testing.T) { require := require.New(t) cmd := newUpgradeApplyCmd() cmd.SetIn(bytes.NewBufferString(tc.stdin)) - cmd.Flags().String("workspace", "", "") // register persistent flag manually - cmd.Flags().Bool("force", true, "") // register persistent flag manually - cmd.Flags().String("tf-log", "DEBUG", "") // register persistent flag manually - - if tc.yesFlag { - err := cmd.Flags().Set("yes", "true") - require.NoError(err) - } handler := file.NewHandler(afero.NewMemMapFs()) @@ -158,16 +121,16 @@ func TestUpgradeApply(t *testing.T) { require.NoError(handler.WriteJSON(constants.MasterSecretFilename, uri.MasterSecret{})) upgrader := upgradeApplyCmd{ - kubeUpgrader: tc.kubeUpgrader, - helmUpgrader: tc.helmUpgrader, - terraformUpgrader: tc.terraformUpgrader, - log: logger.NewTest(t), - configFetcher: stubAttestationFetcher{}, - clusterShower: &stubShowCluster{}, - fileHandler: handler, + kubeUpgrader: tc.kubeUpgrader, + helmUpgrader: tc.helmUpgrader, + clusterUpgrader: tc.terraformUpgrader, + log: logger.NewTest(t), + configFetcher: stubAttestationFetcher{}, + clusterShower: &stubShowCluster{}, + fileHandler: handler, } - err := upgrader.upgradeApply(cmd) + err := upgrader.upgradeApply(cmd, "test", tc.flags) if tc.wantErr { assert.Error(err) return @@ -222,35 +185,13 @@ func (u stubKubernetesUpgrader) RemoveHelmKeepAnnotation(_ context.Context) erro type stubTerraformUpgrader struct { terraformDiff bool planTerraformErr error - checkTerraformErr error applyTerraformErr error - cleanTerraformErr error } -func (u stubTerraformUpgrader) CheckTerraformMigrations(_ string) error { - return u.checkTerraformErr -} - -func (u stubTerraformUpgrader) CleanUpTerraformMigrations(_ string) error { - return u.cleanTerraformErr -} - -func (u stubTerraformUpgrader) PlanTerraformMigrations(context.Context, upgrade.TerraformUpgradeOptions) (bool, error) { +func (u stubTerraformUpgrader) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) { return u.terraformDiff, u.planTerraformErr } -func (u stubTerraformUpgrader) ApplyTerraformMigrations(context.Context, upgrade.TerraformUpgradeOptions) (terraform.ApplyOutput, error) { +func (u stubTerraformUpgrader) ApplyClusterUpgrade(_ context.Context, _ cloudprovider.Provider) (terraform.ApplyOutput, error) { return terraform.ApplyOutput{}, u.applyTerraformErr } - -func (u stubTerraformUpgrader) UpgradeID() string { - return "test-upgrade" -} - -func fakeAzureAttestationConfigFromCluster(ctx context.Context, t *testing.T, provider cloudprovider.Provider) config.AttestationCfg { - cpCfg := defaultConfigWithExpectedMeasurements(t, config.Default(), provider) - // the cluster attestation config needs to have real version numbers that are translated from "latest" as defined in config.Default() - err := cpCfg.Attestation.AzureSEVSNP.FetchAndSetLatestVersionNumbers(ctx, stubAttestationFetcher{}, time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)) - require.NoError(t, err) - return cpCfg.GetAttestationConfig() -} diff --git a/cli/internal/cmd/upgradecheck.go b/cli/internal/cmd/upgradecheck.go index 090cc46fab..9534b23545 100644 --- a/cli/internal/cmd/upgradecheck.go +++ b/cli/internal/cmd/upgradecheck.go @@ -21,7 +21,6 @@ import ( "github.com/edgelesssys/constellation/v2/cli/internal/helm" "github.com/edgelesssys/constellation/v2/cli/internal/kubecmd" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/api/fetcher" "github.com/edgelesssys/constellation/v2/internal/api/versionsapi" @@ -74,9 +73,16 @@ func runUpgradeCheck(cmd *cobra.Command, _ []string) error { fileHandler := file.NewHandler(afero.NewOsFs()) upgradeID := generateUpgradeID(upgradeCmdKindCheck) - tfClient, err := terraform.New(cmd.Context(), filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformUpgradeWorkingDir)) + upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID) + tfClient, err := cloudcmd.NewClusterUpgrader( + cmd.Context(), + constants.TerraformWorkingDir, + upgradeDir, + flags.terraformLogLevel, + fileHandler, + ) if err != nil { - return fmt.Errorf("setting up terraform client: %w", err) + return fmt.Errorf("setting up Terraform upgrader: %w", err) } kubeChecker, err := kubecmd.New(cmd.OutOrStdout(), constants.AdminConfFilename, log) @@ -103,11 +109,12 @@ func runUpgradeCheck(cmd *cobra.Command, _ []string) error { log: log, versionsapi: versionfetcher, }, - terraformChecker: upgrade.NewTerraformUpgrader(tfClient, cmd.OutOrStdout(), fileHandler, upgradeID), + terraformChecker: tfClient, + fileHandler: fileHandler, log: log, } - return up.upgradeCheck(cmd, fileHandler, attestationconfigapi.NewFetcher(), flags) + return up.upgradeCheck(cmd, attestationconfigapi.NewFetcher(), upgradeDir, flags) } func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) { @@ -150,12 +157,13 @@ type upgradeCheckCmd struct { canUpgradeCheck bool collect collector terraformChecker terraformChecker + fileHandler file.Handler log debugLog } // upgradePlan plans an upgrade of a Constellation cluster. -func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fileHandler file.Handler, fetcher attestationconfigapi.Fetcher, flags upgradeCheckFlags) error { - conf, err := config.New(fileHandler, constants.ConfigFilename, fetcher, flags.force) +func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fetcher attestationconfigapi.Fetcher, upgradeDir string, flags upgradeCheckFlags) error { + conf, err := config.New(u.fileHandler, constants.ConfigFilename, fetcher, flags.force) var configValidationErr *config.ValidationError if errors.As(err, &configValidationErr) { cmd.PrintErrln(configValidationErr.LongMessage()) @@ -216,34 +224,21 @@ func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fileHandler file.Hand // u.upgrader.AddManualStateMigration(migration) // } - if err := u.terraformChecker.CheckTerraformMigrations(constants.UpgradeDir); err != nil { - return fmt.Errorf("checking workspace: %w", err) - } - vars, err := cloudcmd.TerraformUpgradeVars(conf) if err != nil { return fmt.Errorf("parsing upgrade variables: %w", err) } u.log.Debugf("Using Terraform variables:\n%v", vars) - opts := upgrade.TerraformUpgradeOptions{ - LogLevel: flags.terraformLogLevel, - CSP: conf.GetProvider(), - Vars: vars, - TFWorkspace: constants.TerraformWorkingDir, - UpgradeWorkspace: constants.UpgradeDir, - } - cmd.Println("The following Terraform migrations are available with this CLI:") - - // Check if there are any Terraform migrations - hasDiff, err := u.terraformChecker.PlanTerraformMigrations(cmd.Context(), opts) + hasDiff, err := u.terraformChecker.PlanClusterUpgrade(cmd.Context(), cmd.OutOrStdout(), vars, conf.GetProvider()) if err != nil { return fmt.Errorf("planning terraform migrations: %w", err) } defer func() { - if err := u.terraformChecker.CleanUpTerraformMigrations(constants.UpgradeDir); err != nil { - u.log.Debugf("Failed to clean up Terraform migrations: %v", err) + // Remove the upgrade directory + if err := u.fileHandler.RemoveAll(upgradeDir); err != nil { + u.log.Debugf("Failed to clean up Terraform migrations: %s", err) } }() @@ -271,7 +266,7 @@ func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fileHandler file.Hand cmd.Print(updateMsg) if flags.updateConfig { - if err := upgrade.writeConfig(conf, fileHandler, constants.ConfigFilename); err != nil { + if err := upgrade.writeConfig(conf, u.fileHandler, constants.ConfigFilename); err != nil { return fmt.Errorf("writing config: %w", err) } cmd.Println("Config updated successfully.") @@ -376,7 +371,7 @@ type currentVersionInfo struct { } func (v *versionCollector) currentVersions(ctx context.Context) (currentVersionInfo, error) { - helmClient, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.UpgradeDir, constants.AdminConfFilename, constants.HelmNamespace, v.log) + helmClient, err := helm.NewUpgradeClient(kubectl.NewUninitialized(), constants.AdminConfFilename, constants.HelmNamespace, v.log) if err != nil { return currentVersionInfo{}, fmt.Errorf("setting up helm client: %w", err) } @@ -727,9 +722,7 @@ type kubernetesChecker interface { } type terraformChecker interface { - PlanTerraformMigrations(ctx context.Context, opts upgrade.TerraformUpgradeOptions) (bool, error) - CheckTerraformMigrations(upgradeWorkspace string) error - CleanUpTerraformMigrations(upgradeWorkspace string) error + PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) } type versionListFetcher interface { diff --git a/cli/internal/cmd/upgradecheck_test.go b/cli/internal/cmd/upgradecheck_test.go index a962d48847..1b4e5601fa 100644 --- a/cli/internal/cmd/upgradecheck_test.go +++ b/cli/internal/cmd/upgradecheck_test.go @@ -15,7 +15,7 @@ import ( "strings" "testing" - "github.com/edgelesssys/constellation/v2/cli/internal/upgrade" + "github.com/edgelesssys/constellation/v2/cli/internal/terraform" "github.com/edgelesssys/constellation/v2/internal/api/versionsapi" "github.com/edgelesssys/constellation/v2/internal/attestation/measurements" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" @@ -207,12 +207,13 @@ func TestUpgradeCheck(t *testing.T) { canUpgradeCheck: true, collect: &tc.collector, terraformChecker: tc.checker, + fileHandler: fileHandler, log: logger.NewTest(t), } cmd := newUpgradeCheckCmd() - err := checkCmd.upgradeCheck(cmd, fileHandler, stubAttestationFetcher{}, upgradeCheckFlags{}) + err := checkCmd.upgradeCheck(cmd, stubAttestationFetcher{}, "test", upgradeCheckFlags{}) if tc.wantError { assert.Error(err) return @@ -281,18 +282,10 @@ type stubTerraformChecker struct { err error } -func (s stubTerraformChecker) PlanTerraformMigrations(context.Context, upgrade.TerraformUpgradeOptions) (bool, error) { +func (s stubTerraformChecker) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) { return s.tfDiff, s.err } -func (s stubTerraformChecker) CheckTerraformMigrations(_ string) error { - return s.err -} - -func (s stubTerraformChecker) CleanUpTerraformMigrations(_ string) error { - return s.err -} - func TestNewCLIVersions(t *testing.T) { someErr := errors.New("some error") minorList := func() versionsapi.List { diff --git a/cli/internal/helm/backup.go b/cli/internal/helm/backup.go index ca0efb3569..08e20b4b74 100644 --- a/cli/internal/helm/backup.go +++ b/cli/internal/helm/backup.go @@ -17,14 +17,14 @@ import ( "sigs.k8s.io/yaml" ) -func (c *UpgradeClient) backupCRDs(ctx context.Context, upgradeID string) ([]apiextensionsv1.CustomResourceDefinition, error) { +func (c *UpgradeClient) backupCRDs(ctx context.Context, upgradeDir string) ([]apiextensionsv1.CustomResourceDefinition, error) { c.log.Debugf("Starting CRD backup") crds, err := c.kubectl.ListCRDs(ctx) if err != nil { return nil, fmt.Errorf("getting CRDs: %w", err) } - crdBackupFolder := c.crdBackupFolder(upgradeID) + crdBackupFolder := c.crdBackupFolder(upgradeDir) if err := c.fs.MkdirAll(crdBackupFolder); err != nil { return nil, fmt.Errorf("creating backup dir: %w", err) } @@ -98,10 +98,10 @@ func (c *UpgradeClient) backupCRs(ctx context.Context, crds []apiextensionsv1.Cu return nil } -func (c *UpgradeClient) backupFolder(upgradeID string) string { - return filepath.Join(c.upgradeWorkspace, upgradeID, "backups") + string(filepath.Separator) +func (c *UpgradeClient) backupFolder(upgradeDir string) string { + return filepath.Join(upgradeDir, "backups") } -func (c *UpgradeClient) crdBackupFolder(upgradeID string) string { - return filepath.Join(c.backupFolder(upgradeID), "crds") + string(filepath.Separator) +func (c *UpgradeClient) crdBackupFolder(upgradeDir string) string { + return filepath.Join(c.backupFolder(upgradeDir), "crds") } diff --git a/cli/internal/helm/upgrade.go b/cli/internal/helm/upgrade.go index 015d73a53f..704e978607 100644 --- a/cli/internal/helm/upgrade.go +++ b/cli/internal/helm/upgrade.go @@ -47,16 +47,15 @@ var errReleaseNotFound = errors.New("release not found") // UpgradeClient handles interaction with helm and the cluster. type UpgradeClient struct { - config *action.Configuration - kubectl crdClient - fs file.Handler - actions actionWrapper - upgradeWorkspace string - log debugLog + config *action.Configuration + kubectl crdClient + fs file.Handler + actions actionWrapper + log debugLog } // NewUpgradeClient returns a newly initialized UpgradeClient for the given namespace. -func NewUpgradeClient(client crdClient, upgradeWorkspace, kubeConfigPath, helmNamespace string, log debugLog) (*UpgradeClient, error) { +func NewUpgradeClient(client crdClient, kubeConfigPath, helmNamespace string, log debugLog) (*UpgradeClient, error) { settings := cli.New() settings.KubeConfig = kubeConfigPath @@ -77,11 +76,10 @@ func NewUpgradeClient(client crdClient, upgradeWorkspace, kubeConfigPath, helmNa } return &UpgradeClient{ - kubectl: client, - fs: fileHandler, - actions: actions{config: actionConfig}, - upgradeWorkspace: upgradeWorkspace, - log: log, + kubectl: client, + fs: fileHandler, + actions: actions{config: actionConfig}, + log: log, }, nil } @@ -115,7 +113,7 @@ func (c *UpgradeClient) shouldUpgrade(releaseName string, newVersion semver.Semv // If the CLI receives an interrupt signal it will cancel the context. // Canceling the context will prompt helm to abort and roll back the ongoing upgrade. func (c *UpgradeClient) Upgrade(ctx context.Context, config *config.Config, idFile clusterid.File, timeout time.Duration, - allowDestructive, force bool, upgradeID string, conformance bool, helmWaitMode WaitMode, masterSecret uri.MasterSecret, + allowDestructive, force bool, upgradeDir string, conformance bool, helmWaitMode WaitMode, masterSecret uri.MasterSecret, serviceAccURI string, validK8sVersion versions.ValidK8sVersion, output terraform.ApplyOutput, ) error { upgradeErrs := []error{} @@ -174,11 +172,11 @@ func (c *UpgradeClient) Upgrade(ctx context.Context, config *config.Config, idFi // Backup CRDs and CRs if we are upgrading anything. if len(upgradeReleases) != 0 { c.log.Debugf("Creating backup of CRDs and CRs") - crds, err := c.backupCRDs(ctx, upgradeID) + crds, err := c.backupCRDs(ctx, upgradeDir) if err != nil { return fmt.Errorf("creating CRD backup: %w", err) } - if err := c.backupCRs(ctx, crds, upgradeID); err != nil { + if err := c.backupCRs(ctx, crds, upgradeDir); err != nil { return fmt.Errorf("creating CR backup: %w", err) } } diff --git a/cli/internal/terraform/BUILD.bazel b/cli/internal/terraform/BUILD.bazel index 208ee0ecbd..98f3c75833 100644 --- a/cli/internal/terraform/BUILD.bazel +++ b/cli/internal/terraform/BUILD.bazel @@ -83,7 +83,9 @@ go_library( "@com_github_hashicorp_hc_install//product", "@com_github_hashicorp_hc_install//releases", "@com_github_hashicorp_hc_install//src", + "@com_github_hashicorp_hcl_v2//:hcl", "@com_github_hashicorp_hcl_v2//gohcl", + "@com_github_hashicorp_hcl_v2//hclsyntax", "@com_github_hashicorp_hcl_v2//hclwrite", "@com_github_hashicorp_terraform_exec//tfexec", "@com_github_hashicorp_terraform_json//:terraform-json", diff --git a/cli/internal/terraform/terraform.go b/cli/internal/terraform/terraform.go index a4aceb1d89..29d08925c3 100644 --- a/cli/internal/terraform/terraform.go +++ b/cli/internal/terraform/terraform.go @@ -47,18 +47,6 @@ const ( terraformUpgradePlanFile = "plan.zip" ) -// PrepareIAMUpgradeWorkspace prepares a Terraform workspace for a Constellation IAM upgrade. -func PrepareIAMUpgradeWorkspace(file file.Handler, path, oldWorkingDir, newWorkingDir, backupDir string) error { - if err := prepareUpgradeWorkspace(path, file, oldWorkingDir, newWorkingDir, backupDir); err != nil { - return fmt.Errorf("prepare upgrade workspace: %w", err) - } - // copy the vars file from the old working dir to the new working dir - if err := file.CopyFile(filepath.Join(oldWorkingDir, terraformVarsFile), filepath.Join(newWorkingDir, terraformVarsFile)); err != nil { - return fmt.Errorf("copying vars file: %w", err) - } - return nil -} - // ErrTerraformWorkspaceExistsWithDifferentVariables is returned when existing Terraform files differ from the version the CLI wants to extract. var ErrTerraformWorkspaceExistsWithDifferentVariables = errors.New("creating cluster: a Terraform workspace already exists with different variables") @@ -347,10 +335,12 @@ func (c *Client) PrepareWorkspace(path string, vars Variables) error { return c.writeVars(vars) } -// PrepareUpgradeWorkspace prepares a Terraform workspace for a Constellation version upgrade. -// It copies the Terraform state from the old working dir and the embedded Terraform files into the new working dir. -func (c *Client) PrepareUpgradeWorkspace(path, oldWorkingDir, newWorkingDir, backupDir string, vars Variables) error { - if err := prepareUpgradeWorkspace(path, c.file, oldWorkingDir, newWorkingDir, backupDir); err != nil { +// PrepareUpgradeWorkspace prepares a Terraform workspace for an upgrade. +// It copies the Terraform state from the old working dir and the embedded Terraform files +// into the working dir of the Terraform client. +// Additionally, a backup of the old working dir is created in the backup dir. +func (c *Client) PrepareUpgradeWorkspace(path, oldWorkingDir, backupDir string, vars Variables) error { + if err := prepareUpgradeWorkspace(path, c.file, oldWorkingDir, c.workingDir, backupDir); err != nil { return fmt.Errorf("prepare upgrade workspace: %w", err) } diff --git a/cli/internal/terraform/variables.go b/cli/internal/terraform/variables.go index 55380ed7ed..d00f66c801 100644 --- a/cli/internal/terraform/variables.go +++ b/cli/internal/terraform/variables.go @@ -8,9 +8,10 @@ package terraform import ( "fmt" - "strings" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/hclwrite" ) @@ -30,6 +31,19 @@ type ClusterVariables interface { GetCreateMAA() bool } +// VariablesFromBytes parses the given bytes into the given variables struct. +func VariablesFromBytes[T any](b []byte, vars *T) error { + file, err := hclsyntax.ParseConfig(b, "", hcl.Pos{Line: 1, Column: 1}) + if err != nil { + return fmt.Errorf("parsing variables: %w", err) + } + + if err := gohcl.DecodeBody(file.Body, nil, vars); err != nil { + return fmt.Errorf("decoding variables: %w", err) + } + return nil +} + // AWSClusterVariables is user configuration for creating a cluster with Terraform on AWS. type AWSClusterVariables struct { // Name of the cluster. @@ -87,18 +101,16 @@ type AWSNodeGroup struct { // AWSIAMVariables is user configuration for creating the IAM configuration with Terraform on Microsoft Azure. type AWSIAMVariables struct { // Region is the AWS location to use. (e.g. us-east-2) - Region string + Region string `hcl:"region" cty:"region"` // Prefix is the name prefix of the resources to use. - Prefix string + Prefix string `hcl:"name_prefix" cty:"name_prefix"` } // String returns a string representation of the IAM-specific variables, formatted as Terraform variables. func (v *AWSIAMVariables) String() string { - b := &strings.Builder{} - writeLinef(b, "name_prefix = %q", v.Prefix) - writeLinef(b, "region = %q", v.Region) - - return b.String() + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(v, f.Body()) + return string(f.Bytes()) } // GCPClusterVariables is user configuration for creating resources with Terraform on GCP. @@ -151,24 +163,20 @@ type GCPNodeGroup struct { // GCPIAMVariables is user configuration for creating the IAM confioguration with Terraform on GCP. type GCPIAMVariables struct { // Project is the ID of the GCP project to use. - Project string + Project string `hcl:"project_id" cty:"project_id"` // Region is the GCP region to use. - Region string + Region string `hcl:"region" cty:"region"` // Zone is the GCP zone to use. - Zone string + Zone string `hcl:"zone" cty:"zone"` // ServiceAccountID is the ID of the service account to use. - ServiceAccountID string + ServiceAccountID string `hcl:"service_account_id" cty:"service_account_id"` } // String returns a string representation of the IAM-specific variables, formatted as Terraform variables. func (v *GCPIAMVariables) String() string { - b := &strings.Builder{} - writeLinef(b, "project_id = %q", v.Project) - writeLinef(b, "region = %q", v.Region) - writeLinef(b, "zone = %q", v.Zone) - writeLinef(b, "service_account_id = %q", v.ServiceAccountID) - - return b.String() + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(v, f.Body()) + return string(f.Bytes()) } // AzureClusterVariables is user configuration for creating a cluster with Terraform on Azure. @@ -229,21 +237,18 @@ type AzureNodeGroup struct { // AzureIAMVariables is user configuration for creating the IAM configuration with Terraform on Microsoft Azure. type AzureIAMVariables struct { // Region is the Azure region to use. (e.g. westus) - Region string + Region string `hcl:"region" cty:"region"` // ServicePrincipal is the name of the service principal to use. - ServicePrincipal string + ServicePrincipal string `hcl:"service_principal_name" cty:"service_principal_name"` // ResourceGroup is the name of the resource group to use. - ResourceGroup string + ResourceGroup string `hcl:"resource_group_name" cty:"resource_group_name"` } // String returns a string representation of the IAM-specific variables, formatted as Terraform variables. func (v *AzureIAMVariables) String() string { - b := &strings.Builder{} - writeLinef(b, "service_principal_name = %q", v.ServicePrincipal) - writeLinef(b, "region = %q", v.Region) - writeLinef(b, "resource_group_name = %q", v.ResourceGroup) - - return b.String() + f := hclwrite.NewEmptyFile() + gohcl.EncodeIntoBody(v, f.Body()) + return string(f.Bytes()) } // OpenStackClusterVariables is user configuration for creating a cluster with Terraform on OpenStack. @@ -380,11 +385,6 @@ type QEMUNodeGroup struct { MemorySize int `hcl:"memory" cty:"memory"` } -func writeLinef(builder *strings.Builder, format string, a ...any) { - builder.WriteString(fmt.Sprintf(format, a...)) - builder.WriteByte('\n') -} - func toPtr[T any](v T) *T { return &v } diff --git a/cli/internal/terraform/variables_test.go b/cli/internal/terraform/variables_test.go index 4cd4ebea79..282f86c3c4 100644 --- a/cli/internal/terraform/variables_test.go +++ b/cli/internal/terraform/variables_test.go @@ -86,8 +86,8 @@ func TestAWSIAMVariables(t *testing.T) { } // test that the variables are correctly rendered - want := `name_prefix = "my-prefix" -region = "eu-central-1" + want := `region = "eu-central-1" +name_prefix = "my-prefix" ` got := vars.String() assert.Equal(t, want, got) @@ -162,9 +162,9 @@ func TestGCPIAMVariables(t *testing.T) { } // test that the variables are correctly rendered - want := `project_id = "my-project" -region = "eu-central-1" -zone = "eu-central-1a" + want := `project_id = "my-project" +region = "eu-central-1" +zone = "eu-central-1a" service_account_id = "my-service-account" ` got := vars.String() @@ -226,9 +226,9 @@ func TestAzureIAMVariables(t *testing.T) { } // test that the variables are correctly rendered - want := `service_principal_name = "my-service-principal" -region = "eu-central-1" -resource_group_name = "my-resource-group" + want := `region = "eu-central-1" +service_principal_name = "my-service-principal" +resource_group_name = "my-resource-group" ` got := vars.String() assert.Equal(t, want, got) @@ -337,3 +337,34 @@ custom_endpoint = "example.com" got := vars.String() assert.Equal(t, want, got) } + +func TestVariablesFromBytes(t *testing.T) { + assert := assert.New(t) + + awsVars := AWSIAMVariables{ + Region: "test", + } + var loadedAWSVars AWSIAMVariables + err := VariablesFromBytes([]byte(awsVars.String()), &loadedAWSVars) + assert.NoError(err) + assert.Equal(awsVars, loadedAWSVars) + + azureVars := AzureIAMVariables{ + Region: "test", + } + var loadedAzureVars AzureIAMVariables + err = VariablesFromBytes([]byte(azureVars.String()), &loadedAzureVars) + assert.NoError(err) + assert.Equal(azureVars, loadedAzureVars) + + gcpVars := GCPIAMVariables{ + Region: "test", + } + var loadedGCPVars GCPIAMVariables + err = VariablesFromBytes([]byte(gcpVars.String()), &loadedGCPVars) + assert.NoError(err) + assert.Equal(gcpVars, loadedGCPVars) + + err = VariablesFromBytes([]byte("invalid"), &loadedGCPVars) + assert.Error(err) +} diff --git a/cli/internal/upgrade/BUILD.bazel b/cli/internal/upgrade/BUILD.bazel deleted file mode 100644 index 9ff116967d..0000000000 --- a/cli/internal/upgrade/BUILD.bazel +++ /dev/null @@ -1,38 +0,0 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_library") -load("//bazel/go:go_test.bzl", "go_test") - -go_library( - name = "upgrade", - srcs = [ - "iammigrate.go", - "terraform.go", - "upgrade.go", - ], - importpath = "github.com/edgelesssys/constellation/v2/cli/internal/upgrade", - visibility = ["//cli:__subpackages__"], - deps = [ - "//cli/internal/cloudcmd", - "//cli/internal/terraform", - "//internal/cloud/cloudprovider", - "//internal/constants", - "//internal/file", - ], -) - -go_test( - name = "upgrade_test", - srcs = [ - "iammigrate_test.go", - "terraform_test.go", - ], - embed = [":upgrade"], - deps = [ - "//cli/internal/terraform", - "//internal/cloud/cloudprovider", - "//internal/constants", - "//internal/file", - "@com_github_spf13_afero//:afero", - "@com_github_stretchr_testify//assert", - "@com_github_stretchr_testify//require", - ], -) diff --git a/cli/internal/upgrade/iammigrate.go b/cli/internal/upgrade/iammigrate.go deleted file mode 100644 index 79513a88d3..0000000000 --- a/cli/internal/upgrade/iammigrate.go +++ /dev/null @@ -1,110 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package upgrade - -import ( - "context" - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" - "github.com/edgelesssys/constellation/v2/internal/constants" - "github.com/edgelesssys/constellation/v2/internal/file" -) - -// IAMMigrateCmd is a terraform migration command for IAM. Which is used for the tfMigrationClient. -type IAMMigrateCmd struct { - tf tfIAMClient - upgradeID string - iamWorkspace string - upgradeWorkspace string - csp cloudprovider.Provider - logLevel terraform.LogLevel -} - -// NewIAMMigrateCmd creates a new IAMMigrateCmd. -func NewIAMMigrateCmd(ctx context.Context, iamWorkspace, upgradeWorkspace, upgradeID string, csp cloudprovider.Provider, logLevel terraform.LogLevel) (*IAMMigrateCmd, error) { - tfClient, err := terraform.New(ctx, filepath.Join(upgradeWorkspace, upgradeID, constants.TerraformIAMUpgradeWorkingDir)) - if err != nil { - return nil, fmt.Errorf("setting up terraform client: %w", err) - } - return &IAMMigrateCmd{ - tf: tfClient, - upgradeID: upgradeID, - iamWorkspace: iamWorkspace, - upgradeWorkspace: upgradeWorkspace, - csp: csp, - logLevel: logLevel, - }, nil -} - -// String returns the name of the command. -func (c *IAMMigrateCmd) String() string { - return "iam migration" -} - -// UpgradeID returns the upgrade ID. -func (c *IAMMigrateCmd) UpgradeID() string { - return c.upgradeID -} - -// CheckTerraformMigrations checks whether Terraform migrations are possible in the current workspace. -func (c *IAMMigrateCmd) CheckTerraformMigrations(file file.Handler) error { - return checkTerraformMigrations(file, c.upgradeWorkspace, c.upgradeID, constants.TerraformIAMUpgradeBackupDir) -} - -// Plan prepares the upgrade workspace and plans the Terraform migrations for the Constellation upgrade, writing the plan to the outWriter. -func (c *IAMMigrateCmd) Plan(ctx context.Context, file file.Handler, outWriter io.Writer) (bool, error) { - templateDir := filepath.Join("terraform", "iam", strings.ToLower(c.csp.String())) - if err := terraform.PrepareIAMUpgradeWorkspace(file, - templateDir, - c.iamWorkspace, - filepath.Join(c.upgradeWorkspace, c.upgradeID, constants.TerraformIAMUpgradeWorkingDir), - filepath.Join(c.upgradeWorkspace, c.upgradeID, constants.TerraformIAMUpgradeBackupDir), - ); err != nil { - return false, fmt.Errorf("preparing terraform workspace: %w", err) - } - - hasDiff, err := c.tf.Plan(ctx, c.logLevel) - if err != nil { - return false, fmt.Errorf("terraform plan: %w", err) - } - - if hasDiff { - if err := c.tf.ShowPlan(ctx, c.logLevel, outWriter); err != nil { - return false, fmt.Errorf("terraform show plan: %w", err) - } - } - - return hasDiff, nil -} - -// Apply applies the Terraform IAM migrations for the Constellation upgrade. -func (c *IAMMigrateCmd) Apply(ctx context.Context, fileHandler file.Handler) error { - if _, err := c.tf.ApplyIAM(ctx, c.csp, c.logLevel); err != nil { - return fmt.Errorf("terraform apply: %w", err) - } - - if err := fileHandler.RemoveAll(c.iamWorkspace); err != nil { - return fmt.Errorf("removing old terraform directory: %w", err) - } - if err := fileHandler.CopyDir( - filepath.Join(c.upgradeWorkspace, c.upgradeID, constants.TerraformIAMUpgradeWorkingDir), - c.iamWorkspace, - ); err != nil { - return fmt.Errorf("replacing old terraform directory with new one: %w", err) - } - - if err := fileHandler.RemoveAll(filepath.Join(c.upgradeWorkspace, c.upgradeID, constants.TerraformIAMUpgradeWorkingDir)); err != nil { - return fmt.Errorf("removing terraform upgrade directory: %w", err) - } - - return nil -} diff --git a/cli/internal/upgrade/iammigrate_test.go b/cli/internal/upgrade/iammigrate_test.go deleted file mode 100644 index 76957839bf..0000000000 --- a/cli/internal/upgrade/iammigrate_test.go +++ /dev/null @@ -1,119 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package upgrade - -import ( - "bytes" - "context" - "io" - "path/filepath" - "testing" - - "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" - "github.com/edgelesssys/constellation/v2/internal/constants" - "github.com/edgelesssys/constellation/v2/internal/file" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestIAMMigrate(t *testing.T) { - upgradeID := "test-upgrade" - upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformIAMUpgradeWorkingDir) - fs, file := setupMemFSAndFileHandler(t, []string{"terraform.tfvars", "terraform.tfstate"}, []byte("OLD")) - // act - fakeTfClient := &tfClientStub{upgradeID, file} - sut := &IAMMigrateCmd{ - tf: fakeTfClient, - upgradeID: upgradeID, - csp: cloudprovider.AWS, - logLevel: terraform.LogLevelDebug, - iamWorkspace: constants.TerraformIAMWorkingDir, - upgradeWorkspace: constants.UpgradeDir, - } - hasDiff, err := sut.Plan(context.Background(), file, bytes.NewBuffer(nil)) - // assert - assert.NoError(t, err) - assert.False(t, hasDiff) - assertFileExists(fs, filepath.Join(upgradeDir, "terraform.tfvars"), t) - assertFileExists(fs, filepath.Join(upgradeDir, "terraform.tfstate"), t) - - // act - err = sut.Apply(context.Background(), file) - assert.NoError(t, err) - // assert - assertFileReadsContent(file, filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfvars"), "NEW", t) - assertFileReadsContent(file, filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfstate"), "NEW", t) - assertFileDoesntExist(fs, filepath.Join(upgradeDir), t) -} - -func assertFileReadsContent(file file.Handler, path string, expectedContent string, t *testing.T) { - bt, err := file.Read(path) - assert.NoError(t, err) - assert.Equal(t, expectedContent, string(bt)) -} - -func assertFileExists(fs afero.Fs, path string, t *testing.T) { - res, err := fs.Stat(path) - assert.NoError(t, err) - assert.NotNil(t, res) -} - -func assertFileDoesntExist(fs afero.Fs, path string, t *testing.T) { - res, err := fs.Stat(path) - assert.Error(t, err) - assert.Nil(t, res) -} - -// setupMemFSAndFileHandler sets up a file handler with a memory file system and writes the given files with the given content. -func setupMemFSAndFileHandler(t *testing.T, files []string, content []byte) (afero.Fs, file.Handler) { - fs := afero.NewMemMapFs() - file := file.NewHandler(fs) - err := file.MkdirAll(constants.TerraformIAMWorkingDir) - require.NoError(t, err) - - for _, f := range files { - err := file.Write(filepath.Join(constants.TerraformIAMWorkingDir, f), content) - require.NoError(t, err) - } - return fs, file -} - -type tfClientStub struct { - upgradeID string - file file.Handler -} - -func (t *tfClientStub) Plan(_ context.Context, _ terraform.LogLevel) (bool, error) { - return false, nil -} - -func (t *tfClientStub) ShowPlan(_ context.Context, _ terraform.LogLevel, _ io.Writer) error { - return nil -} - -func (t *tfClientStub) ApplyIAM(_ context.Context, _ cloudprovider.Provider, _ terraform.LogLevel) (terraform.IAMOutput, error) { - upgradeDir := filepath.Join(constants.UpgradeDir, t.upgradeID, constants.TerraformIAMUpgradeWorkingDir) - err := t.file.Remove(filepath.Join(upgradeDir, "terraform.tfvars")) - if err != nil { - return terraform.IAMOutput{}, err - } - err = t.file.Write(filepath.Join(upgradeDir, "terraform.tfvars"), []byte("NEW")) - if err != nil { - return terraform.IAMOutput{}, err - } - err = t.file.Remove(filepath.Join(upgradeDir, "terraform.tfstate")) - if err != nil { - return terraform.IAMOutput{}, err - } - err = t.file.Write(filepath.Join(upgradeDir, "terraform.tfstate"), []byte("NEW")) - if err != nil { - return terraform.IAMOutput{}, err - } - return terraform.IAMOutput{}, nil -} diff --git a/cli/internal/upgrade/terraform.go b/cli/internal/upgrade/terraform.go deleted file mode 100644 index e1f03bbb20..0000000000 --- a/cli/internal/upgrade/terraform.go +++ /dev/null @@ -1,197 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package upgrade - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/edgelesssys/constellation/v2/cli/internal/cloudcmd" - "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" - "github.com/edgelesssys/constellation/v2/internal/constants" - "github.com/edgelesssys/constellation/v2/internal/file" -) - -// TerraformUpgradeOptions are the options used for the Terraform upgrade. -type TerraformUpgradeOptions struct { - // LogLevel is the log level used for Terraform. - LogLevel terraform.LogLevel - // CSP is the cloud provider to perform the upgrade on. - CSP cloudprovider.Provider - // Vars are the Terraform variables used for the upgrade. - Vars terraform.Variables - TFWorkspace string - UpgradeWorkspace string -} - -// TerraformUpgrader is responsible for performing Terraform migrations on cluster upgrades. -type TerraformUpgrader struct { - tf tfResourceClient - policyPatcher policyPatcher - outWriter io.Writer - fileHandler file.Handler - upgradeID string -} - -// NewTerraformUpgrader returns a new TerraformUpgrader. -func NewTerraformUpgrader(tfClient tfResourceClient, outWriter io.Writer, fileHandler file.Handler, upgradeID string, -) *TerraformUpgrader { - return &TerraformUpgrader{ - tf: tfClient, - policyPatcher: cloudcmd.NewAzurePolicyPatcher(), - outWriter: outWriter, - fileHandler: fileHandler, - upgradeID: upgradeID, - } -} - -// CheckTerraformMigrations checks whether Terraform migrations are possible in the current workspace. -// If the files that will be written during the upgrade already exist, it returns an error. -func (u *TerraformUpgrader) CheckTerraformMigrations(upgradeWorkspace string) error { - return checkTerraformMigrations(u.fileHandler, upgradeWorkspace, u.upgradeID, constants.TerraformUpgradeBackupDir) -} - -// PlanTerraformMigrations prepares the upgrade workspace and plans the Terraform migrations for the Constellation upgrade. -// If a diff exists, it's being written to the upgrader's output writer. It also returns -// a bool indicating whether a diff exists. -func (u *TerraformUpgrader) PlanTerraformMigrations(ctx context.Context, opts TerraformUpgradeOptions) (bool, error) { - // Prepare the new Terraform workspace and backup the old one - err := u.tf.PrepareUpgradeWorkspace( - filepath.Join("terraform", strings.ToLower(opts.CSP.String())), - opts.TFWorkspace, - filepath.Join(opts.UpgradeWorkspace, u.upgradeID, constants.TerraformUpgradeWorkingDir), - filepath.Join(opts.UpgradeWorkspace, u.upgradeID, constants.TerraformUpgradeBackupDir), - opts.Vars, - ) - if err != nil { - return false, fmt.Errorf("preparing terraform workspace: %w", err) - } - - hasDiff, err := u.tf.Plan(ctx, opts.LogLevel) - if err != nil { - return false, fmt.Errorf("terraform plan: %w", err) - } - - if hasDiff { - if err := u.tf.ShowPlan(ctx, opts.LogLevel, u.outWriter); err != nil { - return false, fmt.Errorf("terraform show plan: %w", err) - } - } - - return hasDiff, nil -} - -// CleanUpTerraformMigrations cleans up the Terraform migration workspace, for example when an upgrade is -// aborted by the user. -func (u *TerraformUpgrader) CleanUpTerraformMigrations(upgradeWorkspace string) error { - return CleanUpTerraformMigrations(upgradeWorkspace, u.upgradeID, u.fileHandler) -} - -// ApplyTerraformMigrations applies the migrations planned by PlanTerraformMigrations. -// If PlanTerraformMigrations has not been executed before, it will return an error. -// In case of a successful upgrade, the output will be written to the specified file and the old Terraform directory is replaced -// By the new one. -func (u *TerraformUpgrader) ApplyTerraformMigrations(ctx context.Context, opts TerraformUpgradeOptions) (terraform.ApplyOutput, error) { - tfOutput, err := u.tf.ApplyCluster(ctx, opts.CSP, opts.LogLevel) - if err != nil { - return tfOutput, fmt.Errorf("terraform apply: %w", err) - } - if tfOutput.Azure != nil { - if err := u.policyPatcher.Patch(ctx, tfOutput.Azure.AttestationURL); err != nil { - return tfOutput, fmt.Errorf("patching policies: %w", err) - } - } - if err := u.fileHandler.RemoveAll(opts.TFWorkspace); err != nil { - return tfOutput, fmt.Errorf("removing old terraform directory: %w", err) - } - - if err := u.fileHandler.CopyDir( - filepath.Join(opts.UpgradeWorkspace, u.upgradeID, constants.TerraformUpgradeWorkingDir), - opts.TFWorkspace, - ); err != nil { - return tfOutput, fmt.Errorf("replacing old terraform directory with new one: %w", err) - } - if err := u.fileHandler.RemoveAll(filepath.Join(opts.UpgradeWorkspace, u.upgradeID, constants.TerraformUpgradeWorkingDir)); err != nil { - return tfOutput, fmt.Errorf("removing terraform upgrade directory: %w", err) - } - return tfOutput, nil -} - -// UpgradeID returns the ID of the upgrade. -func (u *TerraformUpgrader) UpgradeID() string { - return u.upgradeID -} - -// CleanUpTerraformMigrations cleans up the Terraform upgrade directory. -func CleanUpTerraformMigrations(upgradeWorkspace, upgradeID string, fileHandler file.Handler) error { - upgradeDir := filepath.Join(upgradeWorkspace, upgradeID) - if err := fileHandler.RemoveAll(upgradeDir); err != nil { - return fmt.Errorf("cleaning up file %s: %w", upgradeDir, err) - } - return nil -} - -// CheckTerraformMigrations checks whether Terraform migrations are possible in the current workspace. -func checkTerraformMigrations(file file.Handler, upgradeWorkspace, upgradeID, upgradeSubDir string) error { - var existingFiles []string - filesToCheck := []string{ - filepath.Join(upgradeWorkspace, upgradeID, upgradeSubDir), - } - - for _, f := range filesToCheck { - if err := checkFileExists(file, &existingFiles, f); err != nil { - return fmt.Errorf("checking terraform migrations: %w", err) - } - } - - if len(existingFiles) > 0 { - return fmt.Errorf("file(s) %s already exist", strings.Join(existingFiles, ", ")) - } - return nil -} - -// checkFileExists checks whether a file exists and adds it to the existingFiles slice if it does. -func checkFileExists(fileHandler file.Handler, existingFiles *[]string, filename string) error { - _, err := fileHandler.Stat(filename) - if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("checking %s: %w", filename, err) - } - return nil - } - - *existingFiles = append(*existingFiles, filename) - return nil -} - -type tfClientCommon interface { - ShowPlan(ctx context.Context, logLevel terraform.LogLevel, output io.Writer) error - Plan(ctx context.Context, logLevel terraform.LogLevel) (bool, error) -} - -// tfResourceClient is a Terraform client for managing cluster resources. -type tfResourceClient interface { - PrepareUpgradeWorkspace(embeddedPath, oldWorkingDir, newWorkingDir, backupDir string, vars terraform.Variables) error - ApplyCluster(ctx context.Context, provider cloudprovider.Provider, logLevel terraform.LogLevel) (terraform.ApplyOutput, error) - tfClientCommon -} - -// tfIAMClient is a Terraform client for managing IAM resources. -type tfIAMClient interface { - ApplyIAM(ctx context.Context, csp cloudprovider.Provider, logLevel terraform.LogLevel) (terraform.IAMOutput, error) - tfClientCommon -} - -// policyPatcher interacts with the CSP (currently only applies for Azure) to update the attestation policy. -type policyPatcher interface { - Patch(ctx context.Context, attestationURL string) error -} diff --git a/cli/internal/upgrade/terraform_test.go b/cli/internal/upgrade/terraform_test.go deleted file mode 100644 index fcc1cd2f7e..0000000000 --- a/cli/internal/upgrade/terraform_test.go +++ /dev/null @@ -1,331 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package upgrade - -import ( - "bytes" - "context" - "io" - "path/filepath" - "testing" - - "github.com/edgelesssys/constellation/v2/cli/internal/terraform" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" - "github.com/edgelesssys/constellation/v2/internal/constants" - "github.com/edgelesssys/constellation/v2/internal/file" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCheckTerraformMigrations(t *testing.T) { - workspace := func(existingFiles []string) file.Handler { - fs := afero.NewMemMapFs() - for _, f := range existingFiles { - require.NoError(t, afero.WriteFile(fs, f, []byte{}, 0o644)) - } - - return file.NewHandler(fs) - } - - testCases := map[string]struct { - upgradeID string - workspace file.Handler - wantErr bool - }{ - "success": { - upgradeID: "1234", - workspace: workspace(nil), - }, - "terraform backup dir already exists": { - upgradeID: "1234", - workspace: workspace([]string{filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeBackupDir)}), - wantErr: true, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - u := NewTerraformUpgrader(&stubTerraformClient{}, bytes.NewBuffer(nil), tc.workspace, tc.upgradeID) - - err := u.CheckTerraformMigrations(constants.UpgradeDir) - if tc.wantErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - }) - } -} - -func TestPlanTerraformMigrations(t *testing.T) { - workspace := func(existingFiles []string) file.Handler { - fs := afero.NewMemMapFs() - for _, f := range existingFiles { - require.NoError(t, afero.WriteFile(fs, f, []byte{}, 0o644)) - } - - return file.NewHandler(fs) - } - - testCases := map[string]struct { - upgradeID string - tf tfResourceClient - workspace file.Handler - want bool - wantErr bool - }{ - "success no diff": { - upgradeID: "1234", - tf: &stubTerraformClient{}, - workspace: workspace([]string{}), - }, - "success diff": { - upgradeID: "1234", - tf: &stubTerraformClient{ - hasDiff: true, - }, - workspace: workspace([]string{}), - want: true, - }, - "prepare workspace error": { - upgradeID: "1234", - tf: &stubTerraformClient{ - prepareWorkspaceErr: assert.AnError, - }, - workspace: workspace([]string{}), - wantErr: true, - }, - "plan error": { - tf: &stubTerraformClient{ - planErr: assert.AnError, - }, - workspace: workspace([]string{}), - wantErr: true, - }, - "show plan error no diff": { - upgradeID: "1234", - tf: &stubTerraformClient{ - showErr: assert.AnError, - }, - workspace: workspace([]string{}), - }, - "show plan error diff": { - upgradeID: "1234", - tf: &stubTerraformClient{ - showErr: assert.AnError, - hasDiff: true, - }, - workspace: workspace([]string{}), - wantErr: true, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - require := require.New(t) - - u := NewTerraformUpgrader(tc.tf, bytes.NewBuffer(nil), tc.workspace, tc.upgradeID) - - opts := TerraformUpgradeOptions{ - LogLevel: terraform.LogLevelDebug, - CSP: cloudprovider.Unknown, - Vars: &terraform.QEMUVariables{}, - } - - diff, err := u.PlanTerraformMigrations(context.Background(), opts) - if tc.wantErr { - require.Error(err) - } else { - require.NoError(err) - require.Equal(tc.want, diff) - } - }) - } -} - -func TestApplyTerraformMigrations(t *testing.T) { - fileHandler := func(upgradeID string, existingFiles ...string) file.Handler { - fh := file.NewHandler(afero.NewMemMapFs()) - - require.NoError(t, - fh.Write( - filepath.Join(constants.UpgradeDir, upgradeID, constants.TerraformUpgradeWorkingDir, "someFile"), - []byte("some content"), - )) - for _, f := range existingFiles { - require.NoError(t, fh.Write(f, []byte("some content"))) - } - return fh - } - - testCases := map[string]struct { - upgradeID string - tf tfResourceClient - policyPatcher stubPolicyPatcher - fs file.Handler - wantErr bool - }{ - "success": { - upgradeID: "1234", - tf: &stubTerraformClient{}, - fs: fileHandler("1234"), - policyPatcher: stubPolicyPatcher{}, - }, - "create cluster error": { - upgradeID: "1234", - tf: &stubTerraformClient{ - CreateClusterErr: assert.AnError, - }, - fs: fileHandler("1234"), - policyPatcher: stubPolicyPatcher{}, - wantErr: true, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - require := require.New(t) - - u := NewTerraformUpgrader(tc.tf, bytes.NewBuffer(nil), tc.fs, tc.upgradeID) - - opts := TerraformUpgradeOptions{ - LogLevel: terraform.LogLevelDebug, - CSP: cloudprovider.Unknown, - Vars: &terraform.QEMUVariables{}, - TFWorkspace: "test", - UpgradeWorkspace: constants.UpgradeDir, - } - - _, err := u.ApplyTerraformMigrations(context.Background(), opts) - if tc.wantErr { - require.Error(err) - } else { - require.NoError(err) - } - }) - } -} - -func TestCleanUpTerraformMigrations(t *testing.T) { - workspace := func(existingFiles []string) file.Handler { - fs := afero.NewMemMapFs() - for _, f := range existingFiles { - require.NoError(t, afero.WriteFile(fs, f, []byte{}, 0o644)) - } - - return file.NewHandler(fs) - } - - testCases := map[string]struct { - upgradeID string - workspaceFiles []string - wantFiles []string - wantErr bool - }{ - "no files": { - upgradeID: "1234", - workspaceFiles: nil, - wantFiles: []string{}, - }, - "clean backup dir": { - upgradeID: "1234", - workspaceFiles: []string{ - filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeBackupDir), - }, - wantFiles: []string{}, - }, - "clean working dir": { - upgradeID: "1234", - workspaceFiles: []string{ - filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeWorkingDir), - }, - wantFiles: []string{}, - }, - "clean all": { - upgradeID: "1234", - workspaceFiles: []string{ - filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeBackupDir), - filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeWorkingDir), - filepath.Join(constants.UpgradeDir, "1234", "abc"), - }, - wantFiles: []string{}, - }, - "leave other files": { - upgradeID: "1234", - workspaceFiles: []string{ - filepath.Join(constants.UpgradeDir, "1234", constants.TerraformUpgradeBackupDir), - filepath.Join(constants.UpgradeDir, "other"), - }, - wantFiles: []string{ - filepath.Join(constants.UpgradeDir, "other"), - }, - }, - } - - for name, tc := range testCases { - t.Run(name, func(t *testing.T) { - require := require.New(t) - - workspace := workspace(tc.workspaceFiles) - u := NewTerraformUpgrader(&stubTerraformClient{}, bytes.NewBuffer(nil), workspace, tc.upgradeID) - - err := u.CleanUpTerraformMigrations(constants.UpgradeDir) - if tc.wantErr { - require.Error(err) - return - } - - require.NoError(err) - - for _, haveFile := range tc.workspaceFiles { - for _, wantFile := range tc.wantFiles { - if haveFile == wantFile { - _, err := workspace.Stat(wantFile) - require.NoError(err, "file %s should exist", wantFile) - } else { - _, err := workspace.Stat(haveFile) - require.Error(err, "file %s should not exist", haveFile) - } - } - } - }) - } -} - -type stubTerraformClient struct { - hasDiff bool - prepareWorkspaceErr error - showErr error - planErr error - CreateClusterErr error -} - -func (u *stubTerraformClient) PrepareUpgradeWorkspace(_, _, _, _ string, _ terraform.Variables) error { - return u.prepareWorkspaceErr -} - -func (u *stubTerraformClient) ShowPlan(_ context.Context, _ terraform.LogLevel, _ io.Writer) error { - return u.showErr -} - -func (u *stubTerraformClient) Plan(_ context.Context, _ terraform.LogLevel) (bool, error) { - return u.hasDiff, u.planErr -} - -func (u *stubTerraformClient) ApplyCluster(_ context.Context, _ cloudprovider.Provider, _ terraform.LogLevel) (terraform.ApplyOutput, error) { - return terraform.ApplyOutput{}, u.CreateClusterErr -} - -type stubPolicyPatcher struct { - patchErr error -} - -func (p *stubPolicyPatcher) PatchPolicy(_ context.Context, _ string) error { - return p.patchErr -} diff --git a/cli/internal/upgrade/upgrade.go b/cli/internal/upgrade/upgrade.go deleted file mode 100644 index 112cbf7b1d..0000000000 --- a/cli/internal/upgrade/upgrade.go +++ /dev/null @@ -1,14 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -/* -Package upgrade provides functionality to upgrade the cluster and it's resources. - -TODO: Remove this package in favour of adding splitting its functionality onto the kubernetes, helm, and terraform packages. -There should be no additions to this package at the current time. -If you need to make larger changes to existing code, consider refactoring and moving relevant code to the kubernetes, helm, or terraform packages. -*/ -package upgrade