diff --git a/CHANGELOG.md b/CHANGELOG.md index e843de0..1cc9e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index dd21b49..6f91324 100644 --- a/README.md +++ b/README.md @@ -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**. @@ -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. @@ -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= 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 diff --git a/cmddetach.go b/cmddetach.go new file mode 100644 index 0000000..db9717c --- /dev/null +++ b/cmddetach.go @@ -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) + } +} diff --git a/cmddetach_test.go b/cmddetach_test.go new file mode 100644 index 0000000..951657d --- /dev/null +++ b/cmddetach_test.go @@ -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)) +} diff --git a/go.mod b/go.mod index c70874e..a6e5504 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 03b80f0..5328b84 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,9 @@ -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= @@ -14,19 +11,26 @@ github.com/dexyk/stringosim v0.0.0-20170922105913-9d0b3e91a842 h1:FWXGhOthNyZKdK 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= diff --git a/main.go b/main.go index 44380a1..81b81de 100644 --- a/main.go +++ b/main.go @@ -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"` } @@ -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'""` + 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 @@ -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 diff --git a/testdata/detach/01_pre_detach.json b/testdata/detach/01_pre_detach.json new file mode 100644 index 0000000..07f5821 --- /dev/null +++ b/testdata/detach/01_pre_detach.json @@ -0,0 +1,50 @@ +{ + "format_version": "1.0", + "terraform_version": "1.5.7", + "values": { + "root_module": { + "child_modules": [ + { + "resources": [ + { + "address": "module.a.b.c[\"foo\"]", + "index": "foo" + }, + { + "address": "module.a.b.d[\"foo.AN-\"]", + "index": "foo.AN-" + }, + { + "address": "module.a.b.e[\"foo:master*\"]", + "index": "foo:master*" + }, + { + "address": "module.a.b.c[\"foo_wrong\"]", + "index": "foo_wrong" + } + ] + }, + { + "resources": [ + { + "address": "module.x.y.z[\"foo\"]", + "index": "foo" + }, + { + "address": "module.x.y.k[\"foo.AN-\"]", + "index": "foo.AN-" + }, + { + "address": "module.x.y.z.j[\"foo:master*\"]", + "index": "foo:master*" + }, + { + "address": "module.x.y.z[\"foo_wrong\"]", + "index": "foo_wrong" + } + ] + } + ] + } + } +}