From 27c06e79a0a2a73bb90672fc95ce1f574733f6d1 Mon Sep 17 00:00:00 2001 From: magodo Date: Wed, 14 Dec 2022 17:09:23 +0800 Subject: [PATCH] Module support - `-target` support resource address with module spec prefixed. E.g. `module.mod1.module.mod2[0].module.mod3["foo"].null_resource.test` - When no `-target` is specified, all managed resources in the state will generate their configs, including the ones resides in the nested modules. --- addr/addr.go | 118 ++++++++++++++++++- addr/addr_test.go | 206 +++++++++++++++++++++++++++++++++ main.go | 22 +--- tfadd/internal/state_to_tpl.go | 2 +- tfadd/option.go | 9 +- tfadd/tfadd_state.go | 102 ++++++++++------ 6 files changed, 390 insertions(+), 69 deletions(-) create mode 100644 addr/addr_test.go diff --git a/addr/addr.go b/addr/addr.go index fd7486f..5b7ad1d 100644 --- a/addr/addr.go +++ b/addr/addr.go @@ -2,18 +2,124 @@ package addr import ( "fmt" + "regexp" + "strconv" "strings" ) -type ResourceAddr struct { - Type string +type ModuleStep struct { Name string + + // At most one of below is not nil + Key *string + Index *int +} + +func (step ModuleStep) String() string { + out := "module." + step.Name + switch { + case step.Key != nil: + out += `["` + *step.Key + `"]` + case step.Index != nil: + out += `[` + strconv.Itoa(*step.Index) + `]` + } + return out +} + +type ModuleAddr []ModuleStep + +func (addr ModuleAddr) String() string { + var segs []string + for _, ms := range addr { + segs = append(segs, ms.String()) + } + if len(segs) == 0 { + return "" + } + return strings.Join(segs, ".") } -func ParseAddress(addr string) (*ResourceAddr, error) { +func ParseModuleAddr(addr string) (ModuleAddr, error) { segs := strings.Split(addr, ".") - if len(segs) != 2 { - return nil, fmt.Errorf("invalid resource address found: %s", addr) + if len(segs)%2 != 0 { + return nil, fmt.Errorf("invalid module address") + } + + var maddr ModuleAddr + p := regexp.MustCompile(`^([^\[\]]+)(\[(.+)\])?$`) + for i := 0; i < len(segs); i += 2 { + if segs[i] != "module" { + return nil, fmt.Errorf(`expect "module", got %q`, segs[i]) + } + moduleSeg := segs[i+1] + matches := p.FindStringSubmatch(moduleSeg) + if len(matches) == 0 { + return nil, fmt.Errorf("invalid module segment: %s", moduleSeg) + } + ms := ModuleStep{ + Name: matches[1], + } + if matches[3] == "" { + if matches[2] != "" { + return nil, fmt.Errorf("invalid module segment: %s", moduleSeg) + } + } else { + idxLit := matches[3] + if strings.HasPrefix(idxLit, `"`) && strings.HasSuffix(idxLit, `"`) { + key, err := strconv.Unquote(idxLit) + if err != nil { + return nil, fmt.Errorf("unquoting module key %s: %v", idxLit, err) + } + ms.Key = &key + } else { + idx, err := strconv.Atoi(idxLit) + if err != nil { + return nil, fmt.Errorf("converting module index to number %s: %v", idxLit, err) + } + ms.Index = &idx + } + } + maddr = append(maddr, ms) } - return &ResourceAddr{Type: segs[0], Name: segs[1]}, nil + return maddr, nil +} + +type ResourceAddr struct { + ModuleAddr ModuleAddr + Type string + Name string +} + +func (addr ResourceAddr) String() string { + raddr := addr.Type + "." + addr.Name + if moduleAddr := addr.ModuleAddr.String(); moduleAddr != "" { + raddr = moduleAddr + "." + raddr + } + return raddr +} + +func ParseResourceAddr(addr string) (*ResourceAddr, error) { + segs := strings.Split(addr, ".") + + if len(segs)%2 != 0 { + return nil, fmt.Errorf("invalid resource address") + } + + raddr := &ResourceAddr{ + Type: segs[len(segs)-2], + Name: segs[len(segs)-1], + } + + if len(segs) == 2 { + return raddr, nil + } + + maddr, err := ParseModuleAddr(strings.Join(segs[:len(segs)-2], ".")) + if err != nil { + return nil, err + } + + raddr.ModuleAddr = maddr + return raddr, nil + } diff --git a/addr/addr_test.go b/addr/addr_test.go new file mode 100644 index 0000000..ab60492 --- /dev/null +++ b/addr/addr_test.go @@ -0,0 +1,206 @@ +package addr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func ptr[T any](in T) *T { + return &in +} + +func TestParseModuleAddr(t *testing.T) { + cases := []struct { + name string + input string + addr ModuleAddr + err bool + }{ + { + name: "one module", + input: "module.mod1", + addr: []ModuleStep{ + { + Name: "mod1", + }, + }, + }, + { + name: "module instance (key)", + input: `module.mod1["foo"]`, + addr: []ModuleStep{ + { + Name: "mod1", + Key: ptr("foo"), + }, + }, + }, + { + name: "module instance (idx)", + input: `module.mod1[0]`, + addr: []ModuleStep{ + { + Name: "mod1", + Index: ptr(0), + }, + }, + }, + { + name: "nested module instance", + input: `module.mod1[0].module.mod2["foo"].module.mod3`, + addr: []ModuleStep{ + { + Name: "mod1", + Index: ptr(0), + }, + { + Name: "mod2", + Key: ptr("foo"), + }, + { + Name: "mod3", + }, + }, + }, + { + name: "invalid module", + input: "mod1", + err: true, + }, + { + name: "invalid module instance", + input: "module.mod1[]", + err: true, + }, + { + name: "invalid module instance key", + input: "module.mod1[xyz]", + err: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := ParseModuleAddr(tt.input) + if tt.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.addr, addr) + }) + } +} + +func TestParseResourceAddr(t *testing.T) { + cases := []struct { + name string + input string + addr ResourceAddr + err bool + }{ + { + name: "resource only", + input: "null_resource.test", + addr: ResourceAddr{ + Type: "null_resource", + Name: "test", + }, + }, + { + name: "resource with module", + input: "module.mod1.null_resource.test", + addr: ResourceAddr{ + ModuleAddr: []ModuleStep{ + { + Name: "mod1", + }, + }, + Type: "null_resource", + Name: "test", + }, + }, + { + name: "resource with module instance (key)", + input: `module.mod1["foo"].null_resource.test`, + addr: ResourceAddr{ + ModuleAddr: []ModuleStep{ + { + Name: "mod1", + Key: ptr("foo"), + }, + }, + Type: "null_resource", + Name: "test", + }, + }, + { + name: "resource with module instance (idx)", + input: `module.mod1[0].null_resource.test`, + addr: ResourceAddr{ + ModuleAddr: []ModuleStep{ + { + Name: "mod1", + Index: ptr(0), + }, + }, + Type: "null_resource", + Name: "test", + }, + }, + { + name: "resource with nested module instance", + input: `module.mod1[0].module.mod2["foo"].module.mod3.null_resource.test`, + addr: ResourceAddr{ + ModuleAddr: []ModuleStep{ + { + Name: "mod1", + Index: ptr(0), + }, + { + Name: "mod2", + Key: ptr("foo"), + }, + { + Name: "mod3", + }, + }, + Type: "null_resource", + Name: "test", + }, + }, + { + name: "invalid resource addr", + input: "null_resource", + err: true, + }, + { + name: "invalid resource addr with module", + input: "mod1.null_resource.test", + err: true, + }, + { + name: "invalid resource addr with module instance", + input: "module.mod1[].null_resource.test", + err: true, + }, + { + name: "invalid resource addr with module instance key", + input: "module.mod1[xyz].null_resource.test", + err: true, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + addr, err := ParseResourceAddr(tt.input) + if tt.err { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.addr, *addr) + }) + } +} diff --git a/main.go b/main.go index 361592a..ef70ecf 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,6 @@ import ( "github.com/hashicorp/hc-install/fs" "github.com/hashicorp/hc-install/product" "github.com/hashicorp/terraform-exec/tfexec" - "github.com/magodo/tfadd/addr" "github.com/magodo/tfadd/tfadd" "github.com/mitchellh/cli" ) @@ -63,28 +62,15 @@ Usage: tfadd [global options] state [options] Options: -full Output all non-computed properties in the generated config - -target=addr Only generate for the specified resource, can be specified multiple times + -target=addr Only generate for the specified resource ` return strings.TrimSpace(helpText) } -type targetFlag []string - -func (f *targetFlag) String() string { - return fmt.Sprint(*f) -} - -func (f *targetFlag) Set(value string) error { - *f = append(*f, value) - _, err := addr.ParseAddress(value) - return err -} - func (r *stateCommand) Run(args []string) int { - var targets targetFlag fset := defaultFlagSet("state") flagFull := fset.Bool("full", false, "Whether to generate all non-computed properties") - fset.Var(&targets, "target", "Only generate for the specified resource") + flagTarget := fset.String("target", "", "Only generate for the specified resource") if err := fset.Parse(args); err != nil { fmt.Fprintf(os.Stderr, err.Error()) return 1 @@ -105,8 +91,8 @@ func (r *stateCommand) Run(args []string) int { return 1 } opts := []tfadd.StateOption{tfadd.Full(*flagFull)} - for _, target := range targets { - opts = append(opts, tfadd.Target(target)) + if *flagTarget != "" { + opts = append(opts, tfadd.Target(*flagTarget)) } templates, err := tfadd.State(ctx, tf, opts...) if err != nil { diff --git a/tfadd/internal/state_to_tpl.go b/tfadd/internal/state_to_tpl.go index 66d7ee3..e15bdc5 100644 --- a/tfadd/internal/state_to_tpl.go +++ b/tfadd/internal/state_to_tpl.go @@ -15,7 +15,7 @@ import ( func StateToTpl(r *tfstate.StateResource, schema *tfjson.SchemaBlock) ([]byte, error) { var buf strings.Builder - addr, err := addr2.ParseAddress(r.Address) + addr, err := addr2.ParseResourceAddr(r.Address) if err != nil { return nil, fmt.Errorf("parsing resource address: %v", err) } diff --git a/tfadd/option.go b/tfadd/option.go index 53ebef6..429d7c8 100644 --- a/tfadd/option.go +++ b/tfadd/option.go @@ -26,14 +26,11 @@ type targetOption addr.ResourceAddr func Target(raddr string) targetOption { // Validation for the resource address is guaranteed in flag parsing. - addr, _ := addr.ParseAddress(raddr) + addr, _ := addr.ParseResourceAddr(raddr) return targetOption(*addr) } func (opt targetOption) configureState(cfg *stateConfig) { - raddr := addr.ResourceAddr(opt) - if !cfg.targetMap[raddr] { - cfg.targets = append(cfg.targets, raddr) - cfg.targetMap[raddr] = true - } + target := addr.ResourceAddr(opt) + cfg.target = &target } diff --git a/tfadd/tfadd_state.go b/tfadd/tfadd_state.go index 2c6e12b..962782b 100644 --- a/tfadd/tfadd_state.go +++ b/tfadd/tfadd_state.go @@ -19,18 +19,14 @@ type stateConfig struct { // Set via Full option. full bool - // Only generate for the specified one or more target addresses. + // Only generate for the specified target address. // Set via Target option. - targets []addr.ResourceAddr - targetMap map[addr.ResourceAddr]bool + target *addr.ResourceAddr } func defaultStateConfig() stateConfig { return stateConfig{ full: false, - - targets: []addr.ResourceAddr{}, - targetMap: map[addr.ResourceAddr]bool{}, } } @@ -56,62 +52,92 @@ func State(ctx context.Context, tf *tfexec.Terraform, opts ...StateOption) ([]by return nil, fmt.Errorf("from json state: %v", err) } - // templateMap is only used when -target is specified. - // It is mainly used caching the template and later sort it to the same order as the order in option. - templateMap := map[addr.ResourceAddr][]byte{} - hasTarget := len(cfg.targets) != 0 - - var errs error - templates := []byte{} - - for _, res := range state.Values.RootModule.Resources { - raddr := addr.ResourceAddr{Type: res.Type, Name: res.Name} - if hasTarget { - if !cfg.targetMap[raddr] { - continue - } - } + gen := func(pschs *tfjson.ProviderSchemas, res tfstate.StateResource, full bool) ([]byte, error) { if res.Mode != tfjson.ManagedResourceMode { - continue + return nil, nil } psch, ok := pschs.Schemas[res.ProviderName] if !ok { - continue + return nil, fmt.Errorf("no provider named %s found in provider schemas of current workspace", res.ProviderName) } rsch, ok := psch.ResourceSchemas[res.Type] if !ok { - continue + return nil, fmt.Errorf("no resource type %s found in provider's schema", res.Type) } - b, err := internal.StateToTpl(res, rsch.Block) + b, err := internal.StateToTpl(&res, rsch.Block) if err != nil { - errs = multierror.Append(errs, fmt.Errorf("generate template from state for %s: %v", res.Type, err)) + return nil, fmt.Errorf("generate template from state for %s: %v", res.Type, err) } - if !cfg.full { + if !full { sdkPsch, ok := sdkProviderSchemas[res.ProviderName] if !ok { - continue + return b, nil } sch, ok := sdkPsch.ResourceSchemas[res.Type] if !ok { - continue + return b, nil } b, err = internal.TuneTpl(*sch, b, res.Type) if err != nil { - errs = multierror.Append(errs, fmt.Errorf("tune template for %s: %v", res.Type, err)) + return nil, fmt.Errorf("tune template for %s: %v", res.Type, err) } } - if hasTarget { - templateMap[raddr] = b - } else { - templates = append(templates, b...) + return b, nil + } + + if cfg.target == nil { + var templates []byte + var errs error + var genForModule func(pschs *tfjson.ProviderSchemas, module tfstate.StateModule, full bool) + genForModule = func(pschs *tfjson.ProviderSchemas, module tfstate.StateModule, full bool) { + if module.Address != "" { + templates = append(templates, []byte("# "+module.Address+"\n")...) + } + for _, res := range module.Resources { + b, err := gen(pschs, *res, cfg.full) + if err != nil { + errs = multierror.Append(errs, err) + continue + } + if b == nil { + continue + } + templates = append(templates, b...) + } + for _, mod := range module.ChildModules { + genForModule(pschs, *mod, full) + } } + genForModule(pschs, *state.Values.RootModule, cfg.full) + return templates, errs } - if hasTarget { - for _, raddr := range cfg.targets { - templates = append(templates, templateMap[raddr]...) + module := state.Values.RootModule + for i := 0; i < len(cfg.target.ModuleAddr); i++ { + moduleAddr := addr.ModuleAddr(cfg.target.ModuleAddr[:i+1]).String() + var found bool + for _, cm := range module.ChildModules { + if cm.Address == moduleAddr { + module = cm + found = true + break + } + } + if !found { + return nil, fmt.Errorf("failed to find module %s", moduleAddr) } } - return templates, errs + var targetResource *tfstate.StateResource + for _, res := range module.Resources { + if res.Type != cfg.target.Type || res.Name != cfg.target.Name { + continue + } + targetResource = res + break + } + if targetResource == nil { + return nil, fmt.Errorf("can't find target resource") + } + return gen(pschs, *targetResource, cfg.full) }