From 897e6a67ec65eedaf8646215d7d70ab421e0a2d2 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 13 Jul 2026 09:04:40 +0200 Subject: [PATCH 1/4] feat: persist plugin parse options under plugins. Make plugins the source of truth for their own parse options and persist them under a generic, plugin-keyed plugins. section, replacing the deprecated terraform.* / aws.* fields. Additive and backwards-compatible - config never interprets the blob. Generation: - Emit parse options under plugins., keyed by the consuming plugin (CDK folds into cloudformation), as a native YAML map. The deprecated terraform.* / aws.* fields are no longer written. - When a plugin authored a raw_options blob during identification (only when it returned environments authoritatively) it is persisted verbatim. Otherwise - when IdentifyEnvironments returns no environments - config sideloads the var files it attributed locally into a terraform-family blob. Those var files rely on the whole-directory (sibling/pibling) attribution config performs in its tree passes, which the plugins cannot yet reproduce; this sideload is the one deliberate, minimal special case, expected to go away once IdentifyAllProjects moves that attribution plugin-side. Read: - Fold the deprecated structured terraform.* / aws.* fields (and the repo-level terraform.source_map) into plugins. on load, so old and hand-written configs keep working and consumers read only the new style. Isolated in config_plugins_compat.go, marked for removal once the deprecated fields go. - Merge repo-level `plugins:` defaults into each project's blob. Precedence is deprecated field > per-project blob > repo-level default; nested maps merge recursively, lists are atomic with the per-project value winning. Other: - ConfigSHA includes the plugins blob so generated configs still hash their var files / workspace once terraform.* is no longer written. - Bump proto to v1.158.0 for the raw_options fields. Config version stays 0.3. --- autodetect/project.go | 5 ++ autodetect/search.go | 5 ++ config.go | 20 +++++ config_file.go | 22 ++++++ config_plugins.go | 111 +++++++++++++++++++++++++++ config_plugins_compat.go | 99 ++++++++++++++++++++++++ config_test.go | 21 ++++++ generate.go | 61 +++++++++++++-- go.mod | 2 +- go.sum | 2 + plugin/identifier.go | 5 ++ plugins_test.go | 157 +++++++++++++++++++++++++++++++++++++++ setup_test.go | 41 +++++++++- 13 files changed, 541 insertions(+), 10 deletions(-) create mode 100644 config_plugins.go create mode 100644 config_plugins_compat.go create mode 100644 plugins_test.go 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..5215dc8 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 { @@ -127,6 +133,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 +171,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, "|"))) 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..88e8998 --- /dev/null +++ b/config_plugins_compat.go @@ -0,0 +1,99 @@ +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 is set. +func (p *Project) hasTerraformFields() bool { + return len(p.Terraform.VarFiles) > 0 || + p.Terraform.Workspace != "" || + len(p.Terraform.Vars) > 0 || + p.Terraform.Cloud.Org != "" || + p.Terraform.Cloud.Workspace != "" || + p.Terraform.Cloud.Host != "" +} + +func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []TerraformRegexSource) { + if len(p.Terraform.VarFiles) > 0 { + p.setPluginOption(key, "tfVarsFiles", p.Terraform.VarFiles) + } + if p.Terraform.Workspace != "" { + p.setPluginOption(key, "workspace", p.Terraform.Workspace) + } + if len(p.Terraform.Vars) > 0 { + p.setPluginOption(key, "vars", p.Terraform.Vars) + } + // Match the callers' gate: a Terraform Cloud configuration is only meaningful with a workspace. + // token is a credential and is passed via GenericOptions, never persisted here. + if p.Terraform.Cloud.Workspace != "" { + p.setPluginOption(key, "terraformCloudConfiguration", map[string]any{ + "organization": p.Terraform.Cloud.Org, + "workspace": p.Terraform.Cloud.Workspace, + "hostname": p.Terraform.Cloud.Host, + }) + } + 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..a5ca43b 100644 --- a/config_test.go +++ b/config_test.go @@ -181,6 +181,12 @@ projects: }, }, UsageFile: "usage/file", + // terraform.* is folded into the plugins blob on read. workspace folds; the cloud + // config needs a workspace (unset here) and the token is a credential, so neither + // is folded. + Plugins: map[string]map[string]any{ + "terraform": {"workspace": "development"}, + }, }, }, }, @@ -214,6 +220,12 @@ projects: }, }, UsageFile: "usage/file", + // terraform.* is folded into the plugins blob on read. workspace folds; the cloud + // config needs a workspace (unset here) and the token is a credential, so neither + // is folded. + Plugins: map[string]map[string]any{ + "terraform": {"workspace": "development"}, + }, }, }, }, @@ -274,6 +286,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..c9cc5eb 100644 --- a/generate.go +++ b/generate.go @@ -3,6 +3,7 @@ package config import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -255,18 +256,29 @@ 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), + } + + // 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 +301,38 @@ 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 + } + if project.Env != "" { + opts["workspace"] = project.Env + } + 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..c6db5d6 --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,157 @@ +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. +func TestPlugins_PrecedenceDeprecatedOverBlobOverDefault(t *testing.T) { + cfg := loadConfigString(t, `version: 0.3 +plugins: + terraform: + workspace: from-default +projects: + - path: a + type: terraform + terraform: + workspace: from-deprecated + plugins: + terraform: + workspace: from-blob +`) + + a := projectByPath(t, cfg, "a") + assert.Equal(t, "from-deprecated", a.Plugins["terraform"]["workspace"]) +} + +// 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: + workspace: from-default +projects: + - path: a + type: terraform + plugins: + terraform: + workspace: from-blob +`) + + a := projectByPath(t, cfg, "a") + assert.Equal(t, "from-blob", a.Plugins["terraform"]["workspace"]) +} 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 + } +} From de3f77fdb789ad7e30aa97a535e3b688f25140da Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 13 Jul 2026 10:49:30 +0200 Subject: [PATCH 2/4] chore: mark folded terraform.* / aws.* config fields deprecated Add // Deprecated: markers pointing each folded field at its plugins. equivalent (tfVarsFiles, workspace, vars, terraformCloudConfiguration, regexSourceMap, awsContext), so external consumers get an IDE/staticcheck signal to move to the plugins blob. Credentials are left unmarked: the terraform cloud token and spacelift api key are not parse options and are not folded (the token travels via GenericOptions.CredentialSets; spacelift is an upstream integration secret). config's own package is exempt from the deprecation warning (staticcheck same-package rule), so the fold/normalize/ConfigSHA reads are unaffected. --- config.go | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/config.go b/config.go index 5215dc8..d4ec617 100644 --- a/config.go +++ b/config.go @@ -64,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"` } @@ -75,10 +78,18 @@ type TerraformDefaults struct { } type TerraformCloud struct { - Host string `yaml:"host,omitempty"` - Org string `yaml:"org,omitempty"` + // Deprecated: set under plugins.terraform.terraformCloudConfiguration.hostname instead. Still read + // for backwards compatibility (folded into the plugins blob on load); no longer written by generation. + Host string `yaml:"host,omitempty"` + // Deprecated: set under plugins.terraform.terraformCloudConfiguration.organization instead. Folded + // into the plugins blob on load; no longer written by generation. + Org string `yaml:"org,omitempty"` + // Deprecated: set under plugins.terraform.terraformCloudConfiguration.workspace instead. Folded + // into the plugins blob on load; no longer written by generation. Workspace string `yaml:"workspace,omitempty"` - Token string `yaml:"token,omitempty"` + // Token is a credential: it is NOT persisted in the plugins blob and is still supplied here or via + // INFRACOST_TERRAFORM_CLOUD_TOKEN. + Token string `yaml:"token,omitempty"` } type Spacelift struct { @@ -111,9 +122,15 @@ 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"` + // Deprecated: set the workspace under plugins.terraform.workspace instead. Still read for backwards + // compatibility (folded into the plugins blob on load); no longer written by generation. + Workspace string `yaml:"workspace,omitempty"` } // Project defines a specific terraform project config. This can be used @@ -188,9 +205,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"` } From 30970deff352f297ed242a2fac643e45a915c96c Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 13 Jul 2026 11:31:28 +0200 Subject: [PATCH 3/4] feat: keep terraform workspace as a top-level field, not in the plugins blob Workspace is a caller-sourced runtime option that is also read outside the plugin (the project's workspace in the cost output / provider input), so it is passed to the plugin via GenericOptions.Workspace rather than the opaque raw_options blob. - Un-deprecate ProjectTerraform.Workspace; it stays a top-level field and is still written by generation (terraform.workspace = env). - Stop folding terraform.workspace into plugins.terraform.workspace, and stop sideloading workspace into the generated blob (only tfVarsFiles is sideloaded). - Drop workspace from hasTerraformFields (no longer a folded field). Tests updated: the load-fold cases no longer expect a workspace blob entry, and the precedence tests use var files (still folded) instead of workspace. --- config.go | 6 ++++-- config_plugins_compat.go | 10 +++++----- config_test.go | 18 ++++++------------ generate.go | 9 ++++++--- plugins_test.go | 22 ++++++++++++++-------- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/config.go b/config.go index d4ec617..be0b5fc 100644 --- a/config.go +++ b/config.go @@ -128,8 +128,10 @@ type ProjectTerraform struct { // 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"` - // Deprecated: set the workspace under plugins.terraform.workspace instead. Still read for backwards - // compatibility (folded into the plugins blob on load); no longer written by generation. + // 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"` } diff --git a/config_plugins_compat.go b/config_plugins_compat.go index 88e8998..f92baeb 100644 --- a/config_plugins_compat.go +++ b/config_plugins_compat.go @@ -40,10 +40,10 @@ func (p *Project) foldDeprecatedFieldsIntoPlugins(repoSourceMap []TerraformRegex } } -// hasTerraformFields reports whether any deprecated per-project terraform.* field is set. +// hasTerraformFields reports whether any deprecated per-project terraform.* field that folds into the +// blob is set. Workspace is excluded: it is not folded (it stays a top-level, caller-sourced field). func (p *Project) hasTerraformFields() bool { return len(p.Terraform.VarFiles) > 0 || - p.Terraform.Workspace != "" || len(p.Terraform.Vars) > 0 || p.Terraform.Cloud.Org != "" || p.Terraform.Cloud.Workspace != "" || @@ -54,9 +54,9 @@ func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []Terraform if len(p.Terraform.VarFiles) > 0 { p.setPluginOption(key, "tfVarsFiles", p.Terraform.VarFiles) } - if p.Terraform.Workspace != "" { - p.setPluginOption(key, "workspace", p.Terraform.Workspace) - } + // 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) } diff --git a/config_test.go b/config_test.go index a5ca43b..21e292f 100644 --- a/config_test.go +++ b/config_test.go @@ -181,12 +181,9 @@ projects: }, }, UsageFile: "usage/file", - // terraform.* is folded into the plugins blob on read. workspace folds; the cloud - // config needs a workspace (unset here) and the token is a credential, so neither - // is folded. - Plugins: map[string]map[string]any{ - "terraform": {"workspace": "development"}, - }, + // 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. }, }, }, @@ -220,12 +217,9 @@ projects: }, }, UsageFile: "usage/file", - // terraform.* is folded into the plugins blob on read. workspace folds; the cloud - // config needs a workspace (unset here) and the token is a credential, so neither - // is folded. - Plugins: map[string]map[string]any{ - "terraform": {"workspace": "development"}, - }, + // 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. }, }, }, diff --git a/generate.go b/generate.go index c9cc5eb..e3484e6 100644 --- a/generate.go +++ b/generate.go @@ -263,6 +263,10 @@ func Generate( DependencyPaths: project.DependencyPaths, Metadata: project.Metadata, 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, @@ -331,8 +335,7 @@ func generatedPluginOptions(project autodetect.Project) (map[string]any, error) if len(project.TerraformVarFiles) > 0 { opts["tfVarsFiles"] = project.TerraformVarFiles } - if project.Env != "" { - opts["workspace"] = project.Env - } + // 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/plugins_test.go b/plugins_test.go index c6db5d6..87d44ef 100644 --- a/plugins_test.go +++ b/plugins_test.go @@ -118,24 +118,28 @@ projects: assert.Equal(t, map[string]any{"FOO": "bar"}, b.Plugins["terraform"]["env"]) } -// Precedence is deprecated field > per-project blob > repo-level default. +// 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: - workspace: from-default + tfVarsFiles: + - from-default.tfvars projects: - path: a type: terraform terraform: - workspace: from-deprecated + var_files: + - from-deprecated.tfvars plugins: terraform: - workspace: from-blob + tfVarsFiles: + - from-blob.tfvars `) a := projectByPath(t, cfg, "a") - assert.Equal(t, "from-deprecated", a.Plugins["terraform"]["workspace"]) + 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. @@ -143,15 +147,17 @@ func TestPlugins_PrecedenceBlobOverDefault(t *testing.T) { cfg := loadConfigString(t, `version: 0.3 plugins: terraform: - workspace: from-default + tfVarsFiles: + - from-default.tfvars projects: - path: a type: terraform plugins: terraform: - workspace: from-blob + tfVarsFiles: + - from-blob.tfvars `) a := projectByPath(t, cfg, "a") - assert.Equal(t, "from-blob", a.Plugins["terraform"]["workspace"]) + assert.Equal(t, []string{"from-blob.tfvars"}, toStringSlice(a.Plugins["terraform"]["tfVarsFiles"])) } From 17f88543f193f467c342e14270431d7aa0129f04 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 13 Jul 2026 13:55:13 +0200 Subject: [PATCH 4/4] feat: keep terraform cloud config typed, out of the plugins blob Terraform Cloud config (terraform.cloud.host/org/workspace) is caller-sourced and read outside the plugin - the caller passes it to the plugin via GenericOptions.TerraformCloudConfiguration and reads the host to build the terraform-cloud credential set. So, like the workspace, it stays a top-level typed field and is NOT folded into the plugins blob. - Un-deprecate TerraformCloud (host/org/workspace); it is the authoring surface. - Stop folding terraform.cloud into plugins.terraform.terraformCloudConfiguration. - Drop cloud fields from hasTerraformFields (no longer folded). The token remains a credential (never in the blob; via GenericOptions.CredentialSets or INFRACOST_TERRAFORM_CLOUD_TOKEN). --- config.go | 19 ++++++++----------- config_plugins_compat.go | 21 ++++++--------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/config.go b/config.go index be0b5fc..311f8a8 100644 --- a/config.go +++ b/config.go @@ -77,19 +77,16 @@ 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 { - // Deprecated: set under plugins.terraform.terraformCloudConfiguration.hostname instead. Still read - // for backwards compatibility (folded into the plugins blob on load); no longer written by generation. - Host string `yaml:"host,omitempty"` - // Deprecated: set under plugins.terraform.terraformCloudConfiguration.organization instead. Folded - // into the plugins blob on load; no longer written by generation. - Org string `yaml:"org,omitempty"` - // Deprecated: set under plugins.terraform.terraformCloudConfiguration.workspace instead. Folded - // into the plugins blob on load; no longer written by generation. + Host string `yaml:"host,omitempty"` + Org string `yaml:"org,omitempty"` Workspace string `yaml:"workspace,omitempty"` - // Token is a credential: it is NOT persisted in the plugins blob and is still supplied here or via - // INFRACOST_TERRAFORM_CLOUD_TOKEN. - Token string `yaml:"token,omitempty"` + Token string `yaml:"token,omitempty"` } type Spacelift struct { diff --git a/config_plugins_compat.go b/config_plugins_compat.go index f92baeb..7dec855 100644 --- a/config_plugins_compat.go +++ b/config_plugins_compat.go @@ -41,13 +41,10 @@ func (p *Project) foldDeprecatedFieldsIntoPlugins(repoSourceMap []TerraformRegex } // hasTerraformFields reports whether any deprecated per-project terraform.* field that folds into the -// blob is set. Workspace is excluded: it is not folded (it stays a top-level, caller-sourced field). +// 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 || - p.Terraform.Cloud.Org != "" || - p.Terraform.Cloud.Workspace != "" || - p.Terraform.Cloud.Host != "" + return len(p.Terraform.VarFiles) > 0 || len(p.Terraform.Vars) > 0 } func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []TerraformRegexSource) { @@ -60,15 +57,9 @@ func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []Terraform if len(p.Terraform.Vars) > 0 { p.setPluginOption(key, "vars", p.Terraform.Vars) } - // Match the callers' gate: a Terraform Cloud configuration is only meaningful with a workspace. - // token is a credential and is passed via GenericOptions, never persisted here. - if p.Terraform.Cloud.Workspace != "" { - p.setPluginOption(key, "terraformCloudConfiguration", map[string]any{ - "organization": p.Terraform.Cloud.Org, - "workspace": p.Terraform.Cloud.Workspace, - "hostname": p.Terraform.Cloud.Host, - }) - } + // 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 {