Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add "remove" command #60

Merged
merged 1 commit into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 `remove` to remove 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
25 changes: 24 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.
- Remove 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.
- [Remove existing resources](#removing-existing-resources) from Terraform state.

They will be explained in the following sections.

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

1. Provider specific argument ID is wrong.

# Removing existing resources

Although `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. Even worse when multiple high-level resources are removed together.

Thus, `terravalet remove` parses a plan file and creates all the `state rm` commands for you.

1. Remove the resources in the Terraform configuration files.
2. Generate the plan file:
```
$ terraform -chdir=<the tf root> plan -no-color > remove-plan.txt
```
3. Run `terravalet remove`. As usual, this is a safe operation, since it will only generate a script file:
```
$ terravalet remove --up=remove.sh --plan=remove-plan.txt
```
4. Carefully examine the generated script file `remove.sh`!
5. Execute the scrip.
```
$ sh ./remove.sh
```

# Making a release

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

import (
"fmt"
"io"
"os"
"strings"
)

func doRemove(planPath string, upPath string) error {
planFile, err := os.Open(planPath)
if err != nil {
return fmt.Errorf("remove: opening the plan file: %s", err)
}
defer planFile.Close()

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

toCreate, toDestroy, err := parse(planFile)
if err != nil {
return fmt.Errorf("remove: parsing plan: %s", err)
}
if toCreate.Size() > 0 {
return fmt.Errorf("remove: plan contains resources to create: %v",
sorted(toCreate.List()))
}

var bld strings.Builder
generateRemoveScript(&bld, sorted(toDestroy.List()))
_, err = upFile.WriteString(bld.String())
if err != nil {
return fmt.Errorf("remove: writing script file: %s", err)
}

return nil
}

func generateRemoveScript(wr io.Writer, addresses []string) {
fmt.Fprintf(wr, `#! /bin/sh
# DO NOT EDIT. Generated by https://github.com/pix4D/terravalet
# This script will remove %d items.

set -e

`, len(addresses))
for _, addr := range addresses {
fmt.Fprintf(wr, "terraform state rm '%s'\n", addr)
}
fmt.Fprintln(wr)
}
29 changes: 29 additions & 0 deletions cmdremove_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package main

import (
"strings"
"testing"

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

func TestGenerateRemoveScript(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 remove 2 items.

set -e

terraform state rm 'module.a.b.c["foo"]'
terraform state rm 'module.a.b.d["foo.AN-"]'

`

generateRemoveScript(&bld, addresses)
qt.Assert(t, qt.Equals(bld.String(), want))
}
10 changes: 9 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ 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/rogpeppe/go-internal v1.11.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
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
golang.org/x/tools v0.1.12 // indirect
)
26 changes: 17 additions & 9 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
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=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
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=
22 changes: 18 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,27 @@ var (
)

func main() {
os.Exit(Main())
}

func Main() int {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why there is 2 main / Main functions?

Copy link
Contributor Author

@marco-m-pix4d marco-m-pix4d Jan 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iAmoric

To understand, it helps to keep in mind that Go is strongly typed. This makes it easy to follow the flow.

A Go program wants a main function with name and signature main(). That is, it takes nothing and returns nothing. (This is not the same as None in Python or nil in Go, it is really nothing).

Then we have Main() int, which returns an integer. This could have been called anything. We call it Main as convention, because it is doing everything the main() is doing.

Why we added it? Because of

     1  func TestMain(m *testing.M) {
     2      // The commands map holds the set of command names, each with an associated
     3      // run function which should return the code to pass to os.Exit.
     4      // When [testscript.Run] is called, these commands are installed as regular
     5      // commands in the shell path, so can be invoked with "exec".
     6      os.Exit(testscript.RunMain(m, map[string]func() int{
     7          "terravalet": Main,
     8      }))
     9  }

See line 7 ? it calls this Main() and, see line 6, it is using its return code for os.Exit.

The power of testscript, introduced in this PR, is that when it sees line 7, it also compiles the test executable. Said in another way, it allows to drive integration tests directly from Go code with the standard Go tooling (go test), ensuring that dependencies are built before the tests are run.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. So if I summarize roughly, you need Main(), which returns an integer, because you need a return value when using TestMain but main() returns nothing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@iAmoric exactly.

Then we could ask ourselves why main() returns nothing. There are reasons, rooted in the fact that not all OSes are Unix. But this is another story :-)

if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
return 1
}
return 0
}

