cli: perform upgrades in-place in Terraform workspace (#2317)

* perform upgrades in-place in terraform workspace

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* update buildfiles

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* add iam upgrade apply test

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* update buildfiles

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* fix linter

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* make config fetcher stubbable

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* change workspace restoring behaviour

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* allow overwriting existing Terraform files

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* allow overwrites of TF variables

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* fix iam upgrade apply

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* fix embed directive

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* make loader test less brittle

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* pass upgrade ID to user

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* naming nit

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* use upgradeDir

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

* tidy

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>

---------

Signed-off-by: Moritz Sanft <58110325+msanft@users.noreply.github.com>
This commit is contained in:
Moritz Sanft 2023-09-14 11:51:20 +02:00 committed by GitHub
parent 9c54ff06e0
commit 95cf4bdf21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 410 additions and 286 deletions

View File

@ -52,7 +52,6 @@ go_test(
"clusterupgrade_test.go", "clusterupgrade_test.go",
"create_test.go", "create_test.go",
"iam_test.go", "iam_test.go",
"iamupgrade_test.go",
"patch_test.go", "patch_test.go",
"rollback_test.go", "rollback_test.go",
"terminate_test.go", "terminate_test.go",

View File

@ -45,7 +45,7 @@ type tfIAMClient interface {
type tfUpgradePlanner interface { type tfUpgradePlanner interface {
ShowPlan(ctx context.Context, logLevel terraform.LogLevel, output io.Writer) error ShowPlan(ctx context.Context, logLevel terraform.LogLevel, output io.Writer) error
Plan(ctx context.Context, logLevel terraform.LogLevel) (bool, error) Plan(ctx context.Context, logLevel terraform.LogLevel) (bool, error)
PrepareUpgradeWorkspace(embeddedPath, oldWorkingDir, backupDir string, vars terraform.Variables) error PrepareUpgradeWorkspace(embeddedPath, backupDir string, vars terraform.Variables) error
} }
type tfIAMUpgradeClient interface { type tfIAMUpgradeClient interface {

View File

@ -35,7 +35,7 @@ type ClusterUpgrader struct {
func NewClusterUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string, func NewClusterUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string,
logLevel terraform.LogLevel, fileHandler file.Handler, logLevel terraform.LogLevel, fileHandler file.Handler,
) (*ClusterUpgrader, error) { ) (*ClusterUpgrader, error) {
tfClient, err := terraform.New(ctx, filepath.Join(upgradeWorkspace, constants.TerraformUpgradeWorkingDir)) tfClient, err := terraform.New(ctx, constants.TerraformWorkingDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("setting up terraform client: %w", err) return nil, fmt.Errorf("setting up terraform client: %w", err)
} }
@ -57,11 +57,17 @@ func (u *ClusterUpgrader) PlanClusterUpgrade(ctx context.Context, outWriter io.W
return planUpgrade( return planUpgrade(
ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars, ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars,
filepath.Join("terraform", strings.ToLower(csp.String())), filepath.Join("terraform", strings.ToLower(csp.String())),
u.existingWorkspace,
filepath.Join(u.upgradeWorkspace, constants.TerraformUpgradeBackupDir), filepath.Join(u.upgradeWorkspace, constants.TerraformUpgradeBackupDir),
) )
} }
// RestoreClusterWorkspace rolls back the existing workspace to the backup directory created when planning an upgrade,
// when the user decides to not apply an upgrade after planning it.
// Note that this will not apply the restored state from the backup.
func (u *ClusterUpgrader) RestoreClusterWorkspace() error {
return restoreBackup(u.fileHandler, u.existingWorkspace, filepath.Join(u.upgradeWorkspace, constants.TerraformUpgradeBackupDir))
}
// ApplyClusterUpgrade applies the Terraform migrations planned by PlanClusterUpgrade. // ApplyClusterUpgrade applies the Terraform migrations planned by PlanClusterUpgrade.
// On success, the workspace of the Upgrader replaces the existing Terraform workspace. // 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) { func (u *ClusterUpgrader) ApplyClusterUpgrade(ctx context.Context, csp cloudprovider.Provider) (terraform.ApplyOutput, error) {
@ -75,13 +81,5 @@ func (u *ClusterUpgrader) ApplyClusterUpgrade(ctx context.Context, csp cloudprov
} }
} }
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 return tfOutput, nil
} }

View File

@ -198,6 +198,6 @@ func (t *tfClusterUpgradeStub) ApplyCluster(_ context.Context, _ cloudprovider.P
return terraform.ApplyOutput{}, t.applyErr return terraform.ApplyOutput{}, t.applyErr
} }
func (t *tfClusterUpgradeStub) PrepareUpgradeWorkspace(_, _, _ string, _ terraform.Variables) error { func (t *tfClusterUpgradeStub) PrepareUpgradeWorkspace(_, _ string, _ terraform.Variables) error {
return t.prepareWorkspaceErr return t.prepareWorkspaceErr
} }

View File

@ -42,7 +42,7 @@ type IAMUpgrader struct {
func NewIAMUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string, func NewIAMUpgrader(ctx context.Context, existingWorkspace, upgradeWorkspace string,
logLevel terraform.LogLevel, fileHandler file.Handler, logLevel terraform.LogLevel, fileHandler file.Handler,
) (*IAMUpgrader, error) { ) (*IAMUpgrader, error) {
tfClient, err := terraform.New(ctx, filepath.Join(upgradeWorkspace, constants.TerraformIAMUpgradeWorkingDir)) tfClient, err := terraform.New(ctx, constants.TerraformIAMWorkingDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("setting up terraform client: %w", err) return nil, fmt.Errorf("setting up terraform client: %w", err)
} }
@ -62,11 +62,17 @@ func (u *IAMUpgrader) PlanIAMUpgrade(ctx context.Context, outWriter io.Writer, v
return planUpgrade( return planUpgrade(
ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars, ctx, u.tf, u.fileHandler, outWriter, u.logLevel, vars,
filepath.Join("terraform", "iam", strings.ToLower(csp.String())), filepath.Join("terraform", "iam", strings.ToLower(csp.String())),
u.existingWorkspace,
filepath.Join(u.upgradeWorkspace, constants.TerraformIAMUpgradeBackupDir), filepath.Join(u.upgradeWorkspace, constants.TerraformIAMUpgradeBackupDir),
) )
} }
// RestoreIAMWorkspace rolls back the existing workspace to the backup directory created when planning an upgrade,
// when the user decides to not apply an upgrade after planning it.
// Note that this will not apply the restored state from the backup.
func (u *IAMUpgrader) RestoreIAMWorkspace() error {
return restoreBackup(u.fileHandler, u.existingWorkspace, filepath.Join(u.upgradeWorkspace, constants.TerraformIAMUpgradeBackupDir))
}
// ApplyIAMUpgrade applies the Terraform IAM migrations planned by PlanIAMUpgrade. // ApplyIAMUpgrade applies the Terraform IAM migrations planned by PlanIAMUpgrade.
// On success, the workspace of the Upgrader replaces the existing Terraform workspace. // On success, the workspace of the Upgrader replaces the existing Terraform workspace.
func (u *IAMUpgrader) ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error { func (u *IAMUpgrader) ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error {
@ -74,13 +80,5 @@ func (u *IAMUpgrader) ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Pro
return fmt.Errorf("terraform apply: %w", err) 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 return nil
} }

