Skip to content

Commit

Permalink
Add attribute replace command
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
minamijoyo committed Feb 5, 2025
1 parent 1a64342 commit 4772d7c
Show file tree
Hide file tree
Showing 5 changed files with 344 additions and 3 deletions.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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" {
Expand Down
42 changes: 40 additions & 2 deletions cmd/attribute.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func newAttributeCmd() *cobra.Command {
newAttributeGetCmd(),
newAttributeSetCmd(),
newAttributeMvCmd(),
newAttributeReplaceCmd(),
newAttributeRmCmd(),
newAttributeAppendCmd(),
)
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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 <ADDRESS> <NAME> <VALUE>",
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))
Expand Down
79 changes: 79 additions & 0 deletions cmd/attribute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
62 changes: 62 additions & 0 deletions editor/filter_attribute_replace.go
Original file line number Diff line number Diff line change
@@ -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
}
151 changes: 151 additions & 0 deletions editor/filter_attribute_replace_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}

0 comments on commit 4772d7c

Please sign in to comment.