From 90472307375dec93f1da52a255df7e9cfdcc2fa7 Mon Sep 17 00:00:00 2001 From: Masayuki Morita Date: Wed, 5 Feb 2025 11:29:58 +0900 Subject: [PATCH] Add attribute replace command In addition to the attribute mv command, add a new attribute replace command. This command not only renames the key of an attribute but also sets the value of the attribute. This could be done by combining attribute mv and attribute set, but doing it with a single command would be more convenient. This is because in dependency upgrade scenarios, where attribute A is deprecated and use B instead, it is often the case that not only the name of the attribute changes but also its value. For example, Terraform v1.10 introduced DynamoDB-free S3-native state locking in the s3 backend, and the upcoming Terraform v1.11 will start deprecating the old dynamodb_table attribute and recommend using the new use_lockfile. It's easy to rewrite a few files by hand, but it could be tedious if you have hundreds of backend configurations across many repositories. While the above is the initial motivation for implementing this feature, it is not hard to imagine that the real-world use cases go beyond this. As with the attribute mv command, a forked version of hclwrite is used to implement this feature. So, we need to wait for the upstream patch to be merged before we merge this implementation. --- README.md | 13 +- cmd/attribute.go | 42 ++++++- cmd/attribute_test.go | 79 +++++++++++++ editor/filter_attribute_replace.go | 62 ++++++++++ editor/filter_attribute_replace_test.go | 151 ++++++++++++++++++++++++ 5 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 editor/filter_attribute_replace.go create mode 100644 editor/filter_attribute_replace_test.go diff --git a/README.md b/README.md index 3ddaf81..0ea1b2a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - Schemaless: No dependency on specific HCL application binary or schema - Support HCL2 (not HCL1) - Available operations: - - attribute append / get / mv / rm / set + - attribute append / get / mv / replace / rm / set - block append / get / list / mv / new / rm - body get - fmt @@ -84,6 +84,7 @@ Available Commands: append Append attribute get Get attribute mv Move attribute (Rename attribute key) + replace Replace both the name and value of attribute rm Remove attribute set Set attribute @@ -133,6 +134,16 @@ resource "foo" "bar" { } ``` +``` +$ cat tmp/attr.hcl | hcledit attribute replace resource.foo.bar.nested.attr2 attr3 '"val3"' +resource "foo" "bar" { + attr1 = "val1" + nested { + attr3 = "val3" + } +} +``` + ``` $ cat tmp/attr.hcl | hcledit attribute rm resource.foo.bar.attr1 resource "foo" "bar" { diff --git a/cmd/attribute.go b/cmd/attribute.go index 3a60b73..7f95eaf 100644 --- a/cmd/attribute.go +++ b/cmd/attribute.go @@ -26,6 +26,7 @@ func newAttributeCmd() *cobra.Command { newAttributeGetCmd(), newAttributeSetCmd(), newAttributeMvCmd(), + newAttributeReplaceCmd(), newAttributeRmCmd(), newAttributeAppendCmd(), ) @@ -80,9 +81,9 @@ Arguments: ADDRESS An address of attribute to set. VALUE A new value of attribute. The value is set literally, even if references or expressions. - Thus, if you want to set a string literal "hoge", be sure to + Thus, if you want to set a string literal "foo", be sure to escape double quotes so that they are not discarded by your shell. - e.g.) hcledit attribute set aaa.bbb.ccc '"hoge"' + e.g.) hcledit attribute set aaa.bbb.ccc '"foo"' `, RunE: runAttributeSetCmd, } @@ -151,6 +152,43 @@ func runAttributeMvCmd(cmd *cobra.Command, args []string) error { return c.Edit(file, update, filter) } +func newAttributeReplaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "replace
", + Short: "Replace both the name and value of attribute", + Long: `Replace both the name and value of matched attribute at a given address + +Arguments: + ADDRESS An address of attribute to be replaced. + NAME A new name (key) of attribute. + VALUE A new value of attribute. + The value is set literally, even if references or expressions. + Thus, if you want to set a string literal "bar", be sure to + escape double quotes so that they are not discarded by your shell. + e.g.) hcledit attribute replace aaa.bbb.ccc foo '"bar"' +`, + RunE: runAttributeReplaceCmd, + } + + return cmd +} + +func runAttributeReplaceCmd(cmd *cobra.Command, args []string) error { + if len(args) != 3 { + return fmt.Errorf("expected 3 argument, but got %d arguments", len(args)) + } + + address := args[0] + name := args[1] + value := args[2] + file := viper.GetString("file") + update := viper.GetBool("update") + + filter := editor.NewAttributeReplaceFilter(address, name, value) + c := newDefaultClient(cmd) + return c.Edit(file, update, filter) +} + func runAttributeRmCmd(cmd *cobra.Command, args []string) error { if len(args) != 1 { return fmt.Errorf("expected 1 argument, but got %d arguments", len(args)) diff --git a/cmd/attribute_test.go b/cmd/attribute_test.go index 835c8c0..06aa4f6 100644 --- a/cmd/attribute_test.go +++ b/cmd/attribute_test.go @@ -260,6 +260,85 @@ resource "foo" "bar" { } } +func TestAttributeReplace(t *testing.T) { + src := `terraform { + backend "s3" { + region = "ap-northeast-1" + bucket = "my-s3lock-test" + key = "dir1/terraform.tfstate" + dynamodb_table = "tflock" + profile = "foo" + } +} +` + + cases := []struct { + name string + args []string + ok bool + want string + }{ + { + name: "simple", + args: []string{"terraform.backend.s3.dynamodb_table", "use_lockfile", "true"}, + ok: true, + want: `terraform { + backend "s3" { + region = "ap-northeast-1" + bucket = "my-s3lock-test" + key = "dir1/terraform.tfstate" + use_lockfile = true + profile = "foo" + } +} +`, + }, + { + name: "no match", + args: []string{"terraform.backend.s3.foo_table", "use_lockfile", "true"}, + ok: true, + want: src, + }, + { + name: "duplicated", + args: []string{"terraform.backend.s3.dynamodb_table", "profile", "true"}, + ok: false, + want: "", + }, + { + name: "no args", + args: []string{}, + ok: false, + want: "", + }, + { + name: "1 arg", + args: []string{"foo"}, + ok: false, + want: "", + }, + { + name: "2 args", + args: []string{"foo", "bar"}, + ok: false, + want: "", + }, + { + name: "too many args", + args: []string{"foo", "bar", "baz", "qux"}, + ok: false, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cmd := newMockCmd(newAttributeReplaceCmd(), src) + assertMockCmd(t, cmd, tc.args, tc.ok, tc.want) + }) + } +} + func TestAttributeRm(t *testing.T) { src := `locals { service = "hoge" diff --git a/editor/filter_attribute_replace.go b/editor/filter_attribute_replace.go new file mode 100644 index 0000000..7f12198 --- /dev/null +++ b/editor/filter_attribute_replace.go @@ -0,0 +1,62 @@ +package editor + +import ( + "fmt" + + "github.com/hashicorp/hcl/v2/hclwrite" +) + +// AttributeReplaceFilter is a filter implementation for replacing attribute. +type AttributeReplaceFilter struct { + address string + name string + value string +} + +var _ Filter = (*AttributeReplaceFilter)(nil) + +// NewAttributeReplaceFilter creates a new instance of AttributeReplaceFilter. +func NewAttributeReplaceFilter(address string, name string, value string) Filter { + return &AttributeReplaceFilter{ + address: address, + name: name, + value: value, + } +} + +// Filter reads HCL and replaces both the name and value of matched an +// attribute at a given address. +func (f *AttributeReplaceFilter) Filter(inFile *hclwrite.File) (*hclwrite.File, error) { + attr, body, err := findAttribute(inFile.Body(), f.address) + if err != nil { + return nil, err + } + + if attr != nil { + _, fromAttributeName, err := parseAttributeAddress(f.address) + if err != nil { + return nil, err + } + toAttributeName := f.name + + // The Body.RenameAttribute() returns false if fromName does not exist or + // toName already exists. However, here, we want to return an error only + // if toName already exists, so we check it ourselves. + toAttr := body.GetAttribute(toAttributeName) + if toAttr != nil { + return nil, fmt.Errorf("attribute already exists: %s", toAttributeName) + } + + _ = body.RenameAttribute(fromAttributeName, toAttributeName) + + // To delegate expression parsing to the hclwrite parser, + // We build a new expression and set back to the attribute by tokens. + expr, err := buildExpression(toAttributeName, f.value) + if err != nil { + return nil, err + } + body.SetAttributeRaw(toAttributeName, expr.BuildTokens(nil)) + } + + return inFile, nil +} diff --git a/editor/filter_attribute_replace_test.go b/editor/filter_attribute_replace_test.go new file mode 100644 index 0000000..8791883 --- /dev/null +++ b/editor/filter_attribute_replace_test.go @@ -0,0 +1,151 @@ +package editor + +import ( + "testing" +) + +func TestAttributeReplaceFilter(t *testing.T) { + cases := []struct { + name string + src string + address string + toName string + toValue string + ok bool + want string + }{ + { + name: "simple", + src: ` +a0 = v0 +a1 = v1 +`, + address: "a0", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a2 = v2 +a1 = v1 +`, + }, + { + name: "with comments", + src: ` +# before attr +a0 = "v0" # inline +a1 = "v1" +`, + address: "a0", + toName: "a2", + toValue: `"v2"`, + ok: true, + want: ` +# before attr +a2 = "v2" # inline +a1 = "v1" +`, + }, + { + name: "attribute in block", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b1.l1.a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a2 = v2 +} +`, + }, + { + name: "not found", + src: ` +a0 = v0 +`, + address: "a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +`, + }, + { + name: "attribute not found in block", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b1.l1.a2", + toName: "a3", + toValue: "v3", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + }, + { + name: "block not found", + src: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + address: "b2.l1.a1", + toName: "a2", + toValue: "v2", + ok: true, + want: ` +a0 = v0 +b1 "l1" { + a1 = v1 +} +`, + }, + { + name: "attribute already exists", + src: ` +a0 = v0 +a1 = v1 +`, + address: "a0", + toName: "a1", + toValue: "v2", + ok: false, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + o := NewEditOperator(NewAttributeReplaceFilter(tc.address, tc.toName, tc.toValue)) + output, err := o.Apply([]byte(tc.src), "test") + if tc.ok && err != nil { + t.Fatalf("unexpected err = %s", err) + } + + got := string(output) + if !tc.ok && err == nil { + t.Fatalf("expected to return an error, but no error, outStream: \n%s", got) + } + + if got != tc.want { + t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) + } + }) + } +}