Teraform Cloud Backend State Migration

* determining source or destination to cloud
* handling single to single state migrations to cloud,
using a name strategy or a tags strategy
* Add end-to-end tests for state migration.
This commit is contained in:
Omar Ismail 2021-09-22 17:53:33 -04:00 committed by Chris Arcand
parent 7cc53fe163
commit 55fc590904
11 changed files with 568 additions and 84 deletions

2
go.mod
View File

@ -4,6 +4,7 @@ require (
cloud.google.com/go/storage v1.10.0
github.com/Azure/azure-sdk-for-go v52.5.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.18
github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25
github.com/agext/levenshtein v1.2.3
github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a
github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190103054945-8205d1f41e70
@ -154,6 +155,7 @@ require (
github.com/jstemmer/go-junit-report v0.9.1 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/klauspost/compress v1.11.2 // indirect
github.com/kr/pty v1.1.1 // indirect
github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
github.com/mattn/go-colorable v0.1.6 // indirect
github.com/mitchellh/go-testing-interface v1.0.0 // indirect

3
go.sum
View File

@ -89,6 +89,8 @@ github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuN
github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU=
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25 h1:hWfsqBaNZUHztXA78g7Y2Jj3rDQaTCZhhFwz43i2VlA=
github.com/Netflix/go-expect v0.0.0-20211003183012-e1a7c020ce25/go.mod h1:68ORG0HSEWDuH5Eh73AFbYWZ1zT4Y+b0vhOa+vZRUdI=
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
@ -453,6 +455,7 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=

View File

@ -16,12 +16,6 @@ import (
"github.com/hashicorp/terraform/internal/e2e"
)
type tfCommand struct {
command []string
expectedOutput string
expectedErr string
}
func Test_terraform_apply_autoApprove(t *testing.T) {
ctx := context.Background()
cases := map[string]struct {
@ -218,7 +212,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) {
}
orgName := resourceData["organization"]
wsName := resourceData["workspace"]
tfBlock := createTerraformBlock(orgName, wsName)
tfBlock := terraformConfigCloudBackendName(orgName, wsName)
writeMainTF(t, tfBlock, tmpDir)
tf := e2e.NewBinary(terraformBin, tmpDir)
defer tf.Close()
@ -245,28 +239,6 @@ func Test_terraform_apply_autoApprove(t *testing.T) {
}
}
func createTerraformBlock(org, ws string) string {
return fmt.Sprintf(
`terraform {
cloud {
hostname = "%s"
organization = "%s"
workspaces {
name = "%s"
}
}
}
resource "random_pet" "server" {
keepers = {
uuid = uuid()
}
length = 3
}`, tfeHostname, org, ws)
}
func writeMainTF(t *testing.T, block string, dir string) {
f, err := os.Create(fmt.Sprintf("%s/main.tf", dir))
if err != nil {

View File

@ -7,11 +7,30 @@ import (
"context"
"fmt"
"testing"
"time"
tfe "github.com/hashicorp/go-tfe"
"github.com/hashicorp/go-uuid"
)
const (
expectConsoleTimeout = 15 * time.Second
)
type tfCommand struct {
command []string
expectedOutput string
expectedErr string
expectError bool
userInput []string
postInputOutput string
}
type operationSets struct {
commands []tfCommand
prep func(t *testing.T, orgName, dir string)
}
func createOrganization(t *testing.T) (*tfe.Organization, func()) {
ctx := context.Background()
org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
@ -48,3 +67,62 @@ func randomString(t *testing.T) string {
}
return v
}
func terraformConfigLocalBackend() string {
return fmt.Sprintf(`
terraform {
backend "local" {
}
}
output "val" {
value = "${terraform.workspace}"
}
`)
}
func terraformConfigCloudBackendTags(org, tag string) string {
return fmt.Sprintf(`
terraform {
cloud {
hostname = "%s"
organization = "%s"
workspaces {
tags = ["%s"]
}
}
}
resource "random_pet" "server" {
keepers = {
uuid = uuid()
}
length = 3
}
`, tfeHostname, org, tag)
}
func terraformConfigCloudBackendName(org, name string) string {
return fmt.Sprintf(`
terraform {
cloud {
hostname = "%s"
organization = "%s"
workspaces {
name = "%s"
}
}
}
resource "random_pet" "server" {
keepers = {
uuid = uuid()
}
length = 3
}
`, tfeHostname, org, name)
}

View File

@ -4,7 +4,6 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
@ -48,7 +47,6 @@ func setup() func() {
setTfeClient()
teardown := setupBinary()
setVersion()
ensureVersionExists()
return func() {
teardown()
@ -143,42 +141,6 @@ func setVersion() {
terraformVersion = fmt.Sprintf("%s-%s", version, hash)
}
func ensureVersionExists() {
opts := tfe.AdminTerraformVersionsListOptions{
ListOptions: tfe.ListOptions{
PageNumber: 1,
PageSize: 100,
},
}
hasVersion := false
findTfVersion:
for {
tfVersionList, err := tfeClient.Admin.TerraformVersions.List(context.Background(), opts)
if err != nil {
log.Fatalf("Could not retrieve list of terraform versions: %v", err)
}
for _, item := range tfVersionList.Items {
if item.Version == terraformVersion {
hasVersion = true
break findTfVersion
}
}
// Exit the loop when we've seen all pages.
if tfVersionList.CurrentPage >= tfVersionList.TotalPages {
break
}
// Update the page number to get the next page.
opts.PageNumber = tfVersionList.NextPage
}
if !hasVersion {
log.Fatalf("Terraform Version %s does not exist in the list. Please add it.", terraformVersion)
}
}
func writeCredRC(file string) {
creds := credentialBlock()
f, err := os.Create(file)

View File

@ -0,0 +1,18 @@
package main
import (
"testing"
)
/*
"multi" == multi-backend, multiple workspaces
-- when cloud config == name ->
---- prompt -> do you want to ONLY migrate the current workspace
-- 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")
}

View File

@ -0,0 +1,31 @@
package main
import (
"testing"
)
// REMOTE BACKEND
/*
- RB name -> TFC name
-- straight copy if only if different name, or same WS name in diff org
-- other
-- ensure that the local workspace, after migration, is the new name (in the tfc config block)
- RB name -> TFC tags
-- just add tag, if in same org
-- If new org, if WS exists, just add tag
-- If new org, if WS not exists, create and add tag
- RB prefix -> TFC name
-- create if not exists
-- migrate the current worksapce state to ws name
- RB prefix -> TFC tags
-- update previous workspaces (prefix + local) with cloud config tag
-- Rename the local workspaces to match the TFC workspaces (prefix + former local, ie app-prod). inform user
*/
func Test_migrate_remote_backend_name_to_tfc(t *testing.T) {
t.Skip("todo: see comments")
}
func Test_migrate_remote_backend_prefix_to_tfc(t *testing.T) {
t.Skip("todo: see comments")
}

View File

@ -0,0 +1,196 @@
//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"
)
func Test_migrate_single_to_tfc(t *testing.T) {
ctx := context.Background()
cases := map[string]struct {
operations []operationSets
validations func(t *testing.T, orgName string)
}{
"migrate using cloud workspace name strategy": {
operations: []operationSets{
{
prep: func(t *testing.T, orgName, dir string) {
tfBlock := terraformConfigLocalBackend()
writeMainTF(t, tfBlock, dir)
},
commands: []tfCommand{
{
command: []string{"init"},
expectedOutput: `Successfully configured the backend "local"!`,
},
{
command: []string{"apply"},
userInput: []string{"yes"},
expectedOutput: `Do you want to perform these actions?`,
postInputOutput: `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"},
expectedOutput: `Do you want to copy existing state to the new backend?`,
userInput: []string{"yes"},
postInputOutput: `Successfully configured the backend "cloud"!`,
},
{
command: []string{"workspace", "list"},
expectedOutput: `new-workspace`,
},
},
},
},
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]
if ws.Name != "new-workspace" {
t.Fatalf("Expected workspace to be `new-workspace`, but is %s", ws.Name)
}
},
},
"migrate using cloud workspace tags strategy": {
operations: []operationSets{
{
prep: func(t *testing.T, orgName, dir string) {
tfBlock := terraformConfigLocalBackend()
writeMainTF(t, tfBlock, dir)
},
commands: []tfCommand{
{
command: []string{"init"},
expectedOutput: `Successfully configured the backend "local"!`,
},
{
command: []string{"apply"},
userInput: []string{"yes"},
expectedOutput: `Do you want to perform these actions?`,
postInputOutput: `Apply complete!`,
},
},
},
{
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"},
expectedOutput: `The "cloud" backend configuration only allows named workspaces!`,
userInput: []string{"new-workspace", "yes"},
postInputOutput: `Successfully configured the backend "cloud"!`,
},
{
command: []string{"workspace", "list"},
expectedOutput: `new-workspace`,
},
},
},
},
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)
}
ws := wsList.Items[0]
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)
tf.AddEnv("TF_LOG=info")
tf.AddEnv(cliConfigFileEnv)
defer tf.Close()
for _, op := range tc.operations {
op.prep(t, organization.Name, tf.WorkDir())
for _, tfCmd := range op.commands {
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.expectedOutput != "" {
_, err := exp.ExpectString(tfCmd.expectedOutput)
if err != nil {
t.Fatal(err)
}
}
if len(tfCmd.userInput) > 0 {
for _, input := range tfCmd.userInput {
exp.SendLine(input)
}
}
if tfCmd.postInputOutput != "" {
_, err := exp.ExpectString(tfCmd.postInputOutput)
if err != nil {
t.Fatal(err)
}
}
err = cmd.Wait()
if err != nil {
t.Fatal(err)
}
}
}
if tc.validations != nil {
tc.validations(t, organization.Name)
}
}
}

View File

@ -0,0 +1,113 @@
//go:build e2e
// +build e2e
package main
import (
"fmt"
"io/ioutil"
"os"
"testing"
expect "github.com/Netflix/go-expect"
"github.com/hashicorp/terraform/internal/e2e"
)
func Test_migrate_tfc_to_other(t *testing.T) {
cases := map[string]struct {
operations []operationSets
}{
"migrate from cloud to local backend": {
operations: []operationSets{
{
prep: func(t *testing.T, orgName, dir string) {
wsName := "new-workspace"
tfBlock := terraformConfigCloudBackendName(orgName, wsName)
writeMainTF(t, tfBlock, dir)
},
commands: []tfCommand{
{
command: []string{"init"},
expectedOutput: `Successfully configured the backend "cloud"!`,
},
},
},
{
prep: func(t *testing.T, orgName, dir string) {
tfBlock := terraformConfigLocalBackend()
writeMainTF(t, tfBlock, dir)
},
commands: []tfCommand{
{
command: []string{"init", "-migrate-state"},
expectedOutput: `Migrating state from Terraform Cloud to another backend is not yet implemented.`,
expectError: true,
},
},
},
},
},
}
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)
tf.AddEnv("TF_LOG=info")
tf.AddEnv(cliConfigFileEnv)
defer tf.Close()
for _, op := range tc.operations {
op.prep(t, organization.Name, tf.WorkDir())
for _, tfCmd := range op.commands {
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.expectedOutput != "" {
_, err := exp.ExpectString(tfCmd.expectedOutput)
if err != nil {
t.Fatal(err)
}
}
if len(tfCmd.userInput) > 0 {
for _, input := range tfCmd.userInput {
exp.SendLine(input)
}
}
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)
}
}
}
}
}

View File

@ -0,0 +1,21 @@
package main
import (
"testing"
)
/*
If org to org, treat it like a new backend. Then go through the multi/single logic
If same org, but name/tag changes
config name -> config name
-- straight copy
config name -> config tags
-- jsut add tag to workspace.
config tags -> config name
-- straight copy
*/
func Test_migrate_tfc_to_tfc(t *testing.T) {
t.Skip("todo: see comments")
}

View File

@ -12,6 +12,7 @@ import (
"strings"
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/cloud"
"github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/clistate"
"github.com/hashicorp/terraform/internal/command/views"
@ -46,25 +47,18 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error {
log.Printf("[TRACE] backendMigrateState: need to migrate from %q to %q backend config", opts.SourceType, opts.DestinationType)
// We need to check what the named state status is. If we're converting
// from multi-state to single-state for example, we need to handle that.
var sourceSingleState, destinationSingleState bool
sourceWorkspaces, err := opts.Source.Workspaces()
if err == backend.ErrWorkspacesNotSupported {
sourceSingleState = true
err = nil
}
if err != nil {
return fmt.Errorf(strings.TrimSpace(
errMigrateLoadStates), opts.SourceType, err)
}
var sourceSingleState, destinationSingleState, sourceTFC, destinationTFC bool
destinationWorkspaces, err := opts.Destination.Workspaces()
if err == backend.ErrWorkspacesNotSupported {
destinationSingleState = true
err = nil
}
_, sourceTFC = opts.Source.(*cloud.Cloud)
_, destinationTFC = opts.Destination.(*cloud.Cloud)
sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType)
if err != nil {
return fmt.Errorf(strings.TrimSpace(
errMigrateLoadStates), opts.DestinationType, err)
return err
}
destinationWorkspaces, destinationSingleState, err := retrieveWorkspaces(opts.Destination, opts.SourceType)
if err != nil {
return err
}
// Set up defaults
@ -103,6 +97,9 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error {
// Determine migration behavior based on whether the source/destination
// supports multi-state.
switch {
case sourceTFC || destinationTFC:
return m.backendMigrateTFC(opts)
// Single-state to single-state. This is the easiest case: we just
// copy the default state directly.
case sourceSingleState && destinationSingleState:
@ -497,6 +494,91 @@ func (m *Meta) backendMigrateNonEmptyConfirm(
return m.confirm(inputOpts)
}
func retrieveWorkspaces(back backend.Backend, sourceType string) ([]string, bool, error) {
var singleState bool
var err error
workspaces, err := back.Workspaces()
if err == backend.ErrWorkspacesNotSupported {
singleState = true
err = nil
}
if err != nil {
return nil, singleState, fmt.Errorf(strings.TrimSpace(
errMigrateLoadStates), sourceType, err)
}
return workspaces, singleState, err
}
func (m *Meta) backendMigrateTFC(opts *backendMigrateOpts) error {
_, sourceTFC := opts.Source.(*cloud.Cloud)
cloudBackendDestination, destinationTFC := opts.Destination.(*cloud.Cloud)
sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType)
if err != nil {
return err
}
//to be used below, not yet implamented
// destinationWorkspaces, destinationSingleState
_, _, err = retrieveWorkspaces(opts.Destination, opts.SourceType)
if err != nil {
return err
}
// from TFC to non-TFC backend
if sourceTFC && !destinationTFC {
// From Terraform Cloud to another backend. This is not yet implemented, and
// we recommend people to use the TFC API.
return fmt.Errorf(strings.TrimSpace(errTFCMigrateNotYetImplemented))
}
// from TFC to TFC
if sourceTFC && destinationTFC {
// TODO: see internal/cloud/e2e/migrate_state_tfc_to_tfc_test.go for notes
panic("not yet implemented")
}
// Everything below, by the above two conditionals, now assumes that the
// destination is always Terraform Cloud (TFC).
sourceSingle := sourceSingleState || (len(sourceWorkspaces) == 1 && sourceWorkspaces[0] == backend.DefaultStateName)
if sourceSingle {
if cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy {
// If we know the name via WorkspaceNameStrategy, then set the
// destinationWorkspace to the new Name and skip the user prompt. Here the
// destinationWorkspace is not set to `default` thereby we will create it
// in TFC if it does not exist.
opts.destinationWorkspace = cloudBackendDestination.WorkspaceMapping.Name
}
// Run normal single-to-single state migration
// This will handle both situations where the new cloud backend
// configuration is using a workspace.name strategy or workspace.tags
// strategy.
return m.backendMigrateState_s_s(opts)
}
destinationTagsStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceTagsStrategy
destinationNameStrategy := cloudBackendDestination.WorkspaceMapping.Strategy() == cloud.WorkspaceNameStrategy
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")
}
// 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 nil
}
const errMigrateLoadStates = `
Error inspecting states in the %q backend:
%s
@ -541,6 +623,12 @@ The state in the previous backend remains intact and unmodified. Please resolve
the error above and try again.
`
const errTFCMigrateNotYetImplemented = `
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 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