type args struct {
type Args struct {
Rename *RenameCmd `arg:"subcommand:rename" help:"rename resources in the same root environment"`
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"`
Remove *RemoveCmd `arg:"subcommand:remove" help:"remove resources"`
Version *struct{} `arg:"subcommand:version" help:"show version"`
}

func (args) Description() string {
func (Args) Description() string {
return "terravalet - helps with advanced Terraform operations\n"
}

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

type RemoveCmd struct {
Up string `arg:"required" help:"path of the up script to generate (NNN_TITLE.up.sh)"`
Plan string `arg:"required" help:"path to to the output of 'terraform plan -no-color'"`
}

func run() error {
var args args
var args Args

parser := arg.MustParse(&args)
if parser.Subcommand() == nil {
Expand All @@ -86,6 +97,9 @@ func run() error {
case args.Import != nil:
cmd := args.Import
return doImport(cmd.Up, cmd.Down, cmd.SrcPlanPath, cmd.ResourceDefs)
case args.Remove != nil:
cmd := args.Remove
return doRemove(cmd.Plan, cmd.Up)
case args.Version != nil:
fmt.Println("terravalet", fullVersion)
return nil
Expand Down
29 changes: 29 additions & 0 deletions terravalet_script_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// This file runs tests using the 'testscript' package.
// To understand, see:
// - https://github.com/rogpeppe/go-internal
// - https://bitfieldconsulting.com/golang/test-scripts

package main

import (
"os"
"testing"

"github.com/rogpeppe/go-internal/testscript"
)

func TestMain(m *testing.M) {
// The commands map holds the set of command names, each with an associated
// run function which should return the code to pass to os.Exit.
// When [testscript.Run] is called, these commands are installed as regular
// commands in the shell path, so can be invoked with "exec".
os.Exit(testscript.RunMain(m, map[string]func() int{
"terravalet": Main,
}))
}

func TestScript(t *testing.T) {
testscript.Run(t, testscript.Params{
Dir: "testdata/script",
})
}
31 changes: 31 additions & 0 deletions testdata/remove/01_plan.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy

Terraform will perform the following actions:

# module.github.github_branch_default.default["foo-c"] will be destroyed
# (because key ["foo-c"] is not in for_each map)
- resource "github_branch_default" "default" {

# module.github.github_repository.repos["foo-c"] will be destroyed
# (because key ["foo-c"] is not in for_each map)
- resource "github_repository" "repos" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo-c.AN-"] will be destroyed
# (because key ["foo-c.AN-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo-c.CV-"] will be destroyed
# (because key ["foo-c.CV-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo-c.OPF-"] will be destroyed
# (because key ["foo-c.OPF-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_collaborators.repo_collaborators["foo-c"] will be destroyed
# (because key ["foo-c"] is not in for_each map)
- resource "github_repository_collaborators" "repo_collaborators" {

Plan: 0 to add, 0 to change, 6 to destroy.
18 changes: 18 additions & 0 deletions testdata/remove/01_up.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#! /bin/sh
# DO NOT EDIT. Generated by https://github.com/pix4D/terravalet
#
# This script will detach 6 items.

set -e

terraform state rm 'module.github.github_branch_default.default["crane-camera-ccqc"]'

terraform state rm 'module.github.github_repository.repos["crane-camera-ccqc"]'

terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["crane-camera-ccqc.AN-"]'

terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["crane-camera-ccqc.CV-"]'

terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["crane-camera-ccqc.OPF-"]'

terraform state rm 'module.github.github_repository_collaborators.repo_collaborators["crane-camera-ccqc"]'
50 changes: 50 additions & 0 deletions testdata/script/cmd-remove.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
exec terravalet remove --up=foo_up.sh --plan=detach.plan.txt
! stderr .
cmp foo_up.sh foo_up.sh.want

-- foo_up.sh.want --
#! /bin/sh
# DO NOT EDIT. Generated by https://github.com/pix4D/terravalet
# This script will remove 6 items.

set -e

terraform state rm 'module.github.github_branch_default.default["foo"]'
terraform state rm 'module.github.github_repository.repos["foo"]'
terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["foo.AN-"]'
terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["foo.CV-"]'
terraform state rm 'module.github.github_repository_autolink_reference.repo_autolinks["foo.OPF-"]'
terraform state rm 'module.github.github_repository_collaborators.repo_collaborators["foo"]'

-- detach.plan.txt --
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy

Terraform will perform the following actions:

# module.github.github_branch_default.default["foo"] will be destroyed
# (because key ["foo"] is not in for_each map)
- resource "github_branch_default" "default" {

# module.github.github_repository.repos["foo"] will be destroyed
# (because key ["foo"] is not in for_each map)
- resource "github_repository" "repos" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo.AN-"] will be destroyed
# (because key ["foo.AN-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo.CV-"] will be destroyed
# (because key ["foo.CV-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_autolink_reference.repo_autolinks["foo.OPF-"] will be destroyed
# (because key ["foo.OPF-"] is not in for_each map)
- resource "github_repository_autolink_reference" "repo_autolinks" {

# module.github.github_repository_collaborators.repo_collaborators["foo"] will be destroyed
# (because key ["foo"] is not in for_each map)
- resource "github_repository_collaborators" "repo_collaborators" {

Plan: 0 to add, 0 to change, 6 to destroy.
Loading