Skip to content

Commit

Permalink
Merge pull request #145 from mdb/mdb/replace-provider
Browse files Browse the repository at this point in the history
add replace-provider capability
  • Loading branch information
minamijoyo committed Sep 4, 2023
2 parents efb23ac + 7408063 commit dfec615
Show file tree
Hide file tree
Showing 21 changed files with 967 additions and 42 deletions.
15 changes: 15 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,18 @@ testacc: build generate-plugin-cache

.PHONY: check
check: lint test

.PHONY: legacy-tfstate
legacy-tfstate:
# Generate a 0.12.31 tfstate file for use in replace-provider tests.
docker run \
--interactive \
--rm \
--tty \
--volume $(shell pwd):/src \
--workdir /src/test-fixtures/legacy-tfstate \
--entrypoint /bin/sh \
hashicorp/terraform:0.12.31 \
-c \
"terraform init && \
terraform apply -auto-approve"
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A Terraform state migration tool for GitOps.
* [state xmv](#state-xmv)
* [state rm](#state-rm)
* [state import](#state-import)
* [state replace-provider](#state-replace-provider)
* [migration block (multi_state)](#migration-block-multi_state)
* [multi_state mv](#multi_state-mv)
* [multi_state xmv](#multi_state-xmv)
Expand Down Expand Up @@ -572,6 +573,7 @@ The `state` migration updates the state in a single directory. It has the follow
- `"xmv <source> <destination>"`
- `"rm <addresses>...`
- `"import <address> <id>"`
- `"replace-provider <address> <address>"`
- `force` (optional): Apply migrations even if plan show changes

Note that `dir` is relative path to the current working directory where `tfmigrate` command is invoked.
Expand Down Expand Up @@ -633,6 +635,17 @@ migration "state" "test" {
}
```

#### state replace-provider

```hcl
migration "state" "test" {
dir = "dir1"
actions = [
"replace-provider registry.terraform.io/-/null registry.terraform.io/hashicorp/null",
]
}
```

### migration block (multi_state)

The `multi_state` migration updates states in two different directories. It is intended for moving resources across states. It has the following attributes.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
# From observation, although we don’t have complete confidence in the root cause,
# it appears that localstack sometimes misses API requests when run in parallel.
TF_CLI_ARGS_apply: "--parallelism=1"
TERRAFORM_VERSION: ${TERRAFORM_VERSION:-latest}
depends_on:
- localstack
- fake-gcs-server
Expand Down
1 change: 1 addition & 0 deletions test-fixtures/legacy-tfstate/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
resource "null_resource" "foo" {}
24 changes: 24 additions & 0 deletions test-fixtures/legacy-tfstate/terraform.tfstate
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"version": 4,
"terraform_version": "0.12.31",
"serial": 1,
"lineage": "e80ec150-5474-9ca5-445f-bc55e224f303",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "null_resource",
"name": "foo",
"provider": "provider.null",
"instances": [
{
"schema_version": 0,
"attributes": {
"id": "859754710453181749",
"triggers": null
}
}
]
}
]
}
36 changes: 28 additions & 8 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"

"github.com/hashicorp/go-version"
"github.com/mattn/go-shellwords"
)

Expand Down Expand Up @@ -56,8 +57,8 @@ func NewPlan(b []byte) *Plan {
// As a result, the interface is opinionated and less flexible. For running arbitrary terraform commands
// you can use Run(), which is a low-level generic method.
type TerraformCLI interface {
// Version returns a version number of Terraform.
Version(ctx context.Context) (string, error)
// Version returns a Terraform version.
Version(ctx context.Context) (*version.Version, error)

// Init initializes the current work directory.
Init(ctx context.Context, opts ...string) error
Expand All @@ -77,6 +78,10 @@ type TerraformCLI interface {
// If a state is given, use it for the input state.
Import(ctx context.Context, state *State, address string, id string, opts ...string) (*State, error)

// Providers shows a tree of modules in the referenced configuration annotated with
// their provider requirements.
Providers(ctx context.Context) (string, error)

// StateList shows a list of resources.
// If a state is given, use it for the input state.
StateList(ctx context.Context, state *State, addresses []string, opts ...string) ([]string, error)
Expand All @@ -96,6 +101,13 @@ type TerraformCLI interface {
// because the terraform state rm command doesn't have -state-out option.
StateRm(ctx context.Context, state *State, addresses []string, opts ...string) (*State, error)

// StateReplaceProvider replaces a provider from source to destination address.
// If a state argument is given, use it for the input state.
// It returns the given state.
// Unlike other state subcommands, the terraform state replace-provider
// command doesn't support a -state-out option; it only supports the -state option.
StateReplaceProvider(ctx context.Context, state *State, source string, destination string, opts ...string) (*State, error)

// StatePush pushes a given State to remote.
StatePush(ctx context.Context, state *State, opts ...string) error

Expand Down Expand Up @@ -124,10 +136,14 @@ type TerraformCLI interface {
// so we need to switch the backend to local for temporary state operations.
// The filename argument must meet constraints for override file.
// (e.g.) _tfexec_override.tf
OverrideBackendToLocal(ctx context.Context, filename string, workspace string, isBackendTerraformCloud bool, backendConfig []string) (func(), error)
OverrideBackendToLocal(ctx context.Context, filename string, workspace string, isBackendTerraformCloud bool, backendConfig []string, supportsStateReplaceProvider bool) (func(), error)

// PlanHasChange is a helper method which runs plan and return true if the plan has change.
PlanHasChange(ctx context.Context, state *State, opts ...string) (bool, error)

// SupportsStateReplaceProvider is a helper method used to determine whether or
// not the terraform version supports `state replace-provider`.
SupportsStateReplaceProvider(ctx context.Context) (bool, version.Constraints, error)
}

// terraformCLI implements the TerraformCLI interface.
Expand Down Expand Up @@ -197,7 +213,7 @@ func (c *terraformCLI) SetExecPath(execPath string) {
// The filename argument must meet constraints in order to override the file.
// (e.g.) _tfexec_override.tf
func (c *terraformCLI) OverrideBackendToLocal(ctx context.Context, filename string,
workspace string, isBackendTerraformCloud bool, backendConfig []string) (func(), error) {
workspace string, isBackendTerraformCloud bool, backendConfig []string, supportsStateReplaceProvider bool) (func(), error) {
// create local backend override file.
path := filepath.Join(c.Dir(), filename)
contents := `
Expand Down Expand Up @@ -261,12 +277,16 @@ terraform {
if !isBackendTerraformCloud {
args = append(args, "-reconfigure")
}
err = c.Init(ctx, args...)

err = c.Init(ctx, args...)
if err != nil {
// we cannot return error here.
log.Printf("[ERROR] [executor@%s] failed to switch back to remote: %s\n", c.Dir(), err)
log.Printf("[ERROR] [executor@%s] please re-run terraform init -reconfigure\n", c.Dir())
if supportsStateReplaceProvider && strings.Contains(err.Error(), AcceptableLegacyStateInitError) {
log.Printf("[INFO] [migrator@%s] ignoring error '%s'; the error is expected when using Terraform with a legacy Terraform state\n", c.Dir(), AcceptableLegacyStateInitError)
} else {
// we cannot return error here.
log.Printf("[ERROR] [executor@%s] failed to switch back to remote: %s\n", c.Dir(), err)
log.Printf("[ERROR] [executor@%s] please re-run terraform init -reconfigure\n", c.Dir())
}
}
}

Expand Down
18 changes: 18 additions & 0 deletions tfexec/terraform_providers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package tfexec

import (
"context"
)

// Providers prints out a tree of modules in the referenced configuration annotated with
// their provider requirements.
func (c *terraformCLI) Providers(ctx context.Context) (string, error) {
args := []string{"providers"}

stdout, _, err := c.Run(ctx, args...)
if err != nil {
return "", err
}

return stdout, nil
}
121 changes: 121 additions & 0 deletions tfexec/terraform_providers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package tfexec

import (
"context"
"fmt"
"reflect"
"testing"

"github.com/hashicorp/go-version"
)

var providersStdout = `
Providers required by configuration:
.
└── provider[registry.terraform.io/hashicorp/null]
Providers required by state:
provider[registry.terraform.io/hashicorp/null]
`

var legacyProvidersStdout = `.
└── provider.null
`

func TestTerraformCLIProviders(t *testing.T) {
cases := []struct {
desc string
mockCommands []*mockCommand
addresses []string
want string
ok bool
}{
{
desc: "basic invocation",
mockCommands: []*mockCommand{
{
stdout: providersStdout,
exitCode: 0,
},
},
want: providersStdout,
ok: true,
},
{
desc: "failed to run terraform providers",
mockCommands: []*mockCommand{
{
exitCode: 1,
},
},
want: "",
ok: false,
},
}

for _, tc := range cases {
t.Run(tc.desc, func(t *testing.T) {
tc.mockCommands[0].args = []string{"terraform", "providers"}
e := NewMockExecutor(tc.mockCommands)
terraformCLI := NewTerraformCLI(e)
got, err := terraformCLI.Providers(context.Background())
if tc.ok && err != nil {
t.Fatalf("unexpected err: %s", err)
}
if !tc.ok && err == nil {
t.Fatal("expected to return an error, but no error")
}
if tc.ok && !reflect.DeepEqual(got, tc.want) {
t.Errorf("got: %v, want: %v", got, tc.want)
}
})
}
}

func TestAccTerraformCLIProviders(t *testing.T) {
SkipUnlessAcceptanceTestEnabled(t)

source := `
resource "null_resource" "foo" {}
resource "null_resource" "bar" {}
`
e := SetupTestAcc(t, source)
terraformCLI := NewTerraformCLI(e)

err := terraformCLI.Init(context.Background(), "-input=false", "-no-color")
if err != nil {
t.Fatalf("failed to run terraform init: %s", err)
}

err = terraformCLI.Apply(context.Background(), nil, "-input=false", "-no-color", "-auto-approve")
if err != nil {
t.Fatalf("failed to run terraform apply: %s", err)
}

got, err := terraformCLI.Providers(context.Background())
if err != nil {
t.Fatalf("failed to run terraform providers: %s", err)
}

v, err := terraformCLI.Version(context.Background())
if err != nil {
t.Fatalf("unexpected version error: %s", err)
}

constraints, err := version.NewConstraint(fmt.Sprintf(">= %s", MinimumTerraformVersionForStateReplaceProvider))
if err != nil {
t.Fatalf("unexpected version constraint error: %s", err)
}

want := providersStdout
if !constraints.Check(v) {
want = legacyProvidersStdout
}

if got != want {
t.Errorf("got: %s, want: %s", got, want)
}
}
Loading

0 comments on commit dfec615

Please sign in to comment.