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:
parent
7cc53fe163
commit
55fc590904
2
go.mod
2
go.mod
|
@ -4,6 +4,7 @@ require (
|
||||||
cloud.google.com/go/storage v1.10.0
|
cloud.google.com/go/storage v1.10.0
|
||||||
github.com/Azure/azure-sdk-for-go v52.5.0+incompatible
|
github.com/Azure/azure-sdk-for-go v52.5.0+incompatible
|
||||||
github.com/Azure/go-autorest/autorest v0.11.18
|
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/agext/levenshtein v1.2.3
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a
|
github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20190329064014-6e358769c32a
|
||||||
github.com/aliyun/aliyun-oss-go-sdk v0.0.0-20190103054945-8205d1f41e70
|
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/jstemmer/go-junit-report v0.9.1 // indirect
|
||||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||||
github.com/klauspost/compress v1.11.2 // 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/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.6 // indirect
|
github.com/mattn/go-colorable v0.1.6 // indirect
|
||||||
github.com/mitchellh/go-testing-interface v1.0.0 // indirect
|
github.com/mitchellh/go-testing-interface v1.0.0 // indirect
|
||||||
|
|
3
go.sum
3
go.sum
|
@ -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 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU=
|
||||||
github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
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/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/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/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=
|
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
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/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/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 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
|
|
@ -16,12 +16,6 @@ import (
|
||||||
"github.com/hashicorp/terraform/internal/e2e"
|
"github.com/hashicorp/terraform/internal/e2e"
|
||||||
)
|
)
|
||||||
|
|
||||||
type tfCommand struct {
|
|
||||||
command []string
|
|
||||||
expectedOutput string
|
|
||||||
expectedErr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_terraform_apply_autoApprove(t *testing.T) {
|
func Test_terraform_apply_autoApprove(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
cases := map[string]struct {
|
cases := map[string]struct {
|
||||||
|
@ -218,7 +212,7 @@ func Test_terraform_apply_autoApprove(t *testing.T) {
|
||||||
}
|
}
|
||||||
orgName := resourceData["organization"]
|
orgName := resourceData["organization"]
|
||||||
wsName := resourceData["workspace"]
|
wsName := resourceData["workspace"]
|
||||||
tfBlock := createTerraformBlock(orgName, wsName)
|
tfBlock := terraformConfigCloudBackendName(orgName, wsName)
|
||||||
writeMainTF(t, tfBlock, tmpDir)
|
writeMainTF(t, tfBlock, tmpDir)
|
||||||
tf := e2e.NewBinary(terraformBin, tmpDir)
|
tf := e2e.NewBinary(terraformBin, tmpDir)
|
||||||
defer tf.Close()
|
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) {
|
func writeMainTF(t *testing.T, block string, dir string) {
|
||||||
f, err := os.Create(fmt.Sprintf("%s/main.tf", dir))
|
f, err := os.Create(fmt.Sprintf("%s/main.tf", dir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -7,11 +7,30 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
tfe "github.com/hashicorp/go-tfe"
|
tfe "github.com/hashicorp/go-tfe"
|
||||||
"github.com/hashicorp/go-uuid"
|
"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()) {
|
func createOrganization(t *testing.T) (*tfe.Organization, func()) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
|
org, err := tfeClient.Organizations.Create(ctx, tfe.OrganizationCreateOptions{
|
||||||
|
@ -48,3 +67,62 @@ func randomString(t *testing.T) string {
|
||||||
}
|
}
|
||||||
return v
|
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)
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
@ -48,7 +47,6 @@ func setup() func() {
|
||||||
setTfeClient()
|
setTfeClient()
|
||||||
teardown := setupBinary()
|
teardown := setupBinary()
|
||||||
setVersion()
|
setVersion()
|
||||||
ensureVersionExists()
|
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
teardown()
|
teardown()
|
||||||
|
@ -143,42 +141,6 @@ func setVersion() {
|
||||||
terraformVersion = fmt.Sprintf("%s-%s", version, hash)
|
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) {
|
func writeCredRC(file string) {
|
||||||
creds := credentialBlock()
|
creds := credentialBlock()
|
||||||
f, err := os.Create(file)
|
f, err := os.Create(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")
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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")
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/hashicorp/terraform/internal/backend"
|
"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/arguments"
|
||||||
"github.com/hashicorp/terraform/internal/command/clistate"
|
"github.com/hashicorp/terraform/internal/command/clistate"
|
||||||
"github.com/hashicorp/terraform/internal/command/views"
|
"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)
|
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
|
// 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.
|
// from multi-state to single-state for example, we need to handle that.
|
||||||
var sourceSingleState, destinationSingleState bool
|
var sourceSingleState, destinationSingleState, sourceTFC, destinationTFC 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
destinationWorkspaces, err := opts.Destination.Workspaces()
|
_, sourceTFC = opts.Source.(*cloud.Cloud)
|
||||||
if err == backend.ErrWorkspacesNotSupported {
|
_, destinationTFC = opts.Destination.(*cloud.Cloud)
|
||||||
destinationSingleState = true
|
|
||||||
err = nil
|
sourceWorkspaces, sourceSingleState, err := retrieveWorkspaces(opts.Source, opts.SourceType)
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(strings.TrimSpace(
|
return err
|
||||||
errMigrateLoadStates), opts.DestinationType, err)
|
}
|
||||||
|
destinationWorkspaces, destinationSingleState, err := retrieveWorkspaces(opts.Destination, opts.SourceType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up defaults
|
// Set up defaults
|
||||||
|
@ -103,6 +97,9 @@ func (m *Meta) backendMigrateState(opts *backendMigrateOpts) error {
|
||||||
// Determine migration behavior based on whether the source/destination
|
// Determine migration behavior based on whether the source/destination
|
||||||
// supports multi-state.
|
// supports multi-state.
|
||||||
switch {
|
switch {
|
||||||
|
case sourceTFC || destinationTFC:
|
||||||
|
return m.backendMigrateTFC(opts)
|
||||||
|
|
||||||
// Single-state to single-state. This is the easiest case: we just
|
// Single-state to single-state. This is the easiest case: we just
|
||||||
// copy the default state directly.
|
// copy the default state directly.
|
||||||
case sourceSingleState && destinationSingleState:
|
case sourceSingleState && destinationSingleState:
|
||||||
|
@ -497,6 +494,91 @@ func (m *Meta) backendMigrateNonEmptyConfirm(
|
||||||
return m.confirm(inputOpts)
|
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 = `
|
const errMigrateLoadStates = `
|
||||||
Error inspecting states in the %q backend:
|
Error inspecting states in the %q backend:
|
||||||
%s
|
%s
|
||||||
|
@ -541,6 +623,12 @@ The state in the previous backend remains intact and unmodified. Please resolve
|
||||||
the error above and try again.
|
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 = `
|
const inputBackendMigrateEmpty = `
|
||||||
Pre-existing state was found while migrating the previous %q backend to the
|
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
|
newly configured %q backend. No existing state was found in the newly
|
||||||
|
|
Loading…
Reference in New Issue