diff --git a/README.md b/README.md index f33938b..7c7415d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,20 @@ It is recommended to set up the following shell alias for handy `amd64` invocati alias terraform-amd64="TF_DEMUX_ARCH=amd64 terraform-demux" ``` +### Enhanced State Operations Control + +We highly encourage leveraging native Terraform refactoring blocks whenever feasible, provided your Terraform version supports them. In line with this, we've implemented stricter controls over state operations to enhance security and stability. It's important to note that state operations now require the `TF_DEMUX_ALLOW_STATE_COMMANDS` environment variable to be set for execution. + +Usage Details + +* For Terraform 1.1.0 and above: We recomment utilizing Terraform [moved](https://developer.hashicorp.com/terraform/language/modules/develop/refactoring) block instead `terraform state mv` command. + +* For Terraform 1.5.0 and above: We recomment utilizing Terraform [import](https://developer.hashicorp.com/terraform/language/import) block instead `terraform import` command. + +* For Terraform 1.7.0 and above: We recomment utilizing Terraform [removed](https://developer.hashicorp.com/terraform/language/resources/syntax) block instead `terraform state rm` command. + +However, if necessary, you can still utilize the Terraform CLI to manipulate states. Before proceeding, ensure to set the environment variable `TF_DEMUX_ALLOW_STATE_COMMANDS=true` to confirm your intent. + ### Logging Setting the `TF_DEMUX_LOG` environment variable to any non-empty value will cause `terraform-demux` to write out debug logs to `stderr`. diff --git a/go.mod b/go.mod index 184cc7e..4df68ff 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/etsy/terraform-demux go 1.21 require ( - github.com/Masterminds/semver/v3 v3.1.1 + github.com/Masterminds/semver/v3 v3.2.1 github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 github.com/hashicorp/terraform-config-inspect v0.0.0-20231204233900-a34142ec2a72 github.com/natefinch/atomic v1.0.1 diff --git a/go.sum b/go.sum index a9a1dd3..e7a3312 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc= -github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= diff --git a/internal/releaseapi/client.go b/internal/releaseapi/client.go index dbc077f..d9615c2 100644 --- a/internal/releaseapi/client.go +++ b/internal/releaseapi/client.go @@ -71,7 +71,7 @@ func (c *Client) ListReleases() (ReleaseIndex, error) { if err != nil { return releaseIndex, errors.Wrap(err, "could not send request for Terraform release index") } else if response.StatusCode != http.StatusOK { - return releaseIndex, errors.Errorf("error: unexpected status code '%s' in response", response.StatusCode) + return releaseIndex, errors.Errorf("error: unexpected status code '%d' in response", response.StatusCode) } if response.Header.Get(httpcache.XFromCache) != "" { @@ -133,7 +133,7 @@ func (c *Client) getReleaseCheckSums(release Release) (string, error) { } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return "", errors.Errorf("error: unexpected status code '%s' in response", response.StatusCode) + return "", errors.Errorf("error: unexpected status code '%d' in response", response.StatusCode) } bodyBytes, err := io.ReadAll(response.Body) @@ -245,7 +245,7 @@ func (c *Client) downloadReleaseArchive(build Build) (*os.File, int64, error) { defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nil, 0, errors.Errorf("unexpected status code '%s' in response", response.StatusCode) + return nil, 0, errors.Errorf("unexpected status code '%d' in response", response.StatusCode) } tmp, err := os.CreateTemp("", filepath.Base(build.URL)) diff --git a/internal/wrapper/checkargs.go b/internal/wrapper/checkargs.go new file mode 100644 index 0000000..1b5342b --- /dev/null +++ b/internal/wrapper/checkargs.go @@ -0,0 +1,63 @@ +package wrapper + +import ( + "fmt" + "os" + "strings" + + "github.com/Masterminds/semver/v3" +) + +func checkStateCommand(args []string, version *semver.Version) error { + versionImport, _ := semver.NewConstraint(">= 1.5.0") + versionMoved, _ := semver.NewConstraint(">= 1.1.0") + versionRemoved, _ := semver.NewConstraint(">= 1.7.0") + STATE_COMMAND_VAR := "TF_DEMUX_ALLOW_STATE_COMMANDS" + + errorMsg := func(command string, suggestion string) error { + return fmt.Errorf("refusing to execute '%s' command - use a '%s' configuration block instead, or set %s=true", command, suggestion, STATE_COMMAND_VAR) + } + + if allowStateCommand(STATE_COMMAND_VAR) { + return nil + } + + if checkArgsExists(args, "import") >= 0 && + versionImport.Check(version) { + return errorMsg("import", "import") + } + + if checkArgsExists(args, "state") >= 0 && + checkArgsExists(args, "mv") >= 0 && + versionMoved.Check(version) { + return errorMsg("state mv", "moved") + } + + if checkArgsExists(args, "state") >= 0 && + checkArgsExists(args, "rm") >= 0 && + versionRemoved.Check(version) { + return errorMsg("state rm", "removed") + } + + return nil +} + +func checkArgsExists(args []string, cmd string) int { + for i, arg := range args { + if arg == cmd { + return i + } + } + return -1 +} + +func allowStateCommand(envVarName string) bool { + validValues := []string{"1", "true", "yes"} + value := strings.ToLower(os.Getenv(envVarName)) + for _, valid := range validValues { + if value == valid { + return true + } + } + return false +} diff --git a/internal/wrapper/checkargs_test.go b/internal/wrapper/checkargs_test.go new file mode 100644 index 0000000..d9c6345 --- /dev/null +++ b/internal/wrapper/checkargs_test.go @@ -0,0 +1,81 @@ +package wrapper + +import ( + "os" + "testing" + + "github.com/Masterminds/semver/v3" +) + +func TestCheckStateCommand(t *testing.T) { + STATE_COMMAND_VAR := "TF_DEMUX_ALLOW_STATE_COMMANDS" + t.Run("Valid state import command with TF_DEMUX_ALLOW_STATE_COMMANDS on 1.5.0", func(t *testing.T) { + args := []string{"import", "--force"} + version, _ := semver.NewVersion("1.5.0") + os.Setenv(STATE_COMMAND_VAR, "true") + err := checkStateCommand(args, version) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Valid state import command without TF_DEMUX_ALLOW_STATE_COMMANDS on 1.4.7", func(t *testing.T) { + args := []string{"import"} + version, _ := semver.NewVersion("1.4.7") + os.Setenv(STATE_COMMAND_VAR, "true") + err := checkStateCommand(args, version) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) + + t.Run("Invalid state import command without TF_DEMUX_ALLOW_STATE_COMMANDS on 1.5.0", func(t *testing.T) { + args := []string{"import"} + version, _ := semver.NewVersion("1.6.0") + os.Setenv(STATE_COMMAND_VAR, "") + err := checkStateCommand(args, version) + if err == nil { + t.Errorf("Expected error, got: %v", err) + } + }) + + t.Run("Valid state mv command with TF_DEMUX_ALLOW_STATE_COMMANDS on 1.6.0", func(t *testing.T) { + args := []string{"state", "mv", "--force"} + version, _ := semver.NewVersion("1.6.0") + os.Setenv(STATE_COMMAND_VAR, "true") + err := checkStateCommand(args, version) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + }) +} + +func TestCheckArgsExists(t *testing.T) { + t.Run("Check 'import --force' command", func(t *testing.T) { + args := []string{"import", "--force"} + result := checkArgsExists(args, "import") + if result != 0 { + t.Errorf("Expected 0, got: %v", result) + } + result = checkArgsExists(args, "--force") + if result != 1 { + t.Errorf("Expected 1, got: %v", result) + } + }) + + t.Run("Check 'state moved' command", func(t *testing.T) { + args := []string{"state", "mv"} + result := checkArgsExists(args, "state") + if result != 0 { + t.Errorf("Expected 0, got: %v", result) + } + result = checkArgsExists(args, "mv") + if result != 1 { + t.Errorf("Expected 1, got: %v", result) + } + result = checkArgsExists(args, "--force") + if result != -1 { + t.Errorf("Expected -1, got: %v", result) + } + }) +} diff --git a/internal/wrapper/wrapper.go b/internal/wrapper/wrapper.go index 97607f1..dff0814 100644 --- a/internal/wrapper/wrapper.go +++ b/internal/wrapper/wrapper.go @@ -53,6 +53,10 @@ func RunTerraform(args []string, arch string) (int, error) { log.Printf("version '%s' matches all constraints", matchingRelease.Version) + if err := checkStateCommand(args, matchingRelease.Version); err != nil { + return 1, err + } + executablePath, err := client.DownloadRelease(matchingRelease, runtime.GOOS, arch) if err != nil {