Skip to content

Commit

Permalink
add basic "detach" command
Browse files Browse the repository at this point in the history
  • Loading branch information
marco-m-pix4d committed Jan 29, 2024
1 parent ce7bc1f commit 1d716ac
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 11 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

#### New

- New command `detach` to detach resources from the Terraform state. See the README for details.
- The script generated by `terravalet import` now prints each command before executing it. This helps to understand which command is being executed.

### Changes
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ A tool to help with advanced, low-level [Terraform](https://www.terraform.io/) o
- Rename resources within the same Terraform state, with optional fuzzy match.
- Move resources from one Terraform state to another.
- Import existing resources into Terraform state.
- Detach existing resources from Terraform state.

**DISCLAIMER Manipulating Terraform state is inherently dangerous. It is your responsibility to be careful and ensure you UNDERSTAND what you are doing**.

Expand Down Expand Up @@ -57,11 +58,12 @@ After the creation of Terravalet, Terraform introduced the `moved` block, which

## Usage

There are three modes of operation:
Terravalet supports multiple operations:

- [Rename resources](#rename-resources-within-the-same-state) within the same Terraform state, with optional fuzzy match.
- [Move resources](#-move-resources-from-one-state-to-another) from one Terraform state to another.
- [Import existing resources](#-import-existing-resources) into Terraform state.
- [Detach existing resources]() from Terraform state.

They will be explained in the following sections.

Expand Down Expand Up @@ -399,6 +401,26 @@ NON ignorable errors:

1. Provider specific argument ID is wrong.

# Detach existing resources

The reason of existence of this command is in the same spirit of `terravalet import`: it is true that `terraform state rm` allows to remove _individual_ resources, but when a real-world resource is composed of multiple terraform resources, using `terraform state rm` becomes tedious and error-prone.

Thus, `terravalet detach` creates all the `state rm` commands for you.

1. Generate the state file in JSON format:
```
$ terraform -chdir=<the tf root> show -no-color -json > pre-detach.json
```
2. Run terravalet detach. As usual, this is a safe operation, since it will only generate a script file:
```
$ terravalet detach --up=detach_up.sh --state=pre-detach.json --resource=NAME
```
3. Validate the generated script file `detach_up.sh`!
4. Execute:
```
$ sh ./detach_up.sh
```

# Making a release

## Setup
Expand Down
99 changes: 99 additions & 0 deletions cmddetach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package main

import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
)

func doDetach(statePath string, upPath string, resource string) error {
stateFile, err := os.Open(statePath)
if err != nil {
return fmt.Errorf("detach: opening the state file: %s", err)
}
defer stateFile.Close()

upFile, err := os.Create(upPath)
if err != nil {
return fmt.Errorf("detach: creating the up file: %s", err)
}
defer upFile.Close()

addresses, err := matchResources(stateFile, resource)
if err != nil {
return fmt.Errorf("detach: finding matches: %s", err)
}

var bld strings.Builder
generateDetachScript(&bld, addresses)
_, err = upFile.WriteString(bld.String())
if err != nil {
return fmt.Errorf("detach: writing script file: %s", err)
}

return nil
}

// TerraformState is a subset of the fields in the output of
// terraform show -no-color -json
type TerraformState struct {
FormatVersion string `json:"format_version"`
TerraformVersion string `json:"terraform_version"`
Values struct {
RootModule struct {
ChildModules []struct {
Resources []struct {
Address string `json:"address"`
Index string `json:"index"`
} `json:"resources"`
} `json:"child_modules"`
} `json:"root_module"`
} `json:"values"`
}

func matchResources(rd io.Reader, resource string) ([]string, error) {
dec := json.NewDecoder(rd)
var state TerraformState
if err := dec.Decode(&state); err != nil {
return nil, err
}

var addresses []string
for _, child := range state.Values.RootModule.ChildModules {
for _, res := range child.Resources {
if matchResource(res.Index, resource) {
addresses = append(addresses, res.Address)
}
}
}
return addresses, nil
}

func matchResource(index, resource string) bool {
if index == resource {
return true
}
after, found := strings.CutPrefix(index, resource)
if !found {
return false
}
if strings.IndexAny(after, ".:") == 0 {
return true
}
return false
}

func generateDetachScript(wr io.Writer, addresses []string) {
fmt.Fprintf(wr, `#! /bin/sh
# DO NOT EDIT. Generated by https://github.com/pix4D/terravalet
#
# This script will detach %d items.
set -e
`, len(addresses))
for _, addr := range addresses {
fmt.Fprintf(wr, "\nterraform state rm '%s'\n", addr)
}
}
128 changes: 128 additions & 0 deletions cmddetach_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package main

import (
"os"
"strings"
"testing"

"github.com/go-quicktest/qt"
)

func TestMatchResourceSuccess(t *testing.T) {
type testCase struct {
name string
index string
resource string
}

test := func(t *testing.T, tc testCase) {
qt.Assert(t, qt.IsTrue(matchResource(tc.index, tc.resource)))
}

testCases := []testCase{
{
name: "exact match",
index: "foo",
resource: "foo",
},
{
name: "dot after match",
index: "foo.AN-",
resource: "foo",
},
{
// FIXME this currently passes. Maybe we should err on the side of
// caution and change the signature to return an error instead, since
// we are not expecting this case???
name: "FIXME multiple dots after match",
index: "foo.banana.AN-",
resource: "foo",
},
{
name: "colon after match",
index: "foo:master",
resource: "foo",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { test(t, tc) })
}
}

func TestMatchResourceFailure(t *testing.T) {
type testCase struct {
name string
index string
resource string
}

test := func(t *testing.T, tc testCase) {
qt.Assert(t, qt.IsFalse(matchResource(tc.index, tc.resource)),
qt.Commentf("index=%s resource=%s", tc.index, tc.resource))
}

testCases := []testCase{
{
name: "plain no match",
index: "foo",
resource: "bar",
},
{
name: "prefix match is not enough",
index: "foobar",
resource: "foo",
},
{
name: "wrong separator after prefix",
index: "foo_master",
resource: "foo",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) { test(t, tc) })
}
}

func TestMatchResources(t *testing.T) {
fi, err := os.Open("testdata/detach/01_pre_detach.json")
qt.Assert(t, qt.IsNil(err))
defer fi.Close()

want := []string{
`module.a.b.c["foo"]`,
`module.a.b.d["foo.AN-"]`,
`module.a.b.e["foo:master*"]`,
`module.x.y.z["foo"]`,
`module.x.y.k["foo.AN-"]`,
`module.x.y.z.j["foo:master*"]`,
}

have, err := matchResources(fi, "foo")

qt.Assert(t, qt.IsNil(err))
qt.Assert(t, qt.DeepEquals(have, want))
}

func TestGenerateDetachScript(t *testing.T) {
addresses := []string{
`module.a.b.c["foo"]`,
`module.a.b.d["foo.AN-"]`,
}
var bld strings.Builder
want := `#! /bin/sh
# DO NOT EDIT. Generated by https://github.com/pix4D/terravalet
#
# This script will detach 2 items.
set -e
terraform state rm 'module.a.b.c["foo"]'
terraform state rm 'module.a.b.d["foo.AN-"]'
`

generateDetachScript(&bld, addresses)
qt.Assert(t, qt.Equals(bld.String(), want))
}
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ go 1.21
require (
github.com/alexflint/go-arg v1.4.3
github.com/dexyk/stringosim v0.0.0-20170922105913-9d0b3e91a842
github.com/go-quicktest/qt v1.101.0
github.com/google/go-cmp v0.6.0
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e
)

require github.com/alexflint/go-scalar v1.2.0 // indirect
require (
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
)
22 changes: 13 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,32 +1,36 @@
github.com/alexflint/go-arg v1.4.2 h1:lDWZAXxpAnZUq4qwb86p/3rIJJ2Li81EoMbTMujhVa0=
github.com/alexflint/go-arg v1.4.2/go.mod h1:9iRbDxne7LcR/GSvEr7ma++GLpdIU1zrghf2y2768kM=
github.com/alexflint/go-arg v1.4.3 h1:9rwwEBpMXfKQKceuZfYcwuc/7YY7tWJbFsgG5cAU/uo=
github.com/alexflint/go-arg v1.4.3/go.mod h1:3PZ/wp/8HuqRZMUUgu7I+e1qcpUbvmS258mRXkFH4IA=
github.com/alexflint/go-scalar v1.0.0/go.mod h1:GpHzbCOZXEKMEcygYQ5n/aa4Aq84zbxjy3MxYW0gjYw=
github.com/alexflint/go-scalar v1.1.0 h1:aaAouLLzI9TChcPXotr6gUhq+Scr8rl0P9P4PnltbhM=
github.com/alexflint/go-scalar v1.1.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dexyk/stringosim v0.0.0-20170922105913-9d0b3e91a842 h1:FWXGhOthNyZKdK0YVyDrkg5dCXOfKvexcRG37U1v6AQ=
github.com/dexyk/stringosim v0.0.0-20170922105913-9d0b3e91a842/go.mod h1:PfVoEMbmPGFArz22/wIefW9CzuQhdnE+C9ikEzJvb9Q=
github.com/fatih/set v0.2.1 h1:nn2CaJyknWE/6txyUDGwysr3G5QC6xWB/PtVjPBbeaA=
github.com/fatih/set v0.2.1/go.mod h1:+RKtMCH+favT2+3YecHGxcc0b4KyVWA1QWWJUs4E0CI=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e h1:7q6NSFZDeGfvvtIRwBrU/aegEYJYmvev0cHAwo17zZQ=
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e/go.mod h1:DkpGd78rljTxKAnTDPFqXSGxvETQnJyuSOQwsHycqfs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
10 changes: 10 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type args struct {
MoveAfter *MoveAfterCmd `arg:"subcommand:move-after" help:"move resources from one root environment to AFTER another"`
MoveBefore *MoveBeforeCmd `arg:"subcommand:move-before" help:"move resources from one root environment to BEFORE another"`
Import *ImportCmd `arg:"subcommand:import" help:"import resources generated out-of-band of Terraform"`
Detach *DetachCmd `arg:"subcommand:detach" help:"detach resources from Terraform state"`
Version *struct{} `arg:"subcommand:version" help:"show version"`
}

Expand Down Expand Up @@ -64,6 +65,12 @@ type ImportCmd struct {
SrcPlanPath string `arg:"--src-plan,required" help:"path to the SRC terraform plan in JSON format"`
}

type DetachCmd struct {
Up string `arg:"required" help:"path of the up script to generate (NNN_TITLE.up.sh)"`
State string `arg:"required" help:"path to to the output of 'terraform show -no-color -json'""`

Check failure on line 70 in main.go

View workflow job for this annotation

GitHub Actions / all (1.21.x, ubuntu-latest, v3.34.0)

structtag: struct field tag `arg:"required" help:"path to to the output of 'terraform show -no-color -json'""` not compatible with reflect.StructTag.Get: key:"value" pairs not separated by spaces (govet)
Resource string `arg:"--resource,required" help:"name of the high-level resource to detach (will match multiple terraform resources)"`
}

func run() error {
var args args

Expand All @@ -86,6 +93,9 @@ func run() error {
case args.Import != nil:
cmd := args.Import
return doImport(cmd.Up, cmd.Down, cmd.SrcPlanPath, cmd.ResourceDefs)
case args.Detach != nil:
cmd := args.Detach
return doDetach(cmd.State, cmd.Up, cmd.Resource)
case args.Version != nil:
fmt.Println("terravalet", fullVersion)
return nil
Expand Down
Loading

0 comments on commit 1d716ac

Please sign in to comment.