diff --git a/tfexec/internal/e2etest/state_replace_provider_test.go b/tfexec/internal/e2etest/state_replace_provider_test.go new file mode 100644 index 00000000..3a154ec6 --- /dev/null +++ b/tfexec/internal/e2etest/state_replace_provider_test.go @@ -0,0 +1,81 @@ +package e2etest + +import ( + "context" + "encoding/json" + "testing" + + "github.com/hashicorp/go-version" + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-exec/tfexec" +) + +func TestStateReplaceProvider(t *testing.T) { + runTest(t, "basic_with_state", func(t *testing.T, tfv *version.Version, tf *tfexec.Terraform) { + if tfv.LessThan(providerAddressMinVersion) { + t.Skip("state file provider FQNs not compatible with this Terraform version") + } + + providerName := "registry.terraform.io/mildred/null" + + err := tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + err = tf.StateReplaceProvider(context.Background(), "hashicorp/null", "mildred/null") + if err != nil { + t.Fatalf("error running StateReplaceProvider: %s", err) + } + + err = tf.Init(context.Background()) + if err != nil { + t.Fatalf("error running Init in test directory: %s", err) + } + + formatVersion := "0.1" + var sensitiveValues json.RawMessage + if tfv.Core().GreaterThanOrEqual(v1_0_1) { + formatVersion = "0.2" + sensitiveValues = json.RawMessage([]byte("{}")) + } + if tfv.Core().GreaterThanOrEqual(v1_1) { + formatVersion = "1.0" + } + + // test that the new state is as expected + expected := &tfjson.State{ + FormatVersion: formatVersion, + // TerraformVersion is ignored to facilitate latest version testing + Values: &tfjson.StateValues{ + RootModule: &tfjson.StateModule{ + Resources: []*tfjson.StateResource{{ + Address: "null_resource.foo", + AttributeValues: map[string]interface{}{ + "id": "5510719323588825107", + "inputs": nil, + "outputs": nil, + "triggers": nil, + "values": nil, + }, + SensitiveValues: sensitiveValues, + Mode: tfjson.ManagedResourceMode, + Type: "null_resource", + Name: "foo", + ProviderName: providerName, + }}, + }, + }, + } + + actual, err := tf.Show(context.Background()) + if err != nil { + t.Fatal(err) + } + + if diff := diffState(expected, actual); diff != "" { + t.Fatalf("mismatch (-want +got):\n%s", diff) + } + }) +} diff --git a/tfexec/state_replace_provider.go b/tfexec/state_replace_provider.go new file mode 100644 index 00000000..a1a44ffb --- /dev/null +++ b/tfexec/state_replace_provider.go @@ -0,0 +1,87 @@ +package tfexec + +import ( + "context" + "os/exec" + "strconv" +) + +type stateReplaceProviderConfig struct { + backup string + lock bool + lockTimeout string + state string + stateOut string +} + +var defaultStateReplaceProviderOptions = stateReplaceProviderConfig{ + lock: true, + lockTimeout: "0s", +} + +// StateReplaceProviderCmdOption represents options used in the Refresh method. +type StateReplaceProviderCmdOption interface { + configureStateReplaceProvider(*stateReplaceProviderConfig) +} + +func (opt *BackupOption) configureStateReplaceProvider(conf *stateReplaceProviderConfig) { + conf.backup = opt.path +} + +func (opt *LockOption) configureStateReplaceProvider(conf *stateReplaceProviderConfig) { + conf.lock = opt.lock +} + +func (opt *LockTimeoutOption) configureStateReplaceProvider(conf *stateReplaceProviderConfig) { + conf.lockTimeout = opt.timeout +} + +func (opt *StateOption) configureStateReplaceProvider(conf *stateReplaceProviderConfig) { + conf.state = opt.path +} + +func (opt *StateOutOption) configureStateReplaceProvider(conf *stateReplaceProviderConfig) { + conf.stateOut = opt.path +} + +// StateReplaceProvider represents the terraform state replace-provider subcommand. +func (tf *Terraform) StateReplaceProvider(ctx context.Context, fromProviderFqn string, toProviderFqn string, opts ...StateReplaceProviderCmdOption) error { + cmd, err := tf.stateReplaceProviderCmd(ctx, fromProviderFqn, toProviderFqn, opts...) + if err != nil { + return err + } + return tf.runTerraformCmd(ctx, cmd) +} + +func (tf *Terraform) stateReplaceProviderCmd(ctx context.Context, fromProviderFqn string, toProviderFqn string, opts ...StateReplaceProviderCmdOption) (*exec.Cmd, error) { + c := defaultStateReplaceProviderOptions + + for _, o := range opts { + o.configureStateReplaceProvider(&c) + } + + args := []string{"state", "replace-provider", "-no-color", "-auto-approve"} + + // string opts: only pass if set + if c.backup != "" { + args = append(args, "-backup="+c.backup) + } + if c.lockTimeout != "" { + args = append(args, "-lock-timeout="+c.lockTimeout) + } + if c.state != "" { + args = append(args, "-state="+c.state) + } + if c.stateOut != "" { + args = append(args, "-state-out="+c.stateOut) + } + + // boolean and numerical opts: always pass + args = append(args, "-lock="+strconv.FormatBool(c.lock)) + + // positional arguments + args = append(args, fromProviderFqn) + args = append(args, toProviderFqn) + + return tf.buildTerraformCmd(ctx, nil, args...), nil +} diff --git a/tfexec/state_replace_provider_test.go b/tfexec/state_replace_provider_test.go new file mode 100644 index 00000000..c91d8a62 --- /dev/null +++ b/tfexec/state_replace_provider_test.go @@ -0,0 +1,59 @@ +package tfexec + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-exec/tfexec/internal/testutil" +) + +func TestStateReplaceProviderCmd(t *testing.T) { + td := t.TempDir() + + tf, err := NewTerraform(td, tfVersion(t, testutil.Latest013)) + if err != nil { + t.Fatal(err) + } + + // empty env, to avoid environ mismatch in testing + tf.SetEnv(map[string]string{}) + + t.Run("defaults", func(t *testing.T) { + stateReplaceProviderCmd, err := tf.stateReplaceProviderCmd(context.Background(), "testfromprovider", "testtoprovider") + if err != nil { + t.Fatal(err) + } + + assertCmd(t, []string{ + "state", + "replace-provider", + "-no-color", + "-auto-approve", + "-lock-timeout=0s", + "-lock=true", + "testfromprovider", + "testtoprovider", + }, nil, stateReplaceProviderCmd) + }) + + t.Run("override all defaults", func(t *testing.T) { + stateReplaceProviderCmd, err := tf.stateReplaceProviderCmd(context.Background(), "testfromprovider", "testtoprovider", Backup("testbackup"), LockTimeout("200s"), State("teststate"), StateOut("teststateout"), Lock(false)) + if err != nil { + t.Fatal(err) + } + + assertCmd(t, []string{ + "state", + "replace-provider", + "-no-color", + "-auto-approve", + "-backup=testbackup", + "-lock-timeout=200s", + "-state=teststate", + "-state-out=teststateout", + "-lock=false", + "testfromprovider", + "testtoprovider", + }, nil, stateReplaceProviderCmd) + }) +}