View File

@ -1,136 +0,0 @@
/*
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"))
}

View File

@ -21,16 +21,15 @@ import (
func planUpgrade( func planUpgrade(
ctx context.Context, tfClient tfUpgradePlanner, fileHandler file.Handler, ctx context.Context, tfClient tfUpgradePlanner, fileHandler file.Handler,
outWriter io.Writer, logLevel terraform.LogLevel, vars terraform.Variables, outWriter io.Writer, logLevel terraform.LogLevel, vars terraform.Variables,
templateDir, existingWorkspace, backupDir string, templateDir, backupDir string,
) (bool, error) { ) (bool, error) {
if err := ensureFileNotExist(fileHandler, backupDir); err != nil { if err := ensureFileNotExist(fileHandler, backupDir); err != nil {
return false, fmt.Errorf("workspace is not clean: %w", err) return false, fmt.Errorf("backup directory %s already exists: %w", backupDir, err)
} }
// Prepare the new Terraform workspace and backup the old one // Backup the old Terraform workspace and move the embedded Terraform files into the workspace.
err := tfClient.PrepareUpgradeWorkspace( err := tfClient.PrepareUpgradeWorkspace(
templateDir, templateDir,
existingWorkspace,
backupDir, backupDir,
vars, vars,
) )
@ -52,20 +51,20 @@ func planUpgrade(
return hasDiff, nil return hasDiff, nil
} }
// moveUpgradeToCurrent replaces the an existing Terraform workspace with a workspace holding migrated Terraform resources. // restoreBackup replaces the existing Terraform workspace with the backup.
func moveUpgradeToCurrent(fileHandler file.Handler, existingWorkspace, upgradeWorkingDir string) error { func restoreBackup(fileHandler file.Handler, workingDir, backupDir string) error {
if err := fileHandler.RemoveAll(existingWorkspace); err != nil { if err := fileHandler.RemoveAll(workingDir); err != nil {
return fmt.Errorf("removing old terraform directory: %w", err) return fmt.Errorf("removing existing workspace: %w", err)
} }
if err := fileHandler.CopyDir( if err := fileHandler.CopyDir(
upgradeWorkingDir, backupDir,
existingWorkspace, workingDir,
); err != nil { ); err != nil {
return fmt.Errorf("replacing old terraform directory with new one: %w", err) return fmt.Errorf("replacing terraform workspace with backup: %w", err)
} }
if err := fileHandler.RemoveAll(upgradeWorkingDir); err != nil { if err := fileHandler.RemoveAll(backupDir); err != nil {
return fmt.Errorf("removing terraform upgrade directory: %w", err) return fmt.Errorf("removing backup directory: %w", err)
} }
return nil return nil
} }

View File

@ -87,7 +87,7 @@ func TestPlanUpgrade(t *testing.T) {
hasDiff, err := planUpgrade( hasDiff, err := planUpgrade(
context.Background(), tc.tf, fs, io.Discard, terraform.LogLevelDebug, context.Background(), tc.tf, fs, io.Discard, terraform.LogLevelDebug,
&terraform.QEMUVariables{}, &terraform.QEMUVariables{},
"existing", "upgrade", "backup", "test", "backup",
) )
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
@ -99,9 +99,9 @@ func TestPlanUpgrade(t *testing.T) {
} }
} }
func TestMoveUpgradeToCurrent(t *testing.T) { func TestRestoreBackup(t *testing.T) {
existingWorkspace := "foo" existingWorkspace := "foo"
upgradeWorkingDir := "bar" backupDir := "bar"
testCases := map[string]struct { testCases := map[string]struct {
prepareFs func(require *require.Assertions) file.Handler prepareFs func(require *require.Assertions) file.Handler
@ -111,18 +111,18 @@ func TestMoveUpgradeToCurrent(t *testing.T) {
prepareFs: func(require *require.Assertions) file.Handler { prepareFs: func(require *require.Assertions) file.Handler {
fs := file.NewHandler(afero.NewMemMapFs()) fs := file.NewHandler(afero.NewMemMapFs())
require.NoError(fs.MkdirAll(existingWorkspace)) require.NoError(fs.MkdirAll(existingWorkspace))
require.NoError(fs.MkdirAll(upgradeWorkingDir)) require.NoError(fs.MkdirAll(backupDir))
return fs return fs
}, },
}, },
"old workspace does not exist": { "existing workspace does not exist": {
prepareFs: func(require *require.Assertions) file.Handler { prepareFs: func(require *require.Assertions) file.Handler {
fs := file.NewHandler(afero.NewMemMapFs()) fs := file.NewHandler(afero.NewMemMapFs())
require.NoError(fs.MkdirAll(upgradeWorkingDir)) require.NoError(fs.MkdirAll(backupDir))
return fs return fs
}, },
}, },
"upgrade working dir does not exist": { "backup dir does not exist": {
prepareFs: func(require *require.Assertions) file.Handler { prepareFs: func(require *require.Assertions) file.Handler {
fs := file.NewHandler(afero.NewMemMapFs()) fs := file.NewHandler(afero.NewMemMapFs())
require.NoError(fs.MkdirAll(existingWorkspace)) require.NoError(fs.MkdirAll(existingWorkspace))
@ -135,8 +135,7 @@ func TestMoveUpgradeToCurrent(t *testing.T) {
memFS := afero.NewMemMapFs() memFS := afero.NewMemMapFs()
fs := file.NewHandler(memFS) fs := file.NewHandler(memFS)
require.NoError(fs.MkdirAll(existingWorkspace)) require.NoError(fs.MkdirAll(existingWorkspace))
require.NoError(fs.MkdirAll(upgradeWorkingDir)) require.NoError(fs.MkdirAll(backupDir))
return file.NewHandler(afero.NewReadOnlyFs(memFS)) return file.NewHandler(afero.NewReadOnlyFs(memFS))
}, },
wantErr: true, wantErr: true,
@ -148,7 +147,7 @@ func TestMoveUpgradeToCurrent(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)
fs := tc.prepareFs(require.New(t)) fs := tc.prepareFs(require.New(t))
err := moveUpgradeToCurrent(fs, existingWorkspace, upgradeWorkingDir) err := restoreBackup(fs, existingWorkspace, backupDir)
if tc.wantErr { if tc.wantErr {
assert.Error(err) assert.Error(err)
return return
@ -209,7 +208,7 @@ type stubUpgradePlanner struct {
showPlanErr error showPlanErr error
} }
func (s *stubUpgradePlanner) PrepareUpgradeWorkspace(_, _ string, _ string, _ terraform.Variables) error { func (s *stubUpgradePlanner) PrepareUpgradeWorkspace(_, _ string, _ terraform.Variables) error {
return s.prepareWorkspaceErr return s.prepareWorkspaceErr
} }

View File

@ -115,6 +115,7 @@ go_test(
"create_test.go", "create_test.go",
"iamcreate_test.go", "iamcreate_test.go",
"iamdestroy_test.go", "iamdestroy_test.go",
"iamupgradeapply_test.go",
"init_test.go", "init_test.go",
"recover_test.go", "recover_test.go",
"spinner_test.go", "spinner_test.go",

View File

@ -48,8 +48,8 @@ func newIAMUpgradeApplyCmd() *cobra.Command {
type iamUpgradeApplyCmd struct { type iamUpgradeApplyCmd struct {
fileHandler file.Handler fileHandler file.Handler
configFetcher attestationconfigapi.Fetcher
log debugLog log debugLog
configFetcher attestationconfigapi.Fetcher
} }
func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error { func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error {
@ -58,10 +58,9 @@ func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("parsing force argument: %w", err) return fmt.Errorf("parsing force argument: %w", err)
} }
fileHandler := file.NewHandler(afero.NewOsFs()) fileHandler := file.NewHandler(afero.NewOsFs())
configFetcher := attestationconfigapi.NewFetcher()
upgradeID := generateUpgradeID(upgradeCmdKindIAM) upgradeID := generateUpgradeID(upgradeCmdKindIAM)
upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID) upgradeDir := filepath.Join(constants.UpgradeDir, upgradeID)
configFetcher := attestationconfigapi.NewFetcher()
iamMigrateCmd, err := cloudcmd.NewIAMUpgrader( iamMigrateCmd, err := cloudcmd.NewIAMUpgrader(
cmd.Context(), cmd.Context(),
constants.TerraformIAMWorkingDir, constants.TerraformIAMWorkingDir,
@ -85,8 +84,8 @@ func runIAMUpgradeApply(cmd *cobra.Command, _ []string) error {
i := iamUpgradeApplyCmd{ i := iamUpgradeApplyCmd{
fileHandler: fileHandler, fileHandler: fileHandler,
configFetcher: configFetcher,
log: log, log: log,
configFetcher: configFetcher,
} }
return i.iamUpgradeApply(cmd, iamMigrateCmd, upgradeDir, force, yes) return i.iamUpgradeApply(cmd, iamMigrateCmd, upgradeDir, force, yes)
@ -108,7 +107,7 @@ func (i iamUpgradeApplyCmd) iamUpgradeApply(cmd *cobra.Command, iamUpgrader iamU
} }
hasDiff, err := iamUpgrader.PlanIAMUpgrade(cmd.Context(), cmd.OutOrStderr(), vars, conf.GetProvider()) hasDiff, err := iamUpgrader.PlanIAMUpgrade(cmd.Context(), cmd.OutOrStderr(), vars, conf.GetProvider())
if err != nil { if err != nil {
return err return fmt.Errorf("planning terraform migrations: %w", err)
} }
if !hasDiff && !force { if !hasDiff && !force {
cmd.Println("No IAM migrations necessary.") cmd.Println("No IAM migrations necessary.")
@ -124,9 +123,14 @@ func (i iamUpgradeApplyCmd) iamUpgradeApply(cmd *cobra.Command, iamUpgrader iamU
} }
if !ok { if !ok {
cmd.Println("Aborting upgrade.") cmd.Println("Aborting upgrade.")
// Remove the upgrade directory // User doesn't expect to see any changes in his workspace after aborting an "upgrade apply",
if err := i.fileHandler.RemoveAll(upgradeDir); err != nil { // therefore, roll back to the backed up state.
return fmt.Errorf("cleaning up upgrade directory %s: %w", upgradeDir, err) if err := iamUpgrader.RestoreIAMWorkspace(); err != nil {
return fmt.Errorf(
"restoring Terraform workspace: %w, restore the Terraform workspace manually from %s ",
err,
filepath.Join(upgradeDir, constants.TerraformIAMUpgradeBackupDir),
)
} }
return errors.New("IAM upgrade aborted by user") return errors.New("IAM upgrade aborted by user")
} }
@ -144,4 +148,5 @@ func (i iamUpgradeApplyCmd) iamUpgradeApply(cmd *cobra.Command, iamUpgrader iamU
type iamUpgrader interface { type iamUpgrader interface {
PlanIAMUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) PlanIAMUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error)
ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error ApplyIAMUpgrade(ctx context.Context, csp cloudprovider.Provider) error
RestoreIAMWorkspace() error
} }

View File

@ -0,0 +1,181 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"context"
"io"
"path/filepath"
"strings"
"testing"
"time"
"github.com/edgelesssys/constellation/v2/cli/internal/terraform"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIamUpgradeApply(t *testing.T) {
setupFs := func(createConfig, createTerraformVars bool) file.Handler {
fs := afero.NewMemMapFs()
fh := file.NewHandler(fs)
if createConfig {
cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), cloudprovider.Azure)
require.NoError(t, fh.WriteYAML(constants.ConfigFilename, cfg))
}
if createTerraformVars {
require.NoError(t, fh.Write(
filepath.Join(constants.TerraformIAMWorkingDir, "terraform.tfvars"),
[]byte(
"region = \"foo\"\n"+
"resource_group_name = \"bar\"\n"+
"service_principal_name = \"baz\"\n",
),
))
}
return fh
}
testCases := map[string]struct {
fh file.Handler
iamUpgrader *stubIamUpgrader
configFetcher *stubConfigFetcher
yesFlag bool
input string
wantErr bool
}{
"success": {
fh: setupFs(true, true),
configFetcher: &stubConfigFetcher{},
iamUpgrader: &stubIamUpgrader{},
},
"abort": {
fh: setupFs(true, true),
iamUpgrader: &stubIamUpgrader{},
configFetcher: &stubConfigFetcher{},
input: "no",
yesFlag: true,
},
"config missing": {
fh: setupFs(false, true),
iamUpgrader: &stubIamUpgrader{},
configFetcher: &stubConfigFetcher{},
yesFlag: true,
wantErr: true,
},
"iam vars missing": {
fh: setupFs(true, false),
iamUpgrader: &stubIamUpgrader{},
configFetcher: &stubConfigFetcher{},
yesFlag: true,
wantErr: true,
},
"plan error": {
fh: setupFs(true, true),
iamUpgrader: &stubIamUpgrader{
planErr: assert.AnError,
},
configFetcher: &stubConfigFetcher{},
yesFlag: true,
wantErr: true,
},
"apply error": {
fh: setupFs(true, true),
iamUpgrader: &stubIamUpgrader{
hasDiff: true,
applyErr: assert.AnError,
},
configFetcher: &stubConfigFetcher{},
yesFlag: true,
wantErr: true,
},
"restore error": {
fh: setupFs(true, true),
iamUpgrader: &stubIamUpgrader{
hasDiff: true,
rollbackErr: assert.AnError,
},
configFetcher: &stubConfigFetcher{},
input: "no\n",
wantErr: true,
},
"config fetcher err": {
fh: setupFs(true, true),
iamUpgrader: &stubIamUpgrader{
rollbackErr: assert.AnError,
},
configFetcher: &stubConfigFetcher{
fetchLatestErr: assert.AnError,
},
yesFlag: true,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
cmd := newIAMUpgradeApplyCmd()
cmd.SetIn(strings.NewReader(tc.input))
iamUpgradeApplyCmd := &iamUpgradeApplyCmd{
fileHandler: tc.fh,
log: logger.NewTest(t),
configFetcher: tc.configFetcher,
}
err := iamUpgradeApplyCmd.iamUpgradeApply(cmd, tc.iamUpgrader, "", false, tc.yesFlag)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
type stubIamUpgrader struct {
hasDiff bool
planErr error
applyErr error
rollbackErr error
}
func (u *stubIamUpgrader) PlanIAMUpgrade(context.Context, io.Writer, terraform.Variables, cloudprovider.Provider) (bool, error) {
return u.hasDiff, u.planErr
}
func (u *stubIamUpgrader) ApplyIAMUpgrade(context.Context, cloudprovider.Provider) error {
return u.applyErr
}
func (u *stubIamUpgrader) RestoreIAMWorkspace() error {
return u.rollbackErr
}
type stubConfigFetcher struct {
fetchLatestErr error
}
func (s *stubConfigFetcher) FetchAzureSEVSNPVersion(context.Context, attestationconfigapi.AzureSEVSNPVersionAPI) (attestationconfigapi.AzureSEVSNPVersionAPI, error) {
panic("not implemented")
}
func (s *stubConfigFetcher) FetchAzureSEVSNPVersionList(context.Context, attestationconfigapi.AzureSEVSNPVersionList) (attestationconfigapi.AzureSEVSNPVersionList, error) {
panic("not implemented")
}
func (s *stubConfigFetcher) FetchAzureSEVSNPVersionLatest(context.Context, time.Time) (attestationconfigapi.AzureSEVSNPVersionAPI, error) {
return attestationconfigapi.AzureSEVSNPVersionAPI{}, s.fetchLatestErr
}

View File

@ -301,9 +301,14 @@ func (u *upgradeApplyCmd) migrateTerraform(cmd *cobra.Command, conf *config.Conf
} }
if !ok { if !ok {
cmd.Println("Aborting upgrade.") cmd.Println("Aborting upgrade.")
// Remove the upgrade directory // User doesn't expect to see any changes in his workspace after aborting an "upgrade apply",
if err := u.fileHandler.RemoveAll(upgradeDir); err != nil { // therefore, roll back to the backed up state.
return res, fmt.Errorf("cleaning up upgrade directory %s: %w", upgradeDir, err) if err := u.clusterUpgrader.RestoreClusterWorkspace(); err != nil {
return res, fmt.Errorf(
"restoring Terraform workspace: %w, restore the Terraform workspace manually from %s ",
err,
filepath.Join(upgradeDir, constants.TerraformUpgradeBackupDir),
)
} }
return res, fmt.Errorf("cluster upgrade aborted by user") return res, fmt.Errorf("cluster upgrade aborted by user")
} }
@ -636,4 +641,5 @@ type kubernetesUpgrader interface {
type clusterUpgrader interface { type clusterUpgrader interface {
PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) 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) ApplyClusterUpgrade(ctx context.Context, csp cloudprovider.Provider) (terraform.ApplyOutput, error)
RestoreClusterWorkspace() error
} }

View File

@ -83,6 +83,15 @@ func TestUpgradeApply(t *testing.T) {
wantErr: true, wantErr: true,
stdin: "no\n", stdin: "no\n",
}, },
"abort, restore terraform err": {
kubeUpgrader: &stubKubernetesUpgrader{
currentConfig: config.DefaultForAzureSEVSNP(),
},
helmUpgrader: stubApplier{},
terraformUpgrader: &stubTerraformUpgrader{terraformDiff: true, rollbackWorkspaceErr: assert.AnError},
wantErr: true,
stdin: "no\n",
},
"plan terraform error": { "plan terraform error": {
kubeUpgrader: &stubKubernetesUpgrader{ kubeUpgrader: &stubKubernetesUpgrader{
currentConfig: config.DefaultForAzureSEVSNP(), currentConfig: config.DefaultForAzureSEVSNP(),
@ -223,6 +232,7 @@ type stubTerraformUpgrader struct {
terraformDiff bool terraformDiff bool
planTerraformErr error planTerraformErr error
applyTerraformErr error applyTerraformErr error
rollbackWorkspaceErr error
} }
func (u stubTerraformUpgrader) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) { func (u stubTerraformUpgrader) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) {
@ -233,6 +243,10 @@ func (u stubTerraformUpgrader) ApplyClusterUpgrade(_ context.Context, _ cloudpro
return terraform.ApplyOutput{}, u.applyTerraformErr return terraform.ApplyOutput{}, u.applyTerraformErr
} }
func (u stubTerraformUpgrader) RestoreClusterWorkspace() error {
return u.rollbackWorkspaceErr
}
type mockTerraformUpgrader struct { type mockTerraformUpgrader struct {
mock.Mock mock.Mock
} }
@ -247,6 +261,11 @@ func (m *mockTerraformUpgrader) ApplyClusterUpgrade(ctx context.Context, provide
return args.Get(0).(terraform.ApplyOutput), args.Error(1) return args.Get(0).(terraform.ApplyOutput), args.Error(1)
} }
func (m *mockTerraformUpgrader) RestoreClusterWorkspace() error {
args := m.Called()
return args.Error(0)
}
type mockApplier struct { type mockApplier struct {
mock.Mock mock.Mock
} }

View File

@ -108,12 +108,13 @@ func runUpgradeCheck(cmd *cobra.Command, _ []string) error {
log: log, log: log,
versionsapi: versionfetcher, versionsapi: versionfetcher,
}, },
upgradeDir: upgradeDir,
terraformChecker: tfClient, terraformChecker: tfClient,
fileHandler: fileHandler, fileHandler: fileHandler,
log: log, log: log,
} }
return up.upgradeCheck(cmd, attestationconfigapi.NewFetcher(), upgradeDir, flags) return up.upgradeCheck(cmd, attestationconfigapi.NewFetcher(), flags)
} }
func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) { func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) {
@ -154,6 +155,7 @@ func parseUpgradeCheckFlags(cmd *cobra.Command) (upgradeCheckFlags, error) {
type upgradeCheckCmd struct { type upgradeCheckCmd struct {
canUpgradeCheck bool canUpgradeCheck bool
upgradeDir string
collect collector collect collector
terraformChecker terraformChecker terraformChecker terraformChecker
fileHandler file.Handler fileHandler file.Handler
@ -161,7 +163,7 @@ type upgradeCheckCmd struct {
} }
// upgradePlan plans an upgrade of a Constellation cluster. // upgradePlan plans an upgrade of a Constellation cluster.
func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fetcher attestationconfigapi.Fetcher, upgradeDir string, flags upgradeCheckFlags) error { func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fetcher attestationconfigapi.Fetcher, flags upgradeCheckFlags) error {
conf, err := config.New(u.fileHandler, constants.ConfigFilename, fetcher, flags.force) conf, err := config.New(u.fileHandler, constants.ConfigFilename, fetcher, flags.force)
var configValidationErr *config.ValidationError var configValidationErr *config.ValidationError
if errors.As(err, &configValidationErr) { if errors.As(err, &configValidationErr) {
@ -235,9 +237,14 @@ func (u *upgradeCheckCmd) upgradeCheck(cmd *cobra.Command, fetcher attestationco
return fmt.Errorf("planning terraform migrations: %w", err) return fmt.Errorf("planning terraform migrations: %w", err)
} }
defer func() { defer func() {
// Remove the upgrade directory // User doesn't expect to see any changes in his workspace after an "upgrade plan",
if err := u.fileHandler.RemoveAll(upgradeDir); err != nil { // therefore, roll back to the backed up state.
u.log.Debugf("Failed to clean up Terraform migrations: %s", err) if err := u.terraformChecker.RestoreClusterWorkspace(); err != nil {
cmd.PrintErrf(
"restoring Terraform workspace: %s, restore the Terraform workspace manually from %s ",
err,
filepath.Join(u.upgradeDir, constants.TerraformUpgradeBackupDir),
)
} }
}() }()
@ -728,6 +735,7 @@ type kubernetesChecker interface {
type terraformChecker interface { type terraformChecker interface {
PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error) PlanClusterUpgrade(ctx context.Context, outWriter io.Writer, vars terraform.Variables, csp cloudprovider.Provider) (bool, error)
RestoreClusterWorkspace() error
} }
type versionListFetcher interface { type versionListFetcher interface {

View File

@ -24,7 +24,6 @@ import (
"github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/internal/semver"
consemver "github.com/edgelesssys/constellation/v2/internal/semver" consemver "github.com/edgelesssys/constellation/v2/internal/semver"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -47,8 +46,8 @@ func TestBuildString(t *testing.T) {
newKubernetes: []string{"v1.24.12", "v1.25.6"}, newKubernetes: []string{"v1.24.12", "v1.25.6"},
newCLI: []consemver.Semver{consemver.NewFromInt(2, 5, 0, ""), consemver.NewFromInt(2, 6, 0, "")}, newCLI: []consemver.Semver{consemver.NewFromInt(2, 5, 0, ""), consemver.NewFromInt(2, 6, 0, "")},
currentServices: consemver.NewFromInt(2, 4, 0, ""), currentServices: consemver.NewFromInt(2, 4, 0, ""),
currentImage: semver.NewFromInt(2, 4, 0, ""), currentImage: consemver.NewFromInt(2, 4, 0, ""),
currentKubernetes: semver.NewFromInt(1, 24, 5, ""), currentKubernetes: consemver.NewFromInt(1, 24, 5, ""),
currentCLI: consemver.NewFromInt(2, 4, 0, ""), currentCLI: consemver.NewFromInt(2, 4, 0, ""),
}, },
expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n Images:\n v2.4.0 --> v2.5.0\n Includes these measurements:\n 4:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 8:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 9:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 11:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 12:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 13:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 15:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n \n Services: v2.4.0 --> v2.5.0\n", expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n Images:\n v2.4.0 --> v2.5.0\n Includes these measurements:\n 4:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 8:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 9:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 11:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 12:\n expected: \"1234123412341234123412341234123412341234123412341234123412341234\"\n warnOnly: false\n 13:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n 15:\n expected: \"0000000000000000000000000000000000000000000000000000000000000000\"\n warnOnly: false\n \n Services: v2.4.0 --> v2.5.0\n",
@ -70,7 +69,7 @@ func TestBuildString(t *testing.T) {
"k8s only": { "k8s only": {
upgrade: versionUpgrade{ upgrade: versionUpgrade{
newKubernetes: []string{"v1.24.12", "v1.25.6"}, newKubernetes: []string{"v1.24.12", "v1.25.6"},
currentKubernetes: semver.NewFromInt(1, 24, 5, ""), currentKubernetes: consemver.NewFromInt(1, 24, 5, ""),
}, },
expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n", expected: "The following updates are available with this CLI:\n Kubernetes: v1.24.5 --> v1.24.12 v1.25.6\n",
}, },
@ -81,8 +80,8 @@ func TestBuildString(t *testing.T) {
newKubernetes: []string{}, newKubernetes: []string{},
newCLI: []consemver.Semver{}, newCLI: []consemver.Semver{},
currentServices: consemver.NewFromInt(2, 5, 0, ""), currentServices: consemver.NewFromInt(2, 5, 0, ""),
currentImage: semver.NewFromInt(2, 5, 0, ""), currentImage: consemver.NewFromInt(2, 5, 0, ""),
currentKubernetes: semver.NewFromInt(1, 25, 6, ""), currentKubernetes: consemver.NewFromInt(1, 25, 6, ""),
currentCLI: consemver.NewFromInt(2, 5, 0, ""), currentCLI: consemver.NewFromInt(2, 5, 0, ""),
}, },
expected: "You are up to date.\n", expected: "You are up to date.\n",
@ -165,8 +164,8 @@ func TestUpgradeCheck(t *testing.T) {
}, },
supportedK8sVersions: []string{"v1.24.5", "v1.24.12", "v1.25.6"}, supportedK8sVersions: []string{"v1.24.5", "v1.24.12", "v1.25.6"},
currentServicesVersions: consemver.NewFromInt(2, 4, 0, ""), currentServicesVersions: consemver.NewFromInt(2, 4, 0, ""),
currentImageVersion: semver.NewFromInt(2, 4, 0, ""), currentImageVersion: consemver.NewFromInt(2, 4, 0, ""),
currentK8sVersion: semver.NewFromInt(1, 24, 5, ""), currentK8sVersion: consemver.NewFromInt(1, 24, 5, ""),
currentCLIVersion: consemver.NewFromInt(2, 4, 0, ""), currentCLIVersion: consemver.NewFromInt(2, 4, 0, ""),
images: []versionsapi.Version{v2_5}, images: []versionsapi.Version{v2_5},
newCLIVersionsList: []consemver.Semver{consemver.NewFromInt(2, 5, 0, ""), consemver.NewFromInt(2, 6, 0, "")}, newCLIVersionsList: []consemver.Semver{consemver.NewFromInt(2, 5, 0, ""), consemver.NewFromInt(2, 6, 0, "")},
@ -185,15 +184,23 @@ func TestUpgradeCheck(t *testing.T) {
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0", cliVersion: "v1.0.0",
}, },
"terraform err": { "terraform plan err": {
collector: collector, collector: collector,
checker: stubTerraformChecker{ checker: stubTerraformChecker{
err: assert.AnError, planErr: assert.AnError,
}, },
csp: cloudprovider.GCP, csp: cloudprovider.GCP,
cliVersion: "v1.0.0", cliVersion: "v1.0.0",
wantError: true, wantError: true,
}, },
"terraform rollback err, log only": {
collector: collector,
checker: stubTerraformChecker{
rollbackErr: assert.AnError,
},
csp: cloudprovider.GCP,
cliVersion: "v1.0.0",
},
} }
for name, tc := range testCases { for name, tc := range testCases {
@ -214,7 +221,7 @@ func TestUpgradeCheck(t *testing.T) {
cmd := newUpgradeCheckCmd() cmd := newUpgradeCheckCmd()
err := checkCmd.upgradeCheck(cmd, stubAttestationFetcher{}, "test", upgradeCheckFlags{}) err := checkCmd.upgradeCheck(cmd, stubAttestationFetcher{}, upgradeCheckFlags{})
if tc.wantError { if tc.wantError {
assert.Error(err) assert.Error(err)
return return
@ -280,11 +287,16 @@ func (s *stubVersionCollector) filterCompatibleCLIVersions(_ context.Context, _
type stubTerraformChecker struct { type stubTerraformChecker struct {
tfDiff bool tfDiff bool
err error planErr error
rollbackErr error
} }
func (s stubTerraformChecker) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) { func (s stubTerraformChecker) PlanClusterUpgrade(_ context.Context, _ io.Writer, _ terraform.Variables, _ cloudprovider.Provider) (bool, error) {
return s.tfDiff, s.err return s.tfDiff, s.planErr
}
func (s stubTerraformChecker) RestoreClusterWorkspace() error {
return s.rollbackErr
} }
func TestNewCLIVersions(t *testing.T) { func TestNewCLIVersions(t *testing.T) {
@ -374,7 +386,7 @@ func TestFilterCompatibleCLIVersions(t *testing.T) {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
require := require.New(t) require := require.New(t)
_, err := tc.verCollector.filterCompatibleCLIVersions(context.Background(), tc.cliPatchVersions, semver.NewFromInt(1, 24, 5, "")) _, err := tc.verCollector.filterCompatibleCLIVersions(context.Background(), tc.cliPatchVersions, consemver.NewFromInt(1, 24, 5, ""))
if tc.wantErr { if tc.wantErr {
require.Error(err) require.Error(err)
return return

View File

@ -70,6 +70,9 @@ go_library(
"terraform/openstack/outputs.tf", "terraform/openstack/outputs.tf",
"terraform/openstack/variables.tf", "terraform/openstack/variables.tf",
"terraform/qemu/modules/instance_group/tdx_domain.xsl", "terraform/qemu/modules/instance_group/tdx_domain.xsl",
"terraform/iam/aws/.terraform.lock.hcl",
"terraform/iam/azure/.terraform.lock.hcl",
"terraform/iam/gcp/.terraform.lock.hcl",
], ],
importpath = "github.com/edgelesssys/constellation/v2/cli/internal/terraform", importpath = "github.com/edgelesssys/constellation/v2/cli/internal/terraform",
visibility = ["//cli:__subpackages__"], visibility = ["//cli:__subpackages__"],

View File

@ -25,39 +25,39 @@ var ErrTerraformWorkspaceDifferentFiles = errors.New("creating cluster: trying t
//go:embed terraform/* //go:embed terraform/*
//go:embed terraform/*/.terraform.lock.hcl //go:embed terraform/*/.terraform.lock.hcl
//go:embed terraform/iam/*/.terraform.lock.hcl
var terraformFS embed.FS var terraformFS embed.FS
const (
noOverwrites overwritePolicy = iota
allowOverwrites
)
type overwritePolicy int
// prepareWorkspace loads the embedded Terraform files, // prepareWorkspace loads the embedded Terraform files,
// and writes them into the workspace. // and writes them into the workspace.
func prepareWorkspace(rootDir string, fileHandler file.Handler, workingDir string) error { func prepareWorkspace(rootDir string, fileHandler file.Handler, workingDir string) error {
return terraformCopier(fileHandler, rootDir, workingDir) return terraformCopier(fileHandler, rootDir, workingDir, noOverwrites)
} }
// prepareUpgradeWorkspace takes the Terraform state file from the old workspace and the // prepareUpgradeWorkspace backs up the old Terraform workspace from workingDir, and
// embedded Terraform files and writes them into the new workspace. // copies the embedded Terraform files into workingDir.
func prepareUpgradeWorkspace(rootDir string, fileHandler file.Handler, oldWorkingDir, newWorkingDir, backupDir string) error { func prepareUpgradeWorkspace(rootDir string, fileHandler file.Handler, workingDir, backupDir string) error {
// backup old workspace // backup old workspace
if err := fileHandler.CopyDir( if err := fileHandler.CopyDir(
oldWorkingDir, workingDir,
backupDir, backupDir,
); err != nil { ); err != nil {
return fmt.Errorf("backing up old workspace: %w", err) return fmt.Errorf("backing up old workspace: %w", err)
} }
// copy state file return terraformCopier(fileHandler, rootDir, workingDir, allowOverwrites)
if err := fileHandler.CopyFile(
filepath.Join(oldWorkingDir, "terraform.tfstate"),
filepath.Join(newWorkingDir, "terraform.tfstate"),
file.OptMkdirAll,
); err != nil {
return fmt.Errorf("copying state file: %w", err)
}
return terraformCopier(fileHandler, rootDir, newWorkingDir)
} }
// terraformCopier copies the embedded Terraform files into the workspace. // terraformCopier copies the embedded Terraform files into the workspace.
func terraformCopier(fileHandler file.Handler, rootDir, workingDir string) error { // allowOverwrites allows overwriting existing files in the workspace.
func terraformCopier(fileHandler file.Handler, rootDir, workingDir string, overwritePolicy overwritePolicy) error {
goEmbedRootDir := filepath.ToSlash(rootDir) goEmbedRootDir := filepath.ToSlash(rootDir)
return fs.WalkDir(terraformFS, goEmbedRootDir, func(path string, d fs.DirEntry, err error) error { return fs.WalkDir(terraformFS, goEmbedRootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@ -74,8 +74,15 @@ func terraformCopier(fileHandler file.Handler, rootDir, workingDir string) error
} }
// normalize // normalize
fileName := strings.Replace(slashpath.Join(workingDir, path), goEmbedRootDir+"/", "", 1) fileName := strings.Replace(slashpath.Join(workingDir, path), goEmbedRootDir+"/", "", 1)
if err := fileHandler.Write(fileName, content, file.OptMkdirAll); errors.Is(err, afero.ErrFileExists) { opts := []file.Option{
// If a file already exists, check if it is identical. If yes, continue and don't write anything to disk. file.OptMkdirAll,
}
if overwritePolicy == allowOverwrites {
opts = append(opts, file.OptOverwrite)
}
if err := fileHandler.Write(fileName, content, opts...); errors.Is(err, afero.ErrFileExists) {
// If a file already exists and overwritePolicy is set to noOverwrites,
// check if it is identical. If yes, continue and don't write anything to disk.
// If no, don't overwrite it and instead throw an error. The affected file could be from a different version, // If no, don't overwrite it and instead throw an error. The affected file could be from a different version,
// provider, corrupted or manually modified in general. // provider, corrupted or manually modified in general.
existingFileContent, err := fileHandler.Read(fileName) existingFileContent, err := fileHandler.Read(fileName)

View File

@ -20,6 +20,8 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
var oldFileContent = []byte("1234")
func TestPrepareWorkspace(t *testing.T) { func TestPrepareWorkspace(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
pathBase string pathBase string
@ -109,20 +111,20 @@ func TestPrepareWorkspace(t *testing.T) {
err := prepareWorkspace(path, file, testWorkspace) err := prepareWorkspace(path, file, testWorkspace)
require.NoError(err) require.NoError(err)
checkFiles(t, file, func(err error) { assert.NoError(err) }, testWorkspace, tc.fileList) checkFiles(t, file, func(err error) { assert.NoError(err) }, nil, testWorkspace, tc.fileList)
if tc.testAlreadyUnpacked { if tc.testAlreadyUnpacked {
// Let's try the same again and check if we don't get a "file already exists" error. // Let's try the same again and check if we don't get a "file already exists" error.
require.NoError(file.Remove(filepath.Join(testWorkspace, "variables.tf"))) require.NoError(file.Remove(filepath.Join(testWorkspace, "variables.tf")))
err := prepareWorkspace(path, file, testWorkspace) err := prepareWorkspace(path, file, testWorkspace)
assert.NoError(err) assert.NoError(err)
checkFiles(t, file, func(err error) { assert.NoError(err) }, testWorkspace, tc.fileList) checkFiles(t, file, func(err error) { assert.NoError(err) }, nil, testWorkspace, tc.fileList)
} }
err = cleanUpWorkspace(file, testWorkspace) err = cleanUpWorkspace(file, testWorkspace)
require.NoError(err) require.NoError(err)
checkFiles(t, file, func(err error) { assert.ErrorIs(err, fs.ErrNotExist) }, testWorkspace, tc.fileList) checkFiles(t, file, func(err error) { assert.ErrorIs(err, fs.ErrNotExist) }, nil, testWorkspace, tc.fileList)
}) })
} }
} }
@ -131,49 +133,56 @@ func TestPrepareUpgradeWorkspace(t *testing.T) {
testCases := map[string]struct { testCases := map[string]struct {
pathBase string pathBase string
provider cloudprovider.Provider provider cloudprovider.Provider
oldWorkingDir string workingDir string
newWorkingDir string
backupDir string backupDir string
oldWorkspaceFiles []string workspaceFiles []string
newWorkspaceFiles []string
expectedFiles []string expectedFiles []string
expectedBackupFiles []string
testAlreadyUnpacked bool testAlreadyUnpacked bool
wantErr bool wantErr bool
}{ }{
"works": { "works": {
pathBase: "terraform", pathBase: "terraform",
provider: cloudprovider.AWS, provider: cloudprovider.AWS,
oldWorkingDir: "old", workingDir: "working",
newWorkingDir: "new",
backupDir: "backup", backupDir: "backup",
oldWorkspaceFiles: []string{"terraform.tfstate"}, workspaceFiles: []string{"main.tf", "variables.tf", "outputs.tf"},
expectedFiles: []string{ expectedFiles: []string{
"main.tf", "main.tf",
"variables.tf", "variables.tf",
"outputs.tf", "outputs.tf",
"modules", },
"terraform.tfstate", expectedBackupFiles: []string{
"main.tf",
"variables.tf",
"outputs.tf",
}, },
}, },
"state file does not exist": { "state file does not exist": {
pathBase: "terraform", pathBase: "terraform",
provider: cloudprovider.AWS, provider: cloudprovider.AWS,
oldWorkingDir: "old", workingDir: "working",
newWorkingDir: "new",
backupDir: "backup", backupDir: "backup",
oldWorkspaceFiles: []string{}, workspaceFiles: []string{},
expectedFiles: []string{}, expectedFiles: []string{},
wantErr: true, wantErr: true,
}, },
"terraform files already exist in new dir": { "terraform file already exists in working dir (overwrite)": {
pathBase: "terraform", pathBase: "terraform",
provider: cloudprovider.AWS, provider: cloudprovider.AWS,
oldWorkingDir: "old", workingDir: "working",
newWorkingDir: "new",
backupDir: "backup", backupDir: "backup",
oldWorkspaceFiles: []string{"terraform.tfstate"}, workspaceFiles: []string{"main.tf", "variables.tf", "outputs.tf"},
newWorkspaceFiles: []string{"main.tf"}, expectedFiles: []string{
wantErr: true, "main.tf",
"variables.tf",
"outputs.tf",
},
expectedBackupFiles: []string{
"main.tf",
"variables.tf",
"outputs.tf",
},
}, },
} }
@ -186,31 +195,44 @@ func TestPrepareUpgradeWorkspace(t *testing.T) {
path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String())) path := path.Join(tc.pathBase, strings.ToLower(tc.provider.String()))
createFiles(t, file, tc.oldWorkspaceFiles, tc.oldWorkingDir) createFiles(t, file, tc.workspaceFiles, tc.workingDir)
createFiles(t, file, tc.newWorkspaceFiles, tc.newWorkingDir)
err := prepareUpgradeWorkspace(path, file, tc.oldWorkingDir, tc.newWorkingDir, tc.backupDir) err := prepareUpgradeWorkspace(path, file, tc.workingDir, tc.backupDir)
if tc.wantErr { if tc.wantErr {
require.Error(err) require.Error(err)
} else { } else {
require.NoError(err) require.NoError(err)
} checkFiles(
checkFiles(t, file, func(err error) { assert.NoError(err) }, tc.newWorkingDir, tc.expectedFiles) t, file,
checkFiles(t, file, func(err error) { assert.NoError(err) }, func(err error) { assert.NoError(err) },
tc.backupDir, func(content []byte) { assert.NotEqual(oldFileContent, content) },
tc.oldWorkspaceFiles, tc.workingDir, tc.expectedFiles,
) )
checkFiles(
t, file,
func(err error) { assert.NoError(err) },
func(content []byte) { assert.Equal(oldFileContent, content) },
tc.backupDir, tc.expectedBackupFiles,
)
}
}) })
} }
} }
func checkFiles(t *testing.T, fileHandler file.Handler, assertion func(error), dir string, files []string) { func checkFiles(t *testing.T, fileHandler file.Handler, assertion func(error), contentExpection func(content []byte), dir string, files []string) {
t.Helper() t.Helper()
for _, f := range files { for _, f := range files {
path := filepath.Join(dir, f) path := filepath.Join(dir, f)
_, err := fileHandler.Stat(path) _, err := fileHandler.Stat(path)
assertion(err) assertion(err)
if err == nil {
content, err := fileHandler.Read(path)
assertion(err)
if contentExpection != nil {
contentExpection(content)
}
}
} }
} }
@ -220,7 +242,7 @@ func createFiles(t *testing.T, fileHandler file.Handler, fileList []string, targ
for _, f := range fileList { for _, f := range fileList {
path := filepath.Join(targetDir, f) path := filepath.Join(targetDir, f)
err := fileHandler.Write(path, []byte("1234"), file.OptOverwrite, file.OptMkdirAll) err := fileHandler.Write(path, oldFileContent, file.OptOverwrite, file.OptMkdirAll)
require.NoError(err) require.NoError(err)
} }
} }

View File

@ -332,19 +332,18 @@ func (c *Client) PrepareWorkspace(path string, vars Variables) error {
return fmt.Errorf("prepare workspace: %w", err) return fmt.Errorf("prepare workspace: %w", err)
} }
return c.writeVars(vars) return c.writeVars(vars, noOverwrites)
} }
// PrepareUpgradeWorkspace prepares a Terraform workspace for an upgrade. // PrepareUpgradeWorkspace prepares a Terraform workspace for an upgrade.
// It copies the Terraform state from the old working dir and the embedded Terraform files // It creates a backup of the Terraform workspace in the backupDir, and copies
// into the working dir of the Terraform client. // the embedded Terraform files into the workingDir.
// Additionally, a backup of the old working dir is created in the backup dir. func (c *Client) PrepareUpgradeWorkspace(path, backupDir string, vars Variables) error {
func (c *Client) PrepareUpgradeWorkspace(path, oldWorkingDir, backupDir string, vars Variables) error { if err := prepareUpgradeWorkspace(path, c.file, c.workingDir, backupDir); err != nil {
if err := prepareUpgradeWorkspace(path, c.file, oldWorkingDir, c.workingDir, backupDir); err != nil {
return fmt.Errorf("prepare upgrade workspace: %w", err) return fmt.Errorf("prepare upgrade workspace: %w", err)
} }
return c.writeVars(vars) return c.writeVars(vars, allowOverwrites)
} }
// ApplyCluster applies the Terraform configuration of the workspace to create or upgrade a Constellation cluster. // ApplyCluster applies the Terraform configuration of the workspace to create or upgrade a Constellation cluster.
@ -461,13 +460,17 @@ func (c *Client) applyManualStateMigrations(ctx context.Context) error {
} }
// writeVars tries to write the Terraform variables file or, if it exists, checks if it is the same as we are expecting. // writeVars tries to write the Terraform variables file or, if it exists, checks if it is the same as we are expecting.
func (c *Client) writeVars(vars Variables) error { func (c *Client) writeVars(vars Variables, overwritePolicy overwritePolicy) error {
if vars == nil { if vars == nil {
return errors.New("creating cluster: vars is nil") return errors.New("creating cluster: vars is nil")
} }
pathToVarsFile := filepath.Join(c.workingDir, terraformVarsFile) pathToVarsFile := filepath.Join(c.workingDir, terraformVarsFile)
if err := c.file.Write(pathToVarsFile, []byte(vars.String())); errors.Is(err, afero.ErrFileExists) { opts := []file.Option{}
if overwritePolicy == allowOverwrites {
opts = append(opts, file.OptOverwrite)
}
if err := c.file.Write(pathToVarsFile, []byte(vars.String()), opts...); errors.Is(err, afero.ErrFileExists) {
// If a variables file already exists, check if it's the same as we're expecting, so we can continue using it. // If a variables file already exists, check if it's the same as we're expecting, so we can continue using it.
varsContent, err := c.file.Read(pathToVarsFile) varsContent, err := c.file.Read(pathToVarsFile)
if err != nil { if err != nil {