Skip to content

Commit

Permalink
add replace-provider action
Browse files Browse the repository at this point in the history
This addresses issue #144 by providing a `replace-provider` action.

Note that, unlike other `state` actions, the `replace-provider` action does not
support a `-state-out`:

```
terraform state replace-provider \
  -state=terraform.tfstate \
  -state-out=out.tfstate \
  registry.terraform.io/hashicorp/local registy.mdb.io/hashicorp/local

Error parsing command-line flags: flag provided but not defined:
-state-out
```

Signed-off-by: Mike Ball <mikedball@gmail.com>
  • Loading branch information
mdb committed Aug 16, 2023
1 parent 722f089 commit 4a1e408
Show file tree
Hide file tree
Showing 12 changed files with 641 additions and 18 deletions.
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
16 changes: 14 additions & 2 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
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 state list: %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)
}
}
71 changes: 71 additions & 0 deletions tfexec/terraform_state_replace_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package tfexec

import (
"context"
"fmt"
"os"

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

const (
// MinimumTerraformVersionForStateReplaceProvider specifies the minimum
// supported Terraform version for StateReplaceProvider.
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) {
constraints, err := version.NewConstraint(fmt.Sprintf(">= %s", MinimumTerraformVersionForStateReplaceProvider))
if err != nil {
return nil, err
}

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

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

args := []string{"state", "replace-provider"}

var tmpState *os.File

if state != nil {
if hasPrefixOptions(opts, "-state=") {
return nil, fmt.Errorf("failed to build options. The state argument (!= nil) and the -state= option cannot be set at the same time: state=%v, opts=%v", state, opts)
}
tmpState, err = writeTempFile(state.Bytes())
defer os.Remove(tmpState.Name())
if err != nil {
return nil, err
}
args = append(args, "-state="+tmpState.Name())
}

args = append(args, opts...)
args = append(args, source, destination)

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

// Read updated states
var updatedState *State

if state != nil {
bytes, err := os.ReadFile(tmpState.Name())
if err != nil {
return nil, err
}
updatedState = NewState(bytes)
}

return updatedState, nil
}
Loading

0 comments on commit 4a1e408

Please sign in to comment.