diff --git a/cmd/terramate/cli/generate_test.go b/cmd/terramate/cli/generate_test.go index 3b3ace466..1a837690c 100644 --- a/cmd/terramate/cli/generate_test.go +++ b/cmd/terramate/cli/generate_test.go @@ -16,7 +16,9 @@ package cli_test import ( "io/fs" + "os" "path/filepath" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -25,6 +27,7 @@ import ( "github.com/mineiros-io/terramate/generate" "github.com/mineiros-io/terramate/hcl" "github.com/mineiros-io/terramate/test" + "github.com/mineiros-io/terramate/test/hclwrite" "github.com/mineiros-io/terramate/test/sandbox" ) @@ -828,19 +831,12 @@ globals { for _, want := range tcase.want.stacks { stack := s.StackEntry(want.relpath) - got := string(stack.ReadGeneratedTf()) + got := string(stack.ReadGeneratedBackendCfg()) - wantcode := generate.CodeHeader + want.code - - if diff := cmp.Diff(wantcode, got); diff != "" { - t.Error("generated code doesn't match expectation") - t.Errorf("want:\n%q", wantcode) - t.Errorf("got:\n%q", got) - t.Fatalf("diff:\n%s", diff) - } + assertGeneratedHCLEquals(t, got, want.code) } - generatedFiles := listGeneratedTfFiles(t, s.RootDir()) + generatedFiles := findFiles(t, s.RootDir(), generate.TfFilename) if len(generatedFiles) != len(tcase.want.stacks) { t.Errorf("generated %d files, but wanted %d", len(generatedFiles), len(tcase.want.stacks)) @@ -849,12 +845,380 @@ globals { } }) } +} + +func TestLocalsGeneration(t *testing.T) { + // The test approach for locals generation already uses a new test package + // to help creating the HCL files instead of using plain raw strings. + // There are some trade-offs involved and we are assessing how to approach + // the testing, hence for now it is inconsistent between locals generation + // and backend configuration generation. The idea is to converge to a single + // approach ASAP. + type ( + // hclblock represents an HCL block that will be appended on + // the file path. + hclblock struct { + path string + add *hclwrite.Block + } + want struct { + res runResult + stacksLocals map[string]*hclwrite.Block + } + testcase struct { + name string + layout []string + configs []hclblock + want want + } + ) + + exportAsLocals := func(builders ...hclwrite.BlockBuilder) *hclwrite.Block { + return hclwrite.BuildBlock("export_as_locals", builders...) + } + globals := func(builders ...hclwrite.BlockBuilder) *hclwrite.Block { + return hclwrite.BuildBlock("globals", builders...) + } + locals := func(builders ...hclwrite.BlockBuilder) *hclwrite.Block { + return hclwrite.BuildBlock("locals", builders...) + } + expr := hclwrite.Expression + str := hclwrite.String + number := hclwrite.NumberInt + boolean := hclwrite.Boolean + tcases := []testcase{ + { + name: "no stacks no exported locals", + layout: []string{}, + }, + { + name: "single stacks no exported locals", + layout: []string{"s:stack"}, + }, + { + name: "two stacks no exported locals", + layout: []string{ + "s:stacks/stack-1", + "s:stacks/stack-2", + }, + }, + { + name: "single stack with its own exported locals using own globals", + layout: []string{"s:stack"}, + configs: []hclblock{ + { + path: "/stack", + add: globals( + str("some_string", "string"), + number("some_number", 777), + boolean("some_bool", true), + ), + }, + { + path: "/stack", + add: exportAsLocals( + expr("string_local", "global.some_string"), + expr("number_local", "global.some_number"), + expr("bool_local", "global.some_bool"), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stack": locals( + str("string_local", "string"), + number("number_local", 777), + boolean("bool_local", true), + ), + }, + }, + }, + { + name: "single stack exporting metadata using functions", + layout: []string{"s:stack"}, + configs: []hclblock{ + { + path: "/stack", + add: exportAsLocals( + expr("funny_path", `replace(terramate.path, "/", "@")`), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stack": locals( + str("funny_path", "@stack"), + ), + }, + }, + }, + { + name: "single stack referencing undefined global fails", + layout: []string{"s:stack"}, + configs: []hclblock{ + { + path: "/stack", + add: exportAsLocals( + expr("undefined", "global.undefined"), + ), + }, + }, + want: want{ + res: runResult{ + IgnoreStderr: true, + Error: generate.ErrExportingLocals, + }, + }, + }, + { + name: "multiple stack with exported locals being overridden", + layout: []string{ + "s:stacks/stack-1", + "s:stacks/stack-2", + }, + configs: []hclblock{ + { + path: "/", + add: globals( + str("attr1", "value1"), + str("attr2", "value2"), + str("attr3", "value3"), + ), + }, + { + path: "/", + add: exportAsLocals( + expr("string", "global.attr1"), + ), + }, + { + path: "/stacks", + add: exportAsLocals( + expr("string", "global.attr2"), + ), + }, + { + path: "/stacks/stack-1", + add: exportAsLocals( + expr("string", "global.attr3"), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stacks/stack-1": locals( + str("string", "value3"), + ), + "/stacks/stack-2": locals( + str("string", "value2"), + ), + }, + }, + }, + { + name: "single stack with exported locals and globals from parent dirs", + layout: []string{"s:stacks/stack"}, + configs: []hclblock{ + { + path: "/", + add: globals( + str("str", "string"), + ), + }, + { + path: "/", + add: exportAsLocals( + expr("num_local", "global.num"), + expr("path_local", "terramate.path"), + ), + }, + { + path: "/stacks", + add: globals( + number("num", 666), + ), + }, + { + path: "/stacks", + add: exportAsLocals( + expr("str_local", "global.str"), + expr("name_local", "terramate.name"), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stacks/stack": locals( + str("name_local", "stack"), + str("path_local", "/stacks/stack"), + str("str_local", "string"), + number("num_local", 666), + ), + }, + }, + }, + { + name: "multiple stacks with exported locals and globals from parent dirs", + layout: []string{ + "s:stacks/stack-1", + "s:stacks/stack-2", + }, + configs: []hclblock{ + { + path: "/", + add: globals( + str("str", "string"), + ), + }, + { + path: "/", + add: exportAsLocals( + expr("num_local", "global.num"), + expr("path_local", "terramate.path"), + ), + }, + { + path: "/stacks", + add: globals( + number("num", 666), + ), + }, + { + path: "/stacks", + add: exportAsLocals( + expr("str_local", "global.str"), + expr("name_local", "terramate.name"), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stacks/stack-1": locals( + str("name_local", "stack-1"), + str("path_local", "/stacks/stack-1"), + str("str_local", "string"), + number("num_local", 666), + ), + "/stacks/stack-2": locals( + str("name_local", "stack-2"), + str("path_local", "/stacks/stack-2"), + str("str_local", "string"), + number("num_local", 666), + ), + }, + }, + }, + { + name: "multiple stacks with specific exported locals and globals from parent dirs", + layout: []string{ + "s:stacks/stack-1", + "s:stacks/stack-2", + }, + configs: []hclblock{ + { + path: "/", + add: globals( + str("str", "string"), + ), + }, + { + path: "/stacks", + add: globals( + number("num", 666), + ), + }, + { + path: "/stacks/stack-1", + add: exportAsLocals( + expr("str_local", "global.str"), + expr("name_local", "terramate.name"), + ), + }, + { + path: "/stacks/stack-2", + add: exportAsLocals( + expr("num_local", "global.num"), + expr("path_local", "terramate.path"), + ), + }, + }, + want: want{ + stacksLocals: map[string]*hclwrite.Block{ + "/stacks/stack-1": locals( + str("name_local", "stack-1"), + str("str_local", "string"), + ), + "/stacks/stack-2": locals( + str("path_local", "/stacks/stack-2"), + number("num_local", 666), + ), + }, + }, + }, + } + + for _, tcase := range tcases { + t.Run(tcase.name, func(t *testing.T) { + s := sandbox.New(t) + s.BuildTree(tcase.layout) + + for _, cfg := range tcase.configs { + path := filepath.Join(s.RootDir(), cfg.path) + test.AppendFile(t, path, config.Filename, cfg.add.String()) + } + + ts := newCLI(t, s.RootDir()) + assertRunResult(t, ts.run("generate"), tcase.want.res) + + for stackPath, wantHCLBlock := range tcase.want.stacksLocals { + stackRelPath := stackPath[1:] + want := wantHCLBlock.String() + stack := s.StackEntry(stackRelPath) + got := string(stack.ReadGeneratedLocals()) + + assertGeneratedHCLEquals(t, got, want) + } + + generatedFiles := findFiles(t, s.RootDir(), generate.LocalsFilename) + + if len(generatedFiles) != len(tcase.want.stacksLocals) { + t.Errorf("generated %d locals files, but wanted %d", len(generatedFiles), len(tcase.want.stacksLocals)) + for _, genfilepath := range generatedFiles { + genfile, err := os.ReadFile(genfilepath) + assert.NoError(t, err, "reading generated file %s", genfilepath) + t.Errorf("generated file:\n%s", genfile) + } + t.Errorf("generated files: %v", generatedFiles) + t.Fatalf("wanted generated files: %#v", tcase.want.stacksLocals) + } + }) + } +} + +func assertGeneratedHCLEquals(t *testing.T, got string, want string) { + t.Helper() + + // Not 100% sure it is a good idea to compare HCL as strings, formatting + // issues can be annoying and can make tests brittle + // (but we test the formatting too... so maybe that is good ? =P) + const trimmedChars = "\n " + + want = generate.CodeHeader + want + got = strings.Trim(got, trimmedChars) + want = strings.Trim(want, trimmedChars) + + if diff := cmp.Diff(want, got); diff != "" { + t.Error("generated code doesn't match expectation") + t.Errorf("want:\n%q", want) + t.Errorf("got:\n%q", got) + t.Fatalf("diff:\n%s", diff) + } } -func listGeneratedTfFiles(t *testing.T, rootdir string) []string { +func findFiles(t *testing.T, rootdir string, filename string) []string { // Go's glob is not recursive, so can't just glob for generated filenames - var generatedTfFiles []string + var found []string err := filepath.Walk(rootdir, func(path string, info fs.FileInfo, err error) error { if err != nil { @@ -864,12 +1228,12 @@ func listGeneratedTfFiles(t *testing.T, rootdir string) []string { return nil } - if info.Name() == generate.TfFilename { - generatedTfFiles = append(generatedTfFiles, path) + if info.Name() == filename { + found = append(found, path) } return nil }) assert.NoError(t, err) - return generatedTfFiles + return found } diff --git a/exported_locals_test.go b/exported_locals_test.go index 5acff61ec..8d9cf38a0 100644 --- a/exported_locals_test.go +++ b/exported_locals_test.go @@ -41,9 +41,7 @@ func TestExportAsLocals(t *testing.T) { } ) - // Usually in Go names are cammel case, but on this case - // we want it to be as close to original HCL as possible (DSL). - export_as_locals := func(builders ...hclwrite.BlockBuilder) *hclwrite.Block { + exportAsLocals := func(builders ...hclwrite.BlockBuilder) *hclwrite.Block { return hclwrite.BuildBlock("export_as_locals", builders...) } // locals is used only as a syntatic sugar to build testcase.want @@ -90,7 +88,7 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/stack", - add: export_as_locals( + add: exportAsLocals( expr("string_local", "global.some_string"), expr("number_local", "global.some_number"), expr("bool_local", "global.some_bool"), @@ -111,7 +109,7 @@ func TestExportAsLocals(t *testing.T) { blocks: []block{ { path: "/stack", - add: export_as_locals( + add: exportAsLocals( expr("funny_path", `replace(terramate.path, "/", "@")`), ), }, @@ -139,19 +137,19 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/", - add: export_as_locals( + add: exportAsLocals( expr("string", "global.attr1"), ), }, { path: "/stacks", - add: export_as_locals( + add: exportAsLocals( expr("string", "global.attr2"), ), }, { path: "/stacks/stack-1", - add: export_as_locals( + add: exportAsLocals( expr("string", "global.attr3"), ), }, @@ -177,7 +175,7 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/", - add: export_as_locals( + add: exportAsLocals( expr("num_local", "global.num"), expr("path_local", "terramate.path"), ), @@ -190,7 +188,7 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/stacks", - add: export_as_locals( + add: exportAsLocals( expr("str_local", "global.str"), expr("name_local", "terramate.name"), ), @@ -220,7 +218,7 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/", - add: export_as_locals( + add: exportAsLocals( expr("num_local", "global.num"), expr("path_local", "terramate.path"), ), @@ -233,7 +231,7 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/stacks", - add: export_as_locals( + add: exportAsLocals( expr("str_local", "global.str"), expr("name_local", "terramate.name"), ), @@ -275,14 +273,14 @@ func TestExportAsLocals(t *testing.T) { }, { path: "/stacks/stack-1", - add: export_as_locals( + add: exportAsLocals( expr("str_local", "global.str"), expr("name_local", "terramate.name"), ), }, { path: "/stacks/stack-2", - add: export_as_locals( + add: exportAsLocals( expr("num_local", "global.num"), expr("path_local", "terramate.path"), ), @@ -305,13 +303,13 @@ func TestExportAsLocals(t *testing.T) { blocks: []block{ { path: "/stack", - add: export_as_locals( + add: exportAsLocals( expr("some_local", "terramate.name"), ), }, { path: "/stack", - add: export_as_locals( + add: exportAsLocals( expr("some_local", "terramate.name"), expr("some_other_local", "terramate.name"), ), @@ -346,7 +344,7 @@ func TestExportAsLocals(t *testing.T) { want, ok := wantExportAsLocals[stackMetadata.Path] if !ok { - want = export_as_locals() + want = exportAsLocals() } delete(wantExportAsLocals, stackMetadata.Path) diff --git a/generate/generate.go b/generate/generate.go index 6b98b9092..103dc3194 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -35,13 +35,17 @@ const ( // TfFilename is the name of the terramate generated tf file. TfFilename = "_gen_terramate.tm.tf" + // LocalsFilename is the name of the terramate generated tf file for exported locals. + LocalsFilename = "_gen_locals.tm.tf" + // CodeHeader is the header added on all generated files. CodeHeader = "// GENERATED BY TERRAMATE: DO NOT EDIT\n\n" ) const ( - ErrBackendConfig errutil.Error = "generating backend config" - ErrLoadingGlobals errutil.Error = "loading globals" + ErrBackendConfig errutil.Error = "generating backend config" + ErrExportingLocals errutil.Error = "generating locals" + ErrLoadingGlobals errutil.Error = "loading globals" ) // Do will walk all the directories starting from project's root @@ -102,19 +106,14 @@ func Do(root string) error { continue } - tfcode, err := generateStackConfig(root, stackpath, evalctx) - if err != nil { - err = errutil.Chain(ErrBackendConfig, err) - errs = append(errs, fmt.Errorf("stack %q: %w", stackpath, err)) - continue + if err := generateStackBackendConfig(root, stackpath, evalctx); err != nil { + errs = append(errs, fmt.Errorf("stack %q: generating backend config: %w", stackpath, err)) } - if tfcode == nil { - continue + if err := generateStackLocals(root, stackpath, stackMetadata, globals); err != nil { + err = errutil.Chain(ErrExportingLocals, err) + errs = append(errs, fmt.Errorf("stack %q: %w", stackpath, err)) } - - genfile := filepath.Join(stackpath, TfFilename) - errs = append(errs, os.WriteFile(genfile, tfcode, 0666)) } // FIXME(katcipis): errutil.Chain produces a very hard to read string representation @@ -128,7 +127,58 @@ func Do(root string) error { return nil } -func generateStackConfig(root string, configdir string, evalctx *eval.Context) ([]byte, error) { +func generateStackLocals( + rootdir string, + stackpath string, + metadata terramate.StackMetadata, + globals *terramate.Globals, +) error { + stackLocals, err := terramate.LoadStackExportedLocals(rootdir, metadata, globals) + if err != nil { + return err + } + + localsAttrs := stackLocals.Attributes() + if len(localsAttrs) == 0 { + return nil + } + + sortedAttrs := make([]string, 0, len(localsAttrs)) + for name := range localsAttrs { + sortedAttrs = append(sortedAttrs, name) + } + // Avoid generating code with random attr order (map iteration is random) + sort.Strings(sortedAttrs) + + gen := hclwrite.NewEmptyFile() + body := gen.Body() + localsBlock := body.AppendNewBlock("locals", nil) + localsBody := localsBlock.Body() + + for _, name := range sortedAttrs { + localsBody.SetAttributeValue(name, localsAttrs[name]) + } + + tfcode := addHeader(gen.Bytes()) + genfile := filepath.Join(stackpath, LocalsFilename) + return os.WriteFile(genfile, tfcode, 0666) +} + +func generateStackBackendConfig(root string, stackpath string, evalctx *eval.Context) error { + tfcode, err := loadStackBackendConfig(root, stackpath, evalctx) + if err != nil { + return fmt.Errorf("%w: %v", ErrBackendConfig, err) + } + + if len(tfcode) == 0 { + return nil + } + + genfile := filepath.Join(stackpath, TfFilename) + return os.WriteFile(genfile, tfcode, 0666) +} + +func loadStackBackendConfig(root string, configdir string, evalctx *eval.Context) ([]byte, error) { if !strings.HasPrefix(configdir, root) { // check if we are outside of project's root, time to stop return nil, nil @@ -136,7 +186,7 @@ func generateStackConfig(root string, configdir string, evalctx *eval.Context) ( configfile := filepath.Join(configdir, config.Filename) if _, err := os.Stat(configfile); err != nil { - return generateStackConfig(root, filepath.Dir(configdir), evalctx) + return loadStackBackendConfig(root, filepath.Dir(configdir), evalctx) } config, err := os.ReadFile(configfile) @@ -151,7 +201,7 @@ func generateStackConfig(root string, configdir string, evalctx *eval.Context) ( parsed := parsedConfig.Terramate if parsed == nil || parsed.Backend == nil { - return generateStackConfig(root, filepath.Dir(configdir), evalctx) + return loadStackBackendConfig(root, filepath.Dir(configdir), evalctx) } gen := hclwrite.NewEmptyFile() @@ -168,6 +218,10 @@ func generateStackConfig(root string, configdir string, evalctx *eval.Context) ( return append([]byte(CodeHeader), gen.Bytes()...), nil } +func addHeader(code []byte) []byte { + return append([]byte(CodeHeader), code...) +} + func copyBody(target *hclwrite.Body, src *hclsyntax.Body, evalctx *eval.Context) error { if src == nil || target == nil { return nil diff --git a/test/sandbox/sandbox.go b/test/sandbox/sandbox.go index d5721a856..3136c5a2f 100644 --- a/test/sandbox/sandbox.go +++ b/test/sandbox/sandbox.go @@ -362,14 +362,22 @@ func (se StackEntry) WriteConfig(cfg hcl.Config) { se.DirEntry.CreateFile(config.Filename, out.String()) } -// ReadGeneratedTf will read code that was generated by terramate for this stack. +// ReadGeneratedBackendCfg will read code that was generated by terramate for this stack. // It will fail the test if there is no generated code available on the stack, // since it assumes generated code is expected to be there. -func (se StackEntry) ReadGeneratedTf() []byte { +func (se StackEntry) ReadGeneratedBackendCfg() []byte { se.t.Helper() return se.DirEntry.ReadFile(generate.TfFilename) } +// ReadGeneratedLocals will read code that was generated by terramate for this stack. +// It will fail the test if there is no generated code available on the stack, +// since it assumes generated code is expected to be there. +func (se StackEntry) ReadGeneratedLocals() []byte { + se.t.Helper() + return se.DirEntry.ReadFile(generate.LocalsFilename) +} + // Path returns the absolute path of the stack. func (se StackEntry) Path() string { return se.DirEntry.abspath