Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add replace-provider capability #145

Merged
merged 11 commits into from
Sep 4, 2023
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"
mdb marked this conversation as resolved.
Show resolved Hide resolved
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}
minamijoyo marked this conversation as resolved.
Show resolved Hide resolved
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.
minamijoyo marked this conversation as resolved.
Show resolved Hide resolved
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
Loading