Skip to content

Commit

Permalink
Feature: Add xmv command
Browse files Browse the repository at this point in the history
An xmv command is like a mv command but it allows usage of wildcards (*) in the source of the move command and placeholders ($1, $2, ..., $N) in the destination of the move command. The source will match against the state and the placeholders will correspond to the wildcard values.
  • Loading branch information
pvbouwel committed Nov 26, 2022
1 parent a49ce27 commit c27f2a1
Show file tree
Hide file tree
Showing 6 changed files with 373 additions and 0 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ A Terraform state migration tool for GitOps.
* [migration block](#migration-block)
* [migration block (state)](#migration-block-state)
* [state mv](#state-mv)
* [state xmv](#state-xmv)
* [state rm](#state-rm)
* [state import](#state-import)
* [migration block (multi_state)](#migration-block-multi_state)
Expand Down Expand Up @@ -587,6 +588,26 @@ migration "state" "test" {
}
```

#### state xmv

The `xmv` command works like the `mv` command but allows usage of
wildcards `*` in the source definition. The source expressions will be
matched against resources defined in the terraform state. The matched value
can be used in the destination definition via a dollar sign and their ordinal number:
`$1`, `$2`, ... When there is ambiguity the ordinal number can be put in curly braces (e.g. `${1}`).

For example if `foo` and `bar` in de `mv` command example above are the only 2 security group resources
defined at the top level then you can rename them using:

```hcl
migration "state" "test" {
dir = "dir1"
actions = [
"mv aws_security_group.* aws_security_group.${1}2",
]
}
```

#### state rm

```hcl
Expand Down
22 changes: 22 additions & 0 deletions tfexec/test_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,28 @@ func UpdateTestAccSource(t *testing.T, tf TerraformCLI, source string) {
}
}

// AddModuleToTestAcc adds a module dir with given contents
func AddModuleToTestAcc(t *testing.T, tf TerraformCLI, moduleName, source string) {
t.Helper()
moduleDir := filepath.Join(tf.Dir(), moduleName)
if err := os.Mkdir(moduleDir, 0700); err != nil {
t.Fatalf("Failed to add module dir to source: %s", err)
}
if err := os.WriteFile(filepath.Join(moduleDir, testAccSourceFileName), []byte(source), 0600); err != nil {
t.Fatalf("failed to update module source: %s", err)
}
}

// RunTestAccInitModule performs a tf init
func RunTestAccInitModule(t *testing.T, tf TerraformCLI) {
t.Helper()
ctx := context.Background()
err := tf.Init(ctx, "-input=false", "-no-color")
if err != nil {
t.Fatalf("failed to run terraform init: %s", err)
}
}

// MatchTerraformVersion returns true if terraform version matches a given constraints.
func MatchTerraformVersion(ctx context.Context, tf TerraformCLI, constraints string) (bool, error) {
tfVersionRaw, err := tf.Version(ctx)
Expand Down
9 changes: 9 additions & 0 deletions tfmigrate/state_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type StateAction interface {
// "mv <source> <destination>"
// "rm <addresses>...
// "import <address> <id>"
// "xmv <source> <destination>" // To support moves with wildcards
func NewStateActionFromString(cmdStr string) (StateAction, error) {
args, err := splitStateAction(cmdStr)
if err != nil {
Expand All @@ -44,6 +45,14 @@ func NewStateActionFromString(cmdStr string) (StateAction, error) {
dst := args[2]
action = NewStateMvAction(src, dst)

case "xmv":
if len(args) != 3 {
return nil, fmt.Errorf("state xmv action is invalid: %s", cmdStr)
}
src := args[1]
dst := args[2]
action = NewStateXMvAction(src, dst)

case "rm":
if len(args) < 2 {
return nil, fmt.Errorf("state rm action is invalid: %s", cmdStr)
Expand Down
27 changes: 27 additions & 0 deletions tfmigrate/state_action_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@ func TestNewStateActionFromString(t *testing.T) {
want: nil,
ok: false,
},
{
desc: "xmv action (valid)",
cmdStr: "xmv aws_security_group.* aws_security_group.$1",
want: &StateXMvAction{
source: "aws_security_group.*",
destination: "aws_security_group.$1",
},
ok: true,
},
{
desc: "xmv action (no args)",
cmdStr: "xmv",
want: nil,
ok: false,
},
{
desc: "xmv action (1 arg)",
cmdStr: "xmv aws_security_group.foo",
want: nil,
ok: false,
},
{
desc: "xmv action (3 args)",
cmdStr: "xmv aws_security_group.foo aws_security_group.foo2 ws_security_group.foo3",
want: nil,
ok: false,
},
{
desc: "rm action (valid)",
cmdStr: "rm aws_security_group.foo",
Expand Down
129 changes: 129 additions & 0 deletions tfmigrate/state_xmv_action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package tfmigrate

import (
"context"
"fmt"
"regexp"
"strings"

"github.com/minamijoyo/tfmigrate/tfexec"
)

// StateXMvAction implements the StateAction interface.
// StateXMvAction moves a resource from source address to destination address in
// the same tfstate file.
type StateXMvAction struct {
// source is a address of resource or module to be moved which can contain wildcards.
source string
// destination is a new address of resource or module to move which can contain placeholders.
destination string
}

var _ StateAction = (*StateXMvAction)(nil)

// NewStateMvAction returns a new StateXMvAction instance.
func NewStateXMvAction(source string, destination string) *StateXMvAction {
return &StateXMvAction{
source: source,
destination: destination,
}
}

// StateUpdate updates a given state and returns a new state.
// Source resources have wildcards wich should be matched against the tf state. Each occurrence will generate
// a move command.
func (a *StateXMvAction) StateUpdate(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (*tfexec.State, error) {
stateMvActions, err := a.generateMvActions(ctx, tf, state)
if err != nil {
return nil, err
}

for _, action := range stateMvActions {
state, err = action.StateUpdate(ctx, tf, state)
if err != nil {
return nil, err
}
}
return state, err
}

func (a *StateXMvAction) generateMvActions(ctx context.Context, tf tfexec.TerraformCLI, state *tfexec.State) (response []*StateMvAction, err error) {
stateList, err := tf.StateList(ctx, state, nil)
if err != nil {
return nil, err
}
return a.getStateMvActionsForStateList(stateList)
}

// When a wildcardChar is used in a path it should only match a single part of the path
// It can therefore not contain a dot(.), whitespace nor square brackets
const matchWildcardRegex = "([^\\]\\[\t\n\v\f\r ]*)"
const wildcardChar = "*"

func (a *StateXMvAction) nrOfWildcards() int {
return strings.Count(a.source, wildcardChar)
}

// Return regex pattern that matches the wildcard source and make sure characters are not treated as
// special meta characters.
func makeSourceMatchPattern(s string) string {
safeString := regexp.QuoteMeta(s)
quotedWildCardChar := regexp.QuoteMeta(wildcardChar)
return strings.ReplaceAll(safeString, quotedWildCardChar, matchWildcardRegex)
}

// Get a regex that will do matching based on the wildcard source that was given.
func makeSrcRegex(source string) (r *regexp.Regexp, err error) {
regPattern := makeSourceMatchPattern(source)
r, err = regexp.Compile(regPattern)
if err != nil {
return nil, fmt.Errorf("could not make pattern out of %s (%s) due to %s", source, regPattern, err.Error())
}
return
}

// Look into the state and find sources that match pattern with wild cards.
func (a *StateXMvAction) getMatchingSourcesFromState(stateList []string) (wildcardMatches []string, err error) {
r, e := makeSrcRegex(a.source)
if e != nil {
return nil, e
}
wildcardMatches = r.FindAllString(strings.Join(stateList, "\n"), -1)
if wildcardMatches == nil {
return []string{}, nil
}
return
}

// When you have the stateXMvAction with wildcards get the destination for a source
func (a *StateXMvAction) getDestinationForStateSrc(stateSource string) (destination string, err error) {
r, e := makeSrcRegex(a.source)
if e != nil {
return "", e
}
destination = r.ReplaceAllString(stateSource, a.destination)
return
}

// Get actions matching wildcard move actions based on the list of resources.
func (a *StateXMvAction) getStateMvActionsForStateList(stateList []string) (response []*StateMvAction, err error) {
if a.nrOfWildcards() == 0 {
response = make([]*StateMvAction, 1)
response[0] = NewStateMvAction(a.source, a.destination)
return response, nil
}
matchingSources, e := a.getMatchingSourcesFromState(stateList)
if e != nil {
return nil, e
}
response = make([]*StateMvAction, len(matchingSources))
for i, matchingSource := range matchingSources {
destination, e2 := a.getDestinationForStateSrc(matchingSource)
if e2 != nil {
return nil, e2
}
sma := StateMvAction{matchingSource, destination}
response[i] = &sma
}
return
}

0 comments on commit c27f2a1

Please sign in to comment.