From 3fedd6898cac09451bc1e877bad0471c69404c3f Mon Sep 17 00:00:00 2001 From: Omar Ismail Date: Sat, 9 Oct 2021 08:47:12 -0400 Subject: [PATCH] Backend State Migration to `cloud`: Multiple Workspaces * Handle when there are multiple workspaces migrating to cloud, using both the cloud name strategy and cloud tags strategy. * Add e2e tests --- internal/cloud/e2e/apply_auto_approve_test.go | 52 +-- internal/cloud/e2e/helper_test.go | 12 +- .../e2e/migrate_state_multi_to_tfc_test.go | 436 +++++++++++++++++- ...igrate_state_remote_backend_to_tfc_test.go | 34 +- .../e2e/migrate_state_single_to_tfc_test.go | 75 +-- .../e2e/migrate_state_tfc_to_other_test.go | 38 +- internal/command/meta_backend_migrate.go | 187 +++++++- internal/command/meta_backend_migrate_test.go | 62 +++ 8 files changed, 784 insertions(+), 112 deletions(-) create mode 100644 internal/command/meta_backend_migrate_test.go diff --git a/internal/cloud/e2e/apply_auto_approve_test.go b/internal/cloud/e2e/apply_auto_approve_test.go index 4e5a65a02..1614e4f1e 100644 --- a/internal/cloud/e2e/apply_auto_approve_test.go +++ b/internal/cloud/e2e/apply_auto_approve_test.go @@ -42,14 +42,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply"}, - expectedOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + command: []string{"apply"}, + expectedCmdOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -86,14 +86,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply"}, - expectedOutput: "Do you want to perform these actions in workspace", - expectedErr: "Error asking approve", + command: []string{"apply"}, + expectedCmdOutput: "Do you want to perform these actions in workspace", + expectedErr: "Error asking approve", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -130,14 +130,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply", "-auto-approve"}, - expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -175,14 +175,14 @@ func Test_terraform_apply_autoApprove(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: "Terraform has been successfully initialized", - expectedErr: "", + command: []string{"init"}, + expectedCmdOutput: "Terraform has been successfully initialized", + expectedErr: "", }, { - command: []string{"apply", "-auto-approve"}, - expectedOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", - expectedErr: "", + command: []string{"apply", "-auto-approve"}, + expectedCmdOutput: "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.", + expectedErr: "", }, }, validations: func(t *testing.T, orgName, wsName string) { @@ -228,8 +228,8 @@ func Test_terraform_apply_autoApprove(t *testing.T) { } } - if cmd.expectedOutput != "" && !strings.Contains(stdout, cmd.expectedOutput) { - t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedOutput, stdout) + if cmd.expectedCmdOutput != "" && !strings.Contains(stdout, cmd.expectedCmdOutput) { + t.Fatalf("Expected to find output %s, but did not find in\n%s", cmd.expectedCmdOutput, stdout) } } diff --git a/internal/cloud/e2e/helper_test.go b/internal/cloud/e2e/helper_test.go index 5af66bf1c..f8dad3a79 100644 --- a/internal/cloud/e2e/helper_test.go +++ b/internal/cloud/e2e/helper_test.go @@ -19,12 +19,12 @@ const ( ) type tfCommand struct { - command []string - expectedOutput string - expectedErr string - expectError bool - userInput []string - postInputOutput string + command []string + expectedCmdOutput string + expectedErr string + expectError bool + userInput []string + postInputOutput []string } type operationSets struct { diff --git a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go index a6c4f8190..f4160a61a 100644 --- a/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_multi_to_tfc_test.go @@ -1,18 +1,434 @@ +//go:build e2e +// +build e2e + package main import ( + "context" + "fmt" + "io/ioutil" + "os" "testing" + + expect "github.com/Netflix/go-expect" + tfe "github.com/hashicorp/go-tfe" + "github.com/hashicorp/terraform/internal/e2e" ) -/* - "multi" == multi-backend, multiple workspaces - -- when cloud config == name -> - ---- prompt -> do you want to ONLY migrate the current workspace +func Test_migrate_multi_to_tfc_cloud_name_strategy(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using name strategy; current workspace is 'default'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "show"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, // this was the output of the current workspace selected before migration + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 1 { + t.Fatalf("Expected the number of workspaces to be 1, but got %d", len(wsList.Items)) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + "migrating multiple workspaces to cloud using name strategy; current workspace is 'prod'": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + wsName := "new-workspace" + tfBlock := terraformConfigCloudBackendName(orgName, wsName) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy only your current workspace?`, + userInput: []string{"yes", "yes"}, + postInputOutput: []string{ + `Do you want to copy existing state to the new backend?`, + `Successfully configured the backend "cloud"!`}, + }, + { + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, // this comes from the `prep` function + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{}) + if err != nil { + t.Fatal(err) + } + ws := wsList.Items[0] + // this workspace name is what exists in the cloud backend configuration block + if ws.Name != "new-workspace" { + t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name) + } + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("Running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } - -- when cloud config == tags - -- If Default present, prompt to rename default. - -- Then -> Prompt with * -*/ -func Test_migrate_multi_to_tfc(t *testing.T) { - t.Skip("todo: see comments") +} + +func Test_migrate_multi_to_tfc_cloud_tags_strategy(t *testing.T) { + ctx := context.Background() + + cases := map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "migrating multiple workspaces to cloud using tags strategy; pattern is using prefix `app-*`": { + operations: []operationSets{ + { + prep: func(t *testing.T, orgName, dir string) { + tfBlock := terraformConfigLocalBackend() + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "new", "prod"}, + expectedCmdOutput: `Created and switched to workspace "prod"!`, + }, + { + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, + }, + { + command: []string{"workspace", "select", "default"}, + expectedCmdOutput: `Switched to workspace "default".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + { + command: []string{"workspace", "select", "prod"}, + expectedCmdOutput: `Switched to workspace "prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + }, + }, + { + prep: func(t *testing.T, orgName, dir string) { + tag := "app" + tfBlock := terraformConfigCloudBackendTags(orgName, tag) + writeMainTF(t, tfBlock, dir) + }, + commands: []tfCommand{ + { + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"dev", "1", "app-*", "1"}, + postInputOutput: []string{ + `Would you like to rename your workspaces?`, + "What pattern would you like to add to all your workspaces?", + "The currently selected workspace (prod) does not exist.", + "Terraform has been successfully initialized!"}, + }, + { + command: []string{"workspace", "select", "app-prod"}, + expectedCmdOutput: `Switched to workspace "app-prod".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "prod"`, + }, + { + command: []string{"workspace", "select", "app-dev"}, + expectedCmdOutput: `Switched to workspace "app-dev".`, + }, + { + command: []string{"output"}, + expectedCmdOutput: `val = "default"`, + }, + }, + }, + }, + validations: func(t *testing.T, orgName string) { + wsList, err := tfeClient.Workspaces.List(ctx, orgName, tfe.WorkspaceListOptions{ + Tags: tfe.String("app"), + }) + if err != nil { + t.Fatal(err) + } + if len(wsList.Items) != 2 { + t.Fatalf("Expected the number of workspaecs to be 2, but got %d", len(wsList.Items)) + } + expectedWorkspaceNames := []string{"app-prod", "app-dev"} + for _, ws := range wsList.Items { + hasName := false + for _, expectedNames := range expectedWorkspaceNames { + if expectedNames == ws.Name { + hasName = true + } + } + if !hasName { + t.Fatalf("Worksapce %s is not in the expected list of workspaces", ws.Name) + } + } + }, + }, + } + + for name, tc := range cases { + fmt.Println("Test: ", name) + organization, cleanup := createOrganization(t) + t.Log(organization.Name) + defer cleanup() + exp, err := expect.NewConsole(expect.WithStdout(os.Stdout), expect.WithDefaultTimeout(expectConsoleTimeout)) + if err != nil { + t.Fatal(err) + } + defer exp.Close() + + tmpDir, err := ioutil.TempDir("", "terraform-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + tf := e2e.NewBinary(terraformBin, tmpDir) + defer tf.Close() + tf.AddEnv("TF_LOG=INFO") + tf.AddEnv(cliConfigFileEnv) + + for _, op := range tc.operations { + op.prep(t, organization.Name, tf.WorkDir()) + for _, tfCmd := range op.commands { + t.Log("running commands: ", tfCmd.command) + cmd := tf.Cmd(tfCmd.command...) + cmd.Stdin = exp.Tty() + cmd.Stdout = exp.Tty() + cmd.Stderr = exp.Tty() + + err = cmd.Start() + if err != nil { + t.Fatal(err) + } + + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) + if err != nil { + t.Fatal(err) + } + } + + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i < lenInput; i++ { + input := tfCmd.userInput[i] + exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + if output == "" { + continue + } + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } + } + } + + err = cmd.Wait() + if err != nil { + t.Fatal(err) + } + } + } + + if tc.validations != nil { + tc.validations(t, organization.Name) + } + } } diff --git a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go index 6819ea7aa..0381216a6 100644 --- a/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_remote_backend_to_tfc_test.go @@ -1,3 +1,6 @@ +//go:build e2e +// +build e2e + package main import ( @@ -23,9 +26,34 @@ import ( */ func Test_migrate_remote_backend_name_to_tfc(t *testing.T) { - t.Skip("todo: see comments") + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend name strategy, to cloud with name strategy": {}, + "single workspace with backend name strategy, to cloud with tags strategy": {}, + } } -func Test_migrate_remote_backend_prefix_to_tfc(t *testing.T) { - t.Skip("todo: see comments") +func Test_migrate_remote_backend_prefix_to_tfc_name(t *testing.T) { + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with name strategy": {}, + "multiple workspaces with backend prefix strategy, to cloud with name strategy": {}, + } +} + +func Test_migrate_remote_backend_prefix_to_tfc_tags(t *testing.T) { + t.Skip("TODO: see comments") + _ = map[string]struct { + operations []operationSets + validations func(t *testing.T, orgName string) + }{ + "single workspace with backend prefix strategy, to cloud with tags strategy": {}, + "multiple workspaces with backend prefix strategy, to cloud with tags strategy": {}, + } } diff --git a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go index 28292419c..d251207fe 100644 --- a/internal/cloud/e2e/migrate_state_single_to_tfc_test.go +++ b/internal/cloud/e2e/migrate_state_single_to_tfc_test.go @@ -31,14 +31,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "local"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, }, { - command: []string{"apply"}, - userInput: []string{"yes"}, - expectedOutput: `Do you want to perform these actions?`, - postInputOutput: `Apply complete!`, + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, }, }, }, @@ -50,14 +50,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `Do you want to copy existing state to the new backend?`, - userInput: []string{"yes"}, - postInputOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Do you want to copy existing state to the new backend?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, }, { - command: []string{"workspace", "list"}, - expectedOutput: `new-workspace`, + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, }, }, }, @@ -82,14 +82,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "local"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "local"!`, }, { - command: []string{"apply"}, - userInput: []string{"yes"}, - expectedOutput: `Do you want to perform these actions?`, - postInputOutput: `Apply complete!`, + command: []string{"apply"}, + expectedCmdOutput: `Do you want to perform these actions?`, + userInput: []string{"yes"}, + postInputOutput: []string{`Apply complete!`}, }, }, }, @@ -101,14 +101,14 @@ func Test_migrate_single_to_tfc(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `The "cloud" backend configuration only allows named workspaces!`, - userInput: []string{"new-workspace", "yes"}, - postInputOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `The "cloud" backend configuration only allows named workspaces!`, + userInput: []string{"new-workspace", "yes"}, + postInputOutput: []string{`Successfully configured the backend "cloud"!`}, }, { - command: []string{"workspace", "list"}, - expectedOutput: `new-workspace`, + command: []string{"workspace", "list"}, + expectedCmdOutput: `new-workspace`, }, }, }, @@ -162,23 +162,28 @@ func Test_migrate_single_to_tfc(t *testing.T) { t.Fatal(err) } - if tfCmd.expectedOutput != "" { - _, err := exp.ExpectString(tfCmd.expectedOutput) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) if err != nil { t.Fatal(err) } } - if len(tfCmd.userInput) > 0 { - for _, input := range tfCmd.userInput { + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i <= lenInput; i++ { + input := tfCmd.userInput[i] exp.SendLine(input) - } - } - - if tfCmd.postInputOutput != "" { - _, err := exp.ExpectString(tfCmd.postInputOutput) - if err != nil { - t.Fatal(err) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } } } diff --git a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go index 750a315b8..5c4173d01 100644 --- a/internal/cloud/e2e/migrate_state_tfc_to_other_test.go +++ b/internal/cloud/e2e/migrate_state_tfc_to_other_test.go @@ -27,8 +27,8 @@ func Test_migrate_tfc_to_other(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init"}, - expectedOutput: `Successfully configured the backend "cloud"!`, + command: []string{"init"}, + expectedCmdOutput: `Successfully configured the backend "cloud"!`, }, }, }, @@ -39,9 +39,9 @@ func Test_migrate_tfc_to_other(t *testing.T) { }, commands: []tfCommand{ { - command: []string{"init", "-migrate-state"}, - expectedOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, - expectError: true, + command: []string{"init", "-migrate-state"}, + expectedCmdOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`, + expectError: true, }, }, }, @@ -83,26 +83,30 @@ func Test_migrate_tfc_to_other(t *testing.T) { t.Fatal(err) } - if tfCmd.expectedOutput != "" { - _, err := exp.ExpectString(tfCmd.expectedOutput) + if tfCmd.expectedCmdOutput != "" { + _, err := exp.ExpectString(tfCmd.expectedCmdOutput) if err != nil { t.Fatal(err) } } - if len(tfCmd.userInput) > 0 { - for _, input := range tfCmd.userInput { + lenInput := len(tfCmd.userInput) + lenInputOutput := len(tfCmd.postInputOutput) + if lenInput > 0 { + for i := 0; i <= lenInput; i++ { + input := tfCmd.userInput[i] exp.SendLine(input) + // use the index to find the corresponding + // output that matches the input. + if lenInputOutput-1 >= i { + output := tfCmd.postInputOutput[i] + _, err := exp.ExpectString(output) + if err != nil { + t.Fatal(err) + } + } } } - - if tfCmd.postInputOutput != "" { - _, err := exp.ExpectString(tfCmd.postInputOutput) - if err != nil { - t.Fatal(err) - } - } - err = cmd.Wait() if err != nil && !tfCmd.expectError { t.Fatal(err) diff --git a/internal/command/meta_backend_migrate.go b/internal/command/meta_backend_migrate.go index 132bd96ef..17dcd930c 100644 --- a/internal/command/meta_backend_migrate.go +++ b/internal/command/meta_backend_migrate.go @@ -275,16 +275,9 @@ func (m *Meta) backendMigrateState_s_s(opts *backendMigrateOpts) error { // for a new name and migrate the default state to the given named state. destinationState, err = func() (statemgr.Full, error) { log.Print("[TRACE] backendMigrateState: destination doesn't support a default workspace, so we must prompt for a new name") - name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ - Id: "new-state-name", - Query: fmt.Sprintf( - "[reset][bold][yellow]The %q backend configuration only allows "+ - "named workspaces![reset]", - opts.DestinationType), - Description: strings.TrimSpace(inputBackendNewWorkspaceName), - }) + name, err := m.promptNewWorkspaceName(opts.DestinationType) if err != nil { - return nil, fmt.Errorf("Error asking for new state name: %s", err) + return nil, err } // Update the name of the destination state. @@ -562,23 +555,156 @@ func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error { multiSource := !sourceSingleState && len(sourceWorkspaces) > 1 if multiSource && destinationNameStrategy { - // we have to take the current workspace from the source and migrate that - // over to destination. Since there is multiple sources, and we are using a - // name strategy, we will only migrate the current workspace. - panic("not yet implemented") + if err := m.promptMultiToSingleCloudMigration(opts); err != nil { + return err + } + + currentEnv, err := m.Workspace() + if err != nil { + return err + } + + opts.sourceWorkspace = currentEnv + opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name + + return m.backendMigrateState_s_s(opts) } // Multiple sources, and using tags strategy. So migrate every source // workspace over to new one, prompt for workspace name pattern (*), // and start migrating, and create tags for each workspace. if multiSource && destinationTagsStrategy { - // TODO: see internal/cloud/e2e/migrate_state_multi_to_tfc_test.go for notes - panic("not yet implemented") + return m.backendMigrateState_S_TFC(opts, sourceWorkspaces) } return nil } +// migrates a multi-state backend to Terraform Cloud +func (m *Meta) backendMigrateState_S_TFC(opts *backendMigrateOpts, sourceWorkspaces []string) error { + log.Print("[TRACE] backendMigrateState: migrating all named workspaces") + + // This map is used later when doing the migration per source/destination. + // If a source has 'default', then we ask what the new name should be. + // And further down when we actually run state migration for each + // sourc/destination workspce, we use this new name (where source is 'default') + // and set as destinationWorkspace. + defaultNewName := map[string]string{} + for i := 0; i < len(sourceWorkspaces); i++ { + if sourceWorkspaces[i] == backend.DefaultStateName { + newName, err := m.promptNewWorkspaceName(opts.DestinationType) + if err != nil { + return err + } + defaultNewName[sourceWorkspaces[i]] = newName + } + } + pattern, err := m.promptMultiStateMigrationPattern(opts.SourceType) + if err != nil { + return err + } + + // Go through each and migrate + for _, name := range sourceWorkspaces { + + // Copy the same names + opts.sourceWorkspace = name + if newName, ok := defaultNewName[name]; ok { + // this has to be done before setting destinationWorkspace + name = newName + } + opts.destinationWorkspace = strings.Replace(pattern, "*", name, -1) + + // Force it, we confirmed above + opts.force = true + + // Perform the migration + if err := m.backendMigrateState_s_s(opts); err != nil { + return fmt.Errorf(strings.TrimSpace( + errMigrateMulti), name, opts.SourceType, opts.DestinationType, err) + } + } + + return nil +} + +// Multi-state to single state. +func (m *Meta) promptMultiToSingleCloudMigration(opts *backendMigrateOpts) error { + migrate := opts.force + if !migrate { + var err error + // Ask the user if they want to migrate their existing remote state + migrate, err = m.confirm(&terraform.InputOpts{ + Id: "backend-migrate-multistate-to-single", + Query: "Do you want to copy only your current workspace?", + Description: strings.TrimSpace(tfcInputBackendMigrateMultiToSingle), + }) + if err != nil { + return fmt.Errorf("Error asking for state migration action: %s", err) + } + } + + if !migrate { + return fmt.Errorf("Migration aborted by user.") + } + + return nil +} + +func (m *Meta) promptNewWorkspaceName(destinationType string) (string, error) { + name, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "new-state-name", + Query: fmt.Sprintf( + "[reset][bold][yellow]The %q backend configuration only allows "+ + "named workspaces![reset]", + destinationType), + Description: strings.TrimSpace(inputBackendNewWorkspaceName), + }) + if err != nil { + return "", fmt.Errorf("Error asking for new state name: %s", err) + } + + return name, nil +} + +func (m *Meta) promptMultiStateMigrationPattern(sourceType string) (string, error) { + renameWorkspaces, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "Would you like to rename your workspaces?"), + Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMulti), sourceType), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if renameWorkspaces != "2" && renameWorkspaces != "1" { + return "", fmt.Errorf("Please select 1 or 2 as part of this option.") + } + if renameWorkspaces == "2" { + // this means they did not want to rename their workspaces, and we are + // returning a generic '*' that means use the same workspace name during + // migration. + return "*", nil + } + + pattern, err := m.UIInput().Input(context.Background(), &terraform.InputOpts{ + Id: "backend-migrate-multistate-to-tfc-pattern", + Query: fmt.Sprintf("[reset][bold][yellow]%s[reset]", "What pattern would you like to add to all your workspaces?"), + Description: fmt.Sprintf(strings.TrimSpace(tfcInputBackendMigrateMultiToMultiPattern), sourceType), + }) + if err != nil { + return "", fmt.Errorf("Error asking for state migration action: %s", err) + } + if !strings.Contains(pattern, "*") { + return "", fmt.Errorf("The pattern must have an '*'") + } + + if count := strings.Count(pattern, "*"); count > 1 { + return "", fmt.Errorf("The pattern '*' cannot be used more than once.") + } + + return pattern, nil +} + const errMigrateLoadStates = ` Error inspecting states in the %q backend: %s @@ -629,6 +755,37 @@ Migrating state from Terraform Cloud to another backend is not yet implemented. Please use the API to do this: https://www.terraform.io/docs/cloud/api/state-versions.html ` +const tfcInputBackendMigrateMultiToMultiPattern = ` +If you choose to NOT rename your workspaces, just input "*". + +The asterisk "*" represents your workspace name. Here are a few examples +if a workspace was named 'prod': +* input: 'app-*'; output: 'app-prod' +* input: '*-app', output: 'prod-app' +* input: 'app-*-service', output: 'app-prod-service' +* input: '*'; output: 'prod' +` + +const tfcInputBackendMigrateMultiToMulti = ` +When migrating existing workspaces from the backend %[1]q to Terraform Cloud, would you like to +rename your workspaces? + +Unlike typical Terraform workspaces representing an environment associated with a particular +configuration (e.g. production, staging, development), Terraform Cloud workspaces are named uniquely +across all configurations used within an organization. A typical strategy to start with is +-- (e.g. networking-prod-us-east, networking-staging-us-east). + +For more information on workspace naming, see https://www.terraform.io/docs/cloud/workspaces/naming.html + +1. Yes, rename workspaces according to a pattern. +2. No, I would not like to rename my workspaces. Migrate them as currently named. +` + +const tfcInputBackendMigrateMultiToSingle = ` +The cloud configuration has one workspace declared, and you are attemtping to migrate multiple workspaces +to a single workspace. By continuing, you will only migrate your current workspace. +` + const inputBackendMigrateEmpty = ` Pre-existing state was found while migrating the previous %q backend to the newly configured %q backend. No existing state was found in the newly diff --git a/internal/command/meta_backend_migrate_test.go b/internal/command/meta_backend_migrate_test.go new file mode 100644 index 000000000..d45cbdc0d --- /dev/null +++ b/internal/command/meta_backend_migrate_test.go @@ -0,0 +1,62 @@ +package command + +import ( + "fmt" + "testing" +) + +func TestBackendMigrate_promptMultiStatePattern(t *testing.T) { + // Setup the meta + + cases := map[string]struct { + renamePrompt string + patternPrompt string + expectedErr string + }{ + "valid pattern": { + renamePrompt: "1", + patternPrompt: "hello-*", + expectedErr: "", + }, + "invalid pattern, only one asterisk allowed": { + renamePrompt: "1", + patternPrompt: "hello-*-world-*", + expectedErr: "The pattern '*' cannot be used more than once.", + }, + "invalid pattern, missing asterisk": { + renamePrompt: "1", + patternPrompt: "hello-world", + expectedErr: "The pattern must have an '*'", + }, + "invalid rename": { + renamePrompt: "3", + expectedErr: "Please select 1 or 2 as part of this option.", + }, + "no rename": { + renamePrompt: "2", + }, + } + for name, tc := range cases { + fmt.Println("Test: ", name) + m := testMetaBackend(t, nil) + input := map[string]string{} + cleanup := testInputMap(t, input) + if tc.renamePrompt != "" { + input["backend-migrate-multistate-to-tfc"] = tc.renamePrompt + } + if tc.patternPrompt != "" { + input["backend-migrate-multistate-to-tfc-pattern"] = tc.patternPrompt + } + + sourceType := "cloud" + _, err := m.promptMultiStateMigrationPattern(sourceType) + if tc.expectedErr == "" && err != nil { + t.Fatalf("expected error to be nil, but was %s", err.Error()) + } + if tc.expectedErr != "" && tc.expectedErr != err.Error() { + t.Fatalf("expected error to eq %s but got %s", tc.expectedErr, err.Error()) + } + + cleanup() + } +}