Skip to content

Commit

Permalink
improve state replace-provider tests
Browse files Browse the repository at this point in the history
* use force-pushed TF 0.12.31 state file in `state replace-provider`
  acceptance tests, [as suggested in code review](#145 (comment))
* allow `terraform init` to fail -- as expected -- in instances where a
  non-legacy Terraform is used against a legacy TF state for the
  purposes of `state replace-provider`-ing
  • Loading branch information
mdb committed Aug 28, 2023
1 parent 5b6b03d commit 4ecad8d
Show file tree
Hide file tree
Showing 10 changed files with 288 additions and 61 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"
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
}
}
]
}
]
}
4 changes: 4 additions & 0 deletions tfexec/terraform.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ type TerraformCLI interface {

// 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
29 changes: 22 additions & 7 deletions tfexec/terraform_state_replace_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,37 @@ const (
MinimumTerraformVersionForStateReplaceProvider = "0.13"
)

// StateReplaceProvider replaces providers from source to destination address.
// If a state argument is given, use it for the input state.
// It returns the given state.
func (c *terraformCLI) StateReplaceProvider(ctx context.Context, state *State, source string, destination string, opts ...string) (*State, error) {
// SupportsStateReplaceProvider returns true if the terraform version is greater
// than or equal to 0.13.0 and therefore supports the state replace-provider command.
func (c *terraformCLI) SupportsStateReplaceProvider(ctx context.Context) (bool, version.Constraints, error) {
constraints, err := version.NewConstraint(fmt.Sprintf(">= %s", MinimumTerraformVersionForStateReplaceProvider))
if err != nil {
return nil, err
return false, constraints, err
}

v, err := c.Version(ctx)
if err != nil {
return nil, err
return false, constraints, err
}

if !constraints.Check(v) {
return nil, fmt.Errorf("configuration uses Terraform version %s; replace-provider action requires Terraform version %s", v, constraints)
return false, constraints, nil
}

return true, constraints, nil
}

// StateReplaceProvider replaces providers from source to destination address.
// If a state argument is given, use it for the input state.
// It returns the given state.
func (c *terraformCLI) StateReplaceProvider(ctx context.Context, state *State, source string, destination string, opts ...string) (*State, error) {
supports, constraints, err := c.SupportsStateReplaceProvider(ctx)
if err != nil {
return nil, err
}

if !supports {
return nil, fmt.Errorf("replace-provider action requires Terraform version %s", constraints)
}

args := []string{"state", "replace-provider"}
Expand Down
86 changes: 59 additions & 27 deletions tfexec/terraform_state_replace_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ package tfexec

import (
"context"
"fmt"
"os"
"reflect"
"regexp"
"strings"
"testing"

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

func TestTerraformCLIStateReplaceProvider(t *testing.T) {
Expand Down Expand Up @@ -224,8 +221,11 @@ func TestTerraformCLIStateReplaceProvider(t *testing.T) {
}
}

func TestAccTerraformCLIStateReplaceProvider(t *testing.T) {
SkipUnlessAcceptanceTestEnabled(t)
func TestAccTerraformCLIStateReplaceProviderWithLegacyTerraform(t *testing.T) {
tfVersion := os.Getenv("TERRAFORM_VERSION")
if tfVersion != LegacyTerraformVersion {
t.Skipf("skip %s acceptance test for non-legacy Terraform version %s", t.Name(), tfVersion)
}

source := `
resource "null_resource" "foo" {}
Expand All @@ -244,27 +244,70 @@ resource "null_resource" "bar" {}
t.Fatalf("failed to run terraform apply: %s", err)
}

v, err := terraformCLI.Version(context.Background())
state, err := terraformCLI.StatePull(context.Background())
if err != nil {
t.Fatalf("unexpected version error: %s", err)
t.Fatalf("failed to run terraform state pull: %s", err)
}

constraints, err := version.NewConstraint(fmt.Sprintf(">= %s", MinimumTerraformVersionForStateReplaceProvider))
stateProvider := "provider.null"

if !strings.Contains(string(state.Bytes()), stateProvider) {
t.Errorf("state does not contain provider: %s", stateProvider)
}

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

isLegacyTFVersion := !constraints.Check(v)
wantProviders := legacyProvidersStdout

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

_, err = terraformCLI.StateReplaceProvider(context.Background(), state, "registry.terraform.io/hashicorp/null", "registry.tfmigrate.io/hashicorp/null", "-auto-approve")
if err == nil {
t.Fatalf("expected terraform state replace-provider to error")
}

expected := "replace-provider action requires Terraform version >= 0.13"
if err.Error() != expected {
t.Fatalf("expected terraform state replace-provider to error with %s; got %s", expected, err.Error())
}
}

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

tfVersion := os.Getenv("TERRAFORM_VERSION")
if tfVersion == LegacyTerraformVersion {
t.Skipf("skip %s acceptance test for legacy Terraform version %s", t.Name(), tfVersion)
}

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)
}

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

stateProvider := "registry.terraform.io/hashicorp/null"
if isLegacyTFVersion {
stateProvider = "provider.null"
}

if !strings.Contains(string(state.Bytes()), stateProvider) {
t.Errorf("state does not contain provider: %s", stateProvider)
Expand All @@ -276,32 +319,21 @@ resource "null_resource" "bar" {}
}

wantProviders := providersStdout
if isLegacyTFVersion {
wantProviders = legacyProvidersStdout
}

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

updatedState, err := terraformCLI.StateReplaceProvider(context.Background(), state, "registry.terraform.io/hashicorp/null", "registry.tfmigrate.io/hashicorp/null", "-auto-approve")
if err != nil && !isLegacyTFVersion {
if err != nil {
t.Fatalf("failed to run terraform state replace-provider: %s", err)
}

if err == nil && isLegacyTFVersion {
t.Fatalf("expected state replace-provider error for legacy Terraform version: %s", v.String())
}

if isLegacyTFVersion && !strings.Contains(err.Error(), fmt.Sprintf("configuration uses Terraform version %s; replace-provider action requires Terraform version >= %s", v.String(), MinimumTerraformVersionForStateReplaceProvider)) {
t.Fatalf("expected state replace-provider error for legacy Terraform version: %s", v.String())
}

if !isLegacyTFVersion && !strings.Contains(string(updatedState.Bytes()), "registry.tfmigrate.io/hashicorp/null") {
if !strings.Contains(string(updatedState.Bytes()), "registry.tfmigrate.io/hashicorp/null") {
t.Errorf("state does not contain updated provider: %s", "registry.tfmigrate.io/hashicorp/null")
}

if !isLegacyTFVersion && strings.Contains(string(updatedState.Bytes()), "registry.terraform.io/hashicorp/null") {
if strings.Contains(string(updatedState.Bytes()), "registry.terraform.io/hashicorp/null") {
t.Errorf("state contains old provider: %s", "registry.terraform.io/hashicorp/null")
}
}
89 changes: 79 additions & 10 deletions tfexec/test_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ import (
"github.com/hashicorp/go-version"
)

const (
TestS3Bucket = "tfstate-test"
TestS3Region = "ap-northeast-1"
TestS3AccessKey = "dummy"
TestS3SecretKey = "dummy"

// LegacyTerraformVersion is the legacy Terraform version used in acceptance testing.
LegacyTerraformVersion = "0.12.31"
)

// mockExecutor implements the Executor interface for testing.
type mockExecutor struct {
// mockCommands is a sequence of mocked commands.
Expand Down Expand Up @@ -229,37 +239,96 @@ func setupTestPluginCacheDir(e Executor) error {
return nil
}

// GetTestAccBackendS3Config returns mocked backend s3 config for testing.
// Its endpoint can be set via LOCALSTACK_ENDPOINT environment variable.
// default to "http://localhost:4566"
func GetTestAccBackendS3Config(dir string) string {
// GetTestAccS3Endpoint returns the s3 endpoint to use in acceptance tests.
func GetTestAccS3Endpoint() string {
endpoint := "http://localhost:4566"
localstackEndpoint := os.Getenv("LOCALSTACK_ENDPOINT")
if len(localstackEndpoint) > 0 {
endpoint = localstackEndpoint
}

return endpoint
}

// GetTestAccBackendS3Key returns the s3 key to use in acceptance tests' s3
// backend.
func GetTestAccBackendS3Key(dir string) string {
return fmt.Sprintf("%s/terraform.tfstate", dir)
}

// GetTestAccBackendS3Config returns mocked backend s3 config for testing.
// Its endpoint can be set via LOCALSTACK_ENDPOINT environment variable.
// default to "http://localhost:4566"
func GetTestAccBackendS3Config(dir string) string {
endpoint := GetTestAccS3Endpoint()
key := GetTestAccBackendS3Key(dir)

backendConfig := fmt.Sprintf(`
terraform {
# https://www.terraform.io/docs/backends/types/s3.html
backend "s3" {
region = "ap-northeast-1"
bucket = "tfstate-test"
key = "%s/terraform.tfstate"
region = "%s"
bucket = "%s"
key = "%s"
# mock s3 endpoint with localstack
endpoint = "%s"
access_key = "dummy"
secret_key = "dummy"
access_key = "%s"
secret_key = "%s"
skip_credentials_validation = true
skip_metadata_api_check = true
force_path_style = true
}
}
`, dir, endpoint)
`, TestS3Region, TestS3Bucket, key, endpoint, TestS3AccessKey, TestS3SecretKey)
return backendConfig
}

// SetupTestAccForStateReplaceProvider is an acceptance test helper specifically
// for initializing a temporary work directory with a given source for the
// purposes of testing `state replace-provider` actions.
//
// Unlike other helpers such as SetupTestAccWithApply, SetupTestAccForStateReplaceProvider...
// 1. Does not perform a `terraform apply`. Instead, the Terraform state is
// expected to be pre-seeded to the backend S3 bucket from the
// `text-fixtures` directory. This allows the testing of `state replace-provider`
// actions using a non-legacy Terraform CLI and a legacy Terraform state file.
// 2. Permits `Error: Invalid legacy provider address` errors during `terraform
// init`. When invoking `state replace-provider`, it's necessary to first
// invoke `terraform init`. However, when using a non-legacy Terraform CLI
// against a legacy Terraform state, this error is expected.
func SetupTestAccForStateReplaceProvider(t *testing.T, workspace string, source string) TerraformCLI {
t.Helper()

e := SetupTestAcc(t, source)
tf := NewTerraformCLI(e)
ctx := context.Background()

err := tf.Init(ctx, "-input=false", "-no-color")

if err != nil && !strings.Contains(err.Error(), "Error: Invalid legacy provider address") {
t.Fatalf("failed to run terraform init: %s", err)
}

//default workspace always exists so don't try to create it
if workspace != "default" {
err = tf.WorkspaceNew(ctx, workspace)
if err != nil {
t.Fatalf("failed to run terraform workspace new %s : %s", workspace, err)
}
}

// destroy resources after each test not to have any state.
t.Cleanup(func() {
err := tf.Destroy(ctx, "-input=false", "-no-color", "-auto-approve")
if err != nil {
t.Fatalf("failed to run terraform destroy: %s", err)
}
})

return tf
}

// SetupTestAccWithApply is an acceptance test helper for initializing a
// temporary work directory and applying a given source.
func SetupTestAccWithApply(t *testing.T, workspace string, source string) TerraformCLI {
Expand Down
Loading

0 comments on commit 4ecad8d

Please sign in to comment.