diff --git a/autodetect/project.go b/autodetect/project.go index e512727..192ceb6 100644 --- a/autodetect/project.go +++ b/autodetect/project.go @@ -10,6 +10,11 @@ type Project struct { Env string Type types.ProjectType Metadata map[string]string + // RawOptions is the plugin-authored parse-options blob (always JSON) for this project, carried + // from the IdentifyEnvironments RPC. It is only set when a plugin returned environments + // authoritatively; otherwise it is nil and generation sideloads the locally-derived var files + // into the plugins blob instead. + RawOptions []byte } type RootModule struct { diff --git a/autodetect/search.go b/autodetect/search.go index dd266ca..70bf5f6 100644 --- a/autodetect/search.go +++ b/autodetect/search.go @@ -384,6 +384,11 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(envDeps), Env: escapeStringForYAML(env.Name), Type: projectType, + // the plugin authored this environment's parse-options blob (var files, workspace, + // etc.); carry it through so generation persists it verbatim under plugins.. + // Only this authoritative-with-environments branch has a plugin blob - the others + // sideload config's locally-derived var files at generation time instead. + RawOptions: env.RawOptions, }) // record the directories this environment claims so they aren't also emitted as diff --git a/config.go b/config.go index 3cf5797..311f8a8 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package config import ( "crypto/sha1" // nolint:gosec "encoding/hex" + "encoding/json" "fmt" "sort" "strings" @@ -50,6 +51,11 @@ type Config struct { Terraform Terraform `yaml:"terraform,omitempty"` Projects []*Project `yaml:"projects"` CDK cdk.Config `yaml:"cdk,omitempty"` + // Plugins holds repo-level, plugin-specific defaults keyed by the consuming plugin name (e.g. + // "terraform"). They are deep-merged into each project's matching Plugins entry during + // normalization, with the per-project values winning. Opaque to config - the plugin that matches + // the key owns the schema. + Plugins map[string]map[string]any `yaml:"plugins,omitempty"` } type ConfigWithAutodetect struct { @@ -58,6 +64,9 @@ type ConfigWithAutodetect struct { } type Terraform struct { + // Deprecated: set the regex source map per-project under plugins.terraform.regexSourceMap instead. + // Still read for backwards compatibility (folded into each terraform project's plugins blob on + // load); no longer written by config generation. SourceMap []TerraformRegexSource `yaml:"source_map,omitempty"` Defaults TerraformDefaults `yaml:"defaults,omitempty"` } @@ -68,6 +77,11 @@ type TerraformDefaults struct { Workspace string `yaml:"workspace,omitempty"` } +// TerraformCloud is the authoring surface for Terraform Cloud / Enterprise. It is NOT deprecated and +// NOT folded into the plugins blob: Host/Org/Workspace are caller-sourced and read outside the plugin +// (the caller passes them to the plugin via GenericOptions.TerraformCloudConfiguration, and reads the +// host to build the terraform-cloud credential set). The token is a credential supplied here or via +// INFRACOST_TERRAFORM_CLOUD_TOKEN and travels via GenericOptions.CredentialSets. type TerraformCloud struct { Host string `yaml:"host,omitempty"` Org string `yaml:"org,omitempty"` @@ -105,9 +119,17 @@ type ProjectWithLegacySupport struct { type ProjectTerraform struct { Cloud TerraformCloud `yaml:"cloud,omitempty"` Spacelift Spacelift `yaml:"spacelift,omitempty"` - Vars map[string]any `yaml:"vars,omitempty"` - VarFiles []string `yaml:"var_files,omitempty"` - Workspace string `yaml:"workspace,omitempty"` + // Deprecated: set terraform variables under plugins.terraform.vars instead. Still read for + // backwards compatibility (folded into the plugins blob on load); no longer written by generation. + Vars map[string]any `yaml:"vars,omitempty"` + // Deprecated: set var files under plugins.terraform.tfVarsFiles instead. Still read for backwards + // compatibility (folded into the plugins blob on load); no longer written by generation. + VarFiles []string `yaml:"var_files,omitempty"` + // Workspace is the terraform workspace to select. It is NOT part of the plugins blob: it is a + // caller-sourced runtime option passed to the plugin via GenericOptions.Workspace, and is also read + // outside the plugin (the project's workspace in the cost output / provider input). It stays a + // top-level field and is still written by generation. + Workspace string `yaml:"workspace,omitempty"` } // Project defines a specific terraform project config. This can be used @@ -127,6 +149,10 @@ type Project struct { DependencyPaths []string `yaml:"dependency_paths,omitempty"` AWS ProjectAWSConfig `yaml:"aws,omitempty"` CDKSynthError string `yaml:"cdk_synth_error,omitempty"` + // Plugins holds plugin-specific parse options keyed by the consuming plugin name (e.g. + // "terraform"). It is the persisted raw_options blob - autogenerated during identification and + // user-editable. Opaque to config; the plugin that matches the key owns the schema. + Plugins map[string]map[string]any `yaml:"plugins,omitempty"` } // ConfigSHA computes a deterministic SHA for the project config based on its @@ -161,6 +187,16 @@ func (p *Project) ConfigSHA() string { sort.Strings(orderEnv) inputs = append(inputs, orderEnv...) + // Include the plugin blobs so the sha reflects options that now live only under plugins.: + // generated configs no longer write the deprecated terraform.* fields, so without this the sha + // would miss var-file/workspace changes for them. json.Marshal sorts map keys, so this is + // deterministic. + if len(p.Plugins) > 0 { + if b, err := json.Marshal(p.Plugins); err == nil { + inputs = append(inputs, string(b)) + } + } + // nolint:gosec gen := sha1.New() _, _ = gen.Write([]byte(strings.Join(inputs, "|"))) @@ -168,9 +204,17 @@ func (p *Project) ConfigSHA() string { } type ProjectAWSConfig struct { - Region string `yaml:"region,omitempty"` - StackID string `yaml:"stack_id,omitempty"` + // Deprecated: set under plugins.cloudformation.awsContext.region instead. Still read for backwards + // compatibility (folded into the plugins blob on load); no longer written by generation. + Region string `yaml:"region,omitempty"` + // Deprecated: set under plugins.cloudformation.awsContext.stackId instead. Folded into the plugins + // blob on load; no longer written by generation. + StackID string `yaml:"stack_id,omitempty"` + // Deprecated: set under plugins.cloudformation.awsContext.stackName instead. Folded into the + // plugins blob on load; no longer written by generation. StackName string `yaml:"stack_name,omitempty"` + // Deprecated: set under plugins.cloudformation.awsContext.accountId instead. Folded into the + // plugins blob on load; no longer written by generation. AccountID string `yaml:"account_id,omitempty"` } diff --git a/config_file.go b/config_file.go index 977cc76..03e0724 100644 --- a/config_file.go +++ b/config_file.go @@ -111,6 +111,28 @@ func (c *Config) normalize() error { if project.Terraform.Spacelift.APIKey.Secret == "" && c.Terraform.Defaults.Spacelift.APIKey.Secret != "" { project.Terraform.Spacelift.APIKey.Secret = c.Terraform.Defaults.Spacelift.APIKey.Secret } + + // Merge repo-level plugin defaults (c.Plugins) under the per-project blob, so the per-project + // value wins over the global default. Schema-agnostic deep merge - config never interprets the + // blob. See mergePluginBlobs in config_plugins.go. + for name, defaults := range c.Plugins { + if len(defaults) == 0 { + continue + } + merged := mergePluginBlobs(defaults, project.Plugins[name]) + if len(merged) > 0 { + if project.Plugins == nil { + project.Plugins = make(map[string]map[string]any, len(c.Plugins)) + } + project.Plugins[name] = merged + } + } + + // Fold the deprecated structured terraform.* / aws.* fields over the plugins blob so consumers + // can read only the new plugins. style. A hand-written deprecated field wins over the + // blob (generation no longer emits them). Running this after the merge above gives the overall + // precedence deprecated > per-project blob > repo-level default. See config_plugins_compat.go. + project.foldDeprecatedFieldsIntoPlugins(c.Terraform.SourceMap) } // first sort by path + env to ensure duplicate name resolution uses the same path for each iteration diff --git a/config_plugins.go b/config_plugins.go new file mode 100644 index 0000000..d2ca451 --- /dev/null +++ b/config_plugins.go @@ -0,0 +1,111 @@ +package config + +// This file holds the plumbing for the generic, plugin-keyed `plugins.` config section: the +// map from a project type to the plugin that consumes its options, and the schema-agnostic deep +// merge used to combine repo-level defaults with per-project blobs. Neither understands a plugin's +// option schema - that stays opaque to config. The backwards-compatibility fold of the deprecated +// structured fields lives separately in config_plugins_compat.go. + +// pluginKeyForType returns the config-file plugins. key for a project type: the name of the +// plugin that consumes that project's parse options. It is the identity for every type except the +// CDK variants, which are parsed by the cloudformation plugin and so share its options blob. +// +// Keying by consuming plugin (rather than project type) is what lets generation and the deprecated +// fold agree on where a project's blob lives - the two only diverge for CDK. +func pluginKeyForType(t ProjectType) string { + switch t { + case ProjectTypeCDKTypeScript, ProjectTypeCDKJavaScript, ProjectTypeCDKPython: + return string(ProjectTypeCloudFormation) + default: + return string(t) + } +} + +// isTerraformFamily reports whether a project type is parsed with the terraform options schema +// (terraform, terragrunt and cisco stacks all share it), and so folds/sideloads into a +// terraform-shaped blob. +func isTerraformFamily(t ProjectType) bool { + switch t { + case ProjectTypeTerraform, ProjectTypeTerragrunt, ProjectTypeCiscoStacks: + return true + default: + return false + } +} + +// mergePluginBlobs deep-merges a repo-level plugin defaults map (global) with a per-project plugin +// map (project) and returns the result. It is schema-agnostic: nested maps merge recursively, and +// every other value (scalars and lists alike) is atomic with the per-project value winning. Neither +// input is mutated. +func mergePluginBlobs(global, project map[string]any) map[string]any { + if m, ok := deepMergeAny(global, project).(map[string]any); ok { + return m + } + return nil +} + +// deepMergeAny merges project onto global following the plugin-merge convention (see +// mergePluginBlobs). Only maps merge recursively; every other value - scalars AND lists - is treated +// atomically, with the per-project value winning when it is set. +func deepMergeAny(global, project any) any { + gm, gok := global.(map[string]any) + pm, pok := project.(map[string]any) + if gok && pok { + out := make(map[string]any, len(gm)+len(pm)) + for k, gv := range gm { + // deep-copy global branches: global is the shared repo-level defaults map reused for + // every project, so aliasing its nested maps/slices into a project's result would let a + // later mutation of one project's blob corrupt the global and every sibling project. + out[k] = deepCopyAny(gv) + } + for k, pv := range pm { + if gv, ok := gm[k]; ok { + out[k] = deepMergeAny(gv, pv) + } else { + out[k] = pv + } + } + return out + } + + // Non-map values (scalars and lists) are atomic: the per-project value wins, unless the project + // didn't set anything, in which case fall back to a copy of the global default. Lists are + // deliberately NOT concatenated - a project's list replaces the global one. + if project == nil { + return deepCopyAny(global) + } + return project +} + +// deepCopyAny returns a deep copy of a decoded YAML/JSON value (nested maps, slices, scalars) so a +// merged result never aliases the shared global-defaults map. +func deepCopyAny(v any) any { + switch t := v.(type) { + case map[string]any: + out := make(map[string]any, len(t)) + for k, val := range t { + out[k] = deepCopyAny(val) + } + return out + case []any: + out := make([]any, len(t)) + for i, val := range t { + out[i] = deepCopyAny(val) + } + return out + default: + return v + } +} + +// setPluginOption stores a single key on a project's plugins. blob, allocating the maps as +// needed. It is used by both the sideload (generation) and the deprecated fold (read). +func (p *Project) setPluginOption(key, option string, value any) { + if p.Plugins == nil { + p.Plugins = map[string]map[string]any{} + } + if p.Plugins[key] == nil { + p.Plugins[key] = map[string]any{} + } + p.Plugins[key][option] = value +} diff --git a/config_plugins_compat.go b/config_plugins_compat.go new file mode 100644 index 0000000..7dec855 --- /dev/null +++ b/config_plugins_compat.go @@ -0,0 +1,90 @@ +package config + +// BACKWARDS-COMPATIBILITY SHIM. +// +// This file folds the deprecated, structured fields (per-project terraform.* / aws.* and the +// repo-level terraform.source_map) into the generic plugins. blob when a config file is read, +// so downstream consumers can read only the new plugins style regardless of which style the file was +// authored in. Old and hand-written configs keep working unchanged. +// +// It deliberately encodes a subset of each plugin's option schema (the JSON keys the plugin +// consumes). That is isolated here so it can be deleted wholesale once the deprecated fields are +// removed - delete this file and the foldDeprecatedFieldsIntoPlugins call in normalize() then. +// +// Precedence: the deprecated structured fields WIN over any existing blob value for the same key. A +// set structured field is hand-written (generation no longer emits them), so it is the most +// authoritative source. foldDeprecatedFieldsIntoPlugins therefore runs after the repo-level plugin +// defaults have been merged into the per-project blob, giving the overall order +// deprecated > per-project blob > repo-level default. + +// foldDeprecatedFieldsIntoPlugins populates project.Plugins from the deprecated structured fields. +// repoSourceMap is the repo-level terraform.source_map, folded into each terraform-family project. +func (p *Project) foldDeprecatedFieldsIntoPlugins(repoSourceMap []TerraformRegexSource) { + switch { + case isTerraformFamily(p.Type): + p.foldTerraformIntoPlugins(string(p.Type), repoSourceMap) + case p.Type == ProjectTypeCloudFormation, + p.Type == ProjectTypeCDKTypeScript, + p.Type == ProjectTypeCDKJavaScript, + p.Type == ProjectTypeCDKPython: + // aws.* is CloudFormation stack context; CDK is parsed by the cloudformation plugin too, so + // both fold into plugins.cloudformation (see pluginKeyForType). Guarding on type avoids a + // spurious plugins.cloudformation entry on a terraform project that happens to set aws.*. + p.foldAWSIntoPlugins() + case p.Type == ProjectTypeUnknown: + // A config with no explicit type that carries terraform.* fields (a legacy 0.1/0.2 config or a + // hand-written one) is terraform by default - fold it into plugins.terraform. + if p.hasTerraformFields() || len(repoSourceMap) > 0 { + p.foldTerraformIntoPlugins(string(ProjectTypeTerraform), repoSourceMap) + } + } +} + +// hasTerraformFields reports whether any deprecated per-project terraform.* field that folds into the +// blob is set. Workspace and cloud are excluded: they are not folded (they stay top-level, +// caller-sourced fields passed to the plugin via GenericOptions). +func (p *Project) hasTerraformFields() bool { + return len(p.Terraform.VarFiles) > 0 || len(p.Terraform.Vars) > 0 +} + +func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []TerraformRegexSource) { + if len(p.Terraform.VarFiles) > 0 { + p.setPluginOption(key, "tfVarsFiles", p.Terraform.VarFiles) + } + // Workspace is deliberately NOT folded into the blob: it is a caller-sourced runtime option passed + // via GenericOptions.Workspace (and read outside the plugin), so it stays on the top-level + // terraform.workspace field. + if len(p.Terraform.Vars) > 0 { + p.setPluginOption(key, "vars", p.Terraform.Vars) + } + // terraform.cloud.* is deliberately NOT folded into the blob: it is caller-sourced and read + // outside the plugin, so the caller passes it via GenericOptions.TerraformCloudConfiguration (and + // reads the host for the credential set). See config.go TerraformCloud. + if len(repoSourceMap) > 0 { + sm := make(map[string]any, len(repoSourceMap)) + for _, s := range repoSourceMap { + sm[s.Match] = s.Replace + } + p.setPluginOption(key, "regexSourceMap", sm) + } +} + +func (p *Project) foldAWSIntoPlugins() { + awsContext := map[string]any{} + if p.AWS.Region != "" { + awsContext["region"] = p.AWS.Region + } + if p.AWS.AccountID != "" { + awsContext["accountId"] = p.AWS.AccountID + } + if p.AWS.StackID != "" { + awsContext["stackId"] = p.AWS.StackID + } + if p.AWS.StackName != "" { + awsContext["stackName"] = p.AWS.StackName + } + if len(awsContext) == 0 { + return + } + p.setPluginOption(string(ProjectTypeCloudFormation), "awsContext", awsContext) +} diff --git a/config_test.go b/config_test.go index f09c532..21e292f 100644 --- a/config_test.go +++ b/config_test.go @@ -181,6 +181,9 @@ projects: }, }, UsageFile: "usage/file", + // workspace stays on the top-level terraform.workspace field (not folded into the + // plugins blob); cloud config needs a workspace (unset here) and the token is a + // credential, so nothing folds - Plugins stays nil. }, }, }, @@ -214,6 +217,9 @@ projects: }, }, UsageFile: "usage/file", + // workspace stays on the top-level terraform.workspace field (not folded into the + // plugins blob); cloud config needs a workspace (unset here) and the token is a + // credential, so nothing folds - Plugins stays nil. }, }, }, @@ -274,6 +280,15 @@ projects: { Name: "main", Path: "path/to/my_terraform", + // the repo-level terraform.source_map folds into each terraform project's blob as + // regexSourceMap on read. + Plugins: map[string]map[string]any{ + "terraform": { + "regexSourceMap": map[string]any{ + "^ANOTHER_MODULE$": "github.com/CentricaDevOps/networks-aws-modules//modules/another?ref=another_v1.0.0", + }, + }, + }, }, }, }, diff --git a/generate.go b/generate.go index 26a7726..e3484e6 100644 --- a/generate.go +++ b/generate.go @@ -3,6 +3,7 @@ package config import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -255,18 +256,33 @@ func Generate( if !hasProjectsSection { output.Projects = make([]*Project, 0, len(projects)) for _, project := range projects { - output.Projects = append(output.Projects, &Project{ + p := &Project{ Path: project.Path, Name: project.Name, EnvName: project.Env, DependencyPaths: project.DependencyPaths, Metadata: project.Metadata, - Terraform: ProjectTerraform{ - VarFiles: project.TerraformVarFiles, - Workspace: project.Env, - }, - Type: ProjectType(project.Type), - }) + Type: ProjectType(project.Type), + // Workspace is a caller-sourced runtime option (passed to the plugin via + // GenericOptions.Workspace) and is also read outside the plugin, so it stays a top-level + // field rather than going into the plugins blob. + Terraform: ProjectTerraform{Workspace: project.Env}, + } + + // Persist the plugin's parse options under plugins., keyed by the consuming plugin, + // as a native YAML map so it stays readable and editable. This is now the ONLY place + // generated config carries these options - the deprecated terraform.* / aws.* fields are + // no longer emitted (they are folded into plugins. on read for hand-written configs; + // see config_plugins_compat.go). + opts, err := generatedPluginOptions(project) + if err != nil { + return nil, fmt.Errorf("%w: failed to build plugin options for project %q: %s", ErrInvalidConfigYAML, project.Path, err) + } + if len(opts) > 0 { + p.Plugins = map[string]map[string]any{pluginKeyForType(p.Type): opts} + } + + output.Projects = append(output.Projects, p) } } @@ -289,3 +305,37 @@ func Generate( return output, nil } + +// generatedPluginOptions returns the plugins. blob to persist for an autodetected project, as +// a native map ready to store under plugins.. +// +// When a plugin authored a raw_options blob during identification (only when it returned +// environments authoritatively) that blob is persisted verbatim. Otherwise - when the plugin +// returned no environments, was unimplemented, or no plugin was used - config sideloads the var +// files it attributed locally into a terraform-family blob. Those var files depend on the +// whole-directory (sibling/pibling) attribution config performs in its tree passes, which the +// plugins cannot yet reproduce, so config still owns them; this sideload is the one deliberate, +// minimal special case and is expected to go away once IdentifyAllProjects moves that attribution +// plugin-side. Non-terraform-family projects get no generated blob - their options (e.g. aws +// context) are user-authored and folded in on read. +func generatedPluginOptions(project autodetect.Project) (map[string]any, error) { + if len(project.RawOptions) > 0 { + var opts map[string]any + if err := json.Unmarshal(project.RawOptions, &opts); err != nil { + return nil, err + } + return opts, nil + } + + if !isTerraformFamily(ProjectType(project.Type)) { + return nil, nil + } + + opts := map[string]any{} + if len(project.TerraformVarFiles) > 0 { + opts["tfVarsFiles"] = project.TerraformVarFiles + } + // Workspace is not part of the blob - it is written to the top-level terraform.workspace field + // (see Generate) and passed to the plugin via GenericOptions.Workspace. + return opts, nil +} diff --git a/go.mod b/go.mod index f688a6c..e24947e 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/hashicorp/go-plugin v1.8.0 github.com/hashicorp/hcl/v2 v2.24.0 github.com/infracost/go-proto v0.1.2 - github.com/infracost/proto v1.154.0 + github.com/infracost/proto v1.158.0 github.com/json-iterator/go v1.1.12 github.com/soongo/path-to-regexp v1.6.4 github.com/stretchr/testify v1.11.1 diff --git a/go.sum b/go.sum index 19a02c3..9b0efcd 100644 --- a/go.sum +++ b/go.sum @@ -42,6 +42,8 @@ github.com/infracost/proto v1.153.1-0.20260701083223-7dd6cb640924 h1:uqcs0th6Apu github.com/infracost/proto v1.153.1-0.20260701083223-7dd6cb640924/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA= github.com/infracost/proto v1.154.0 h1:BgiNeV0wq17FjFk7DRPaxTgYryJzj6Zfr6nUT3oqZb4= github.com/infracost/proto v1.154.0/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA= +github.com/infracost/proto v1.158.0 h1:vgJibZ9H3+Umz4OVHKqo06ZQokvPH3LUuWb6ZC0s068= +github.com/infracost/proto v1.158.0/go.mod h1:Z8vPWBWblwJlw+/ksO+BtsXwf9NiOcSTWx0WRWNbfUA= github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94= github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/plugin/identifier.go b/plugin/identifier.go index 873072d..3faf8c6 100644 --- a/plugin/identifier.go +++ b/plugin/identifier.go @@ -47,6 +47,10 @@ type Environment struct { Path string Files []string DependencyPaths []string + // RawOptions is the plugin-authored, opaque parse-options blob (always JSON) for this + // environment. The caller persists it under plugins. in the config file and forwards it + // verbatim into Parse; it is never interpreted here. + RawOptions []byte } // AttributedVarFile is the plugin-agnostic view of a var file the caller has already attributed @@ -172,6 +176,7 @@ func (i *Identifier) IdentifyEnvironments(ctx context.Context, dir string, proje Path: e.Path, Files: e.Files, DependencyPaths: e.DependencyPaths, + RawOptions: e.RawOptions, }) } return environments, true, nil diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..87d44ef --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,163 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/infracost/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// loadConfigString writes content to a temp config file and loads it (no env vars, so no YAML +// round-trip after the fold - blob values stay Go-native). +func loadConfigString(t *testing.T, content string) *config.Config { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "infracost.yml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + cfg, err := config.LoadConfigFile(path, dir, nil) + require.NoError(t, err) + return cfg +} + +func projectByPath(t *testing.T, cfg *config.Config, path string) *config.Project { + t.Helper() + for _, p := range cfg.Projects { + if p.Path == path { + return p + } + } + t.Fatalf("no project with path %q", path) + return nil +} + +// Generation persists terraform var files under plugins..tfVarsFiles and no longer writes the +// deprecated terraform.var_files field. +func TestPlugins_GenerationSideloadsVarFilesIntoBlob(t *testing.T) { + root := NewFilesystem(t) + root.AddTerraformFileWithProviderBlock("main.tf") + root.AddTFVarsFile("terraform.tfvars") + + cfg, err := config.Generate(t.Context(), root.Path()) + require.NoError(t, err) + require.Len(t, cfg.Projects, 1) + + p := cfg.Projects[0] + assert.Empty(t, p.Terraform.VarFiles, "generation must not emit the deprecated terraform.var_files field") + assert.Equal(t, config.ProjectTypeTerraform, p.Type) + assert.Contains(t, toStringSlice(p.Plugins["terraform"]["tfVarsFiles"]), "terraform.tfvars") +} + +// A hand-written cloudformation project's aws.* fields fold into plugins.cloudformation.awsContext, +// using the JSON keys the cloudformation plugin consumes. +func TestPlugins_FoldsAWSContext(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +projects: + - path: stack + type: cloudformation + aws: + region: us-east-1 + account_id: "123456789012" + stack_name: my-stack +`) + + p := projectByPath(t, cfg, "stack") + assert.Equal(t, map[string]any{ + "region": "us-east-1", + "accountId": "123456789012", + "stackName": "my-stack", + }, p.Plugins["cloudformation"]["awsContext"]) +} + +// CDK projects are parsed by the cloudformation plugin, so their aws.* fold into +// plugins.cloudformation (not plugins.cdk_*). +func TestPlugins_FoldsCDKAWSContextUnderCloudformation(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +projects: + - path: app + type: cdk_typescript + aws: + region: eu-west-1 +`) + + p := projectByPath(t, cfg, "app") + _, hasCDKKey := p.Plugins["cdk_typescript"] + assert.False(t, hasCDKKey, "CDK options must not live under a cdk_* key") + assert.Equal(t, map[string]any{"region": "eu-west-1"}, p.Plugins["cloudformation"]["awsContext"]) +} + +// Repo-level plugin defaults deep-merge into each project's blob: nested maps merge, but lists are +// atomic with the per-project value winning; a project with no blob inherits the defaults wholesale. +func TestPlugins_TopLevelDefaultsMergeIntoProjects(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +plugins: + terraform: + env: + FOO: bar + tfVarsFiles: + - global.tfvars +projects: + - path: a + type: terraform + plugins: + terraform: + tfVarsFiles: + - a.tfvars + - path: b + type: terraform +`) + + a := projectByPath(t, cfg, "a") + assert.Equal(t, []any{"a.tfvars"}, a.Plugins["terraform"]["tfVarsFiles"], "per-project list wins atomically over the default") + assert.Equal(t, map[string]any{"FOO": "bar"}, a.Plugins["terraform"]["env"], "unset keys are inherited from the repo-level default") + + b := projectByPath(t, cfg, "b") + assert.Equal(t, []any{"global.tfvars"}, b.Plugins["terraform"]["tfVarsFiles"], "a project with no blob inherits the defaults") + assert.Equal(t, map[string]any{"FOO": "bar"}, b.Plugins["terraform"]["env"]) +} + +// Precedence is deprecated field > per-project blob > repo-level default. (workspace is no longer a +// folded field, so this uses var files, which still fold from the deprecated terraform.var_files.) +func TestPlugins_PrecedenceDeprecatedOverBlobOverDefault(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +plugins: + terraform: + tfVarsFiles: + - from-default.tfvars +projects: + - path: a + type: terraform + terraform: + var_files: + - from-deprecated.tfvars + plugins: + terraform: + tfVarsFiles: + - from-blob.tfvars +`) + + a := projectByPath(t, cfg, "a") + assert.Equal(t, []string{"from-deprecated.tfvars"}, toStringSlice(a.Plugins["terraform"]["tfVarsFiles"])) +} + +// With no deprecated field set, the per-project blob wins over the repo-level default. +func TestPlugins_PrecedenceBlobOverDefault(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +plugins: + terraform: + tfVarsFiles: + - from-default.tfvars +projects: + - path: a + type: terraform + plugins: + terraform: + tfVarsFiles: + - from-blob.tfvars +`) + + a := projectByPath(t, cfg, "a") + assert.Equal(t, []string{"from-blob.tfvars"}, toStringSlice(a.Plugins["terraform"]["tfVarsFiles"])) +} diff --git a/setup_test.go b/setup_test.go index f34cc66..0af7a8a 100644 --- a/setup_test.go +++ b/setup_test.go @@ -231,11 +231,48 @@ func assertProjectMatches(t *testing.T, want, got *config.Project, prefix string assert.Equal(t, want.Name, got.Name, "%s: expected project name %q, got %q", prefix, want.Name, got.Name) assert.Equal(t, want.Path, got.Path, "%s: expected project path %q, got %q", prefix, want.Path, got.Path) assert.Equal(t, want.EnvName, got.EnvName, "%s: expected project env name %q, got %q", prefix, want.EnvName, got.EnvName) - if len(want.Terraform.VarFiles) > 0 || len(got.Terraform.VarFiles) > 0 { - assert.EqualValues(t, want.Terraform.VarFiles, got.Terraform.VarFiles, "%s: expected project terraform var files %q, got %q", prefix, want.Terraform.VarFiles, got.Terraform.VarFiles) + // Generated config now carries terraform var files under plugins..tfVarsFiles rather than + // the deprecated terraform.var_files field. These tests express the expectation with the old + // field for readability, so compare the effective var files from whichever representation each + // side uses. + wantVarFiles := effectiveVarFiles(want) + gotVarFiles := effectiveVarFiles(got) + if len(wantVarFiles) > 0 || len(gotVarFiles) > 0 { + assert.EqualValues(t, wantVarFiles, gotVarFiles, "%s: expected project terraform var files %q, got %q", prefix, wantVarFiles, gotVarFiles) } if len(want.DependencyPaths) > 0 || len(got.DependencyPaths) > 0 { assert.EqualValues(t, want.DependencyPaths, got.DependencyPaths, "%s: expected project dependency paths %q, got %q", prefix, want.DependencyPaths, got.DependencyPaths) } assert.Equal(t, want.Type, got.Type, "%s: expected project type %q, got %q", prefix, want.Type, got.Type) } + +// effectiveVarFiles returns a project's terraform var files from whichever representation is set: +// the new plugins..tfVarsFiles blob (populated by generation and by the compat fold) or, as a +// fallback, the deprecated terraform.var_files field the older tests author their expectations with. +func effectiveVarFiles(p *config.Project) []string { + if blob, ok := p.Plugins[string(p.Type)]; ok { + if v, ok := blob["tfVarsFiles"]; ok { + return toStringSlice(v) + } + } + return p.Terraform.VarFiles +} + +// toStringSlice coerces a decoded YAML/JSON value into a []string. Blobs that have been through a +// YAML round-trip hold []any of strings; ones built in-process may hold []string directly. +func toStringSlice(v any) []string { + switch t := v.(type) { + case []string: + return t + case []any: + out := make([]string, 0, len(t)) + for _, e := range t { + if s, ok := e.(string); ok { + out = append(out, s) + } + } + return out + default: + return nil + } +}