From cafffff0eae908eeb4e2e1458cafad43ab102943 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Fri, 10 Jul 2026 09:23:39 +0200 Subject: [PATCH 1/4] feat: capture and backfill plugin raw_options during identification Thread the plugin-authored raw_options blob through identification: capture it from IdentifyProjects (directory-level seed) and IdentifyEnvironments (per environment), thread the seed into IdentifyEnvironments, and carry the resolved blob on the intermediate autodetect project. For plugins that don't yet emit a raw_options blob, backfill it from the data config derived itself (attributed var files + resolved workspace) so the persisted blob matches what the callers historically built. The backfill is isolated in autodetect/rawoptions_backfill.go and marked for removal once every plugin emits raw_options. The blob is captured but not yet emitted to the config file; that follows in a subsequent commit. Bumps github.com/infracost/proto to the commit introducing raw_options. --- autodetect/node.go | 3 ++ autodetect/project.go | 5 +++ autodetect/rawoptions_backfill.go | 59 +++++++++++++++++++++++++++++++ autodetect/search.go | 6 +++- autodetect/tree.go | 2 ++ go.mod | 2 +- go.sum | 2 ++ plugin/identifier.go | 18 ++++++++-- 8 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 autodetect/rawoptions_backfill.go diff --git a/autodetect/node.go b/autodetect/node.go index 6a9ecb5..787dbd5 100644 --- a/autodetect/node.go +++ b/autodetect/node.go @@ -18,6 +18,9 @@ type Node struct { Depth int Parent *Node DependencyPaths []string + // RawOptions is the directory-level seed blob returned by the plugin's IdentifyProjects, + // threaded into IdentifyEnvironments. Empty when the plugin does not emit one. + RawOptions []byte } func (n *Node) IsRoot() bool { diff --git a/autodetect/project.go b/autodetect/project.go index e512727..fdbfb07 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-specific, opaque parse-options blob (JSON) for this project. It is + // either what the plugin returned from IdentifyEnvironments/IdentifyProjects, or - for plugins + // that don't yet emit one - a value backfilled from the data config derived itself (see + // rawoptions_backfill.go). + RawOptions []byte } type RootModule struct { diff --git a/autodetect/rawoptions_backfill.go b/autodetect/rawoptions_backfill.go new file mode 100644 index 0000000..7dd9c0f --- /dev/null +++ b/autodetect/rawoptions_backfill.go @@ -0,0 +1,59 @@ +package autodetect + +import ( + "encoding/json" + + "github.com/infracost/config/types" +) + +// BACKWARDS-COMPATIBILITY SHIM. +// +// This whole file exists to keep plugins that predate the raw_options blob working. Once every +// parser plugin emits its own raw_options from IdentifyProjects/IdentifyEnvironments, delete this +// file and the calls to resolveRawOptions in search.go. +// +// When a plugin does not return a raw_options blob, we synthesise one from the data config +// derived itself (the attributed var files and the resolved environment) so the persisted blob +// matches what the callers historically built from the config file. That deliberately duplicates +// a subset of the plugin's option schema here; it is isolated in this file so it can be removed +// wholesale later. + +// terraformRawOptions mirrors the subset of the terraform-family plugin options that config can +// derive on its own. The JSON tags MUST match the plugin's options.Options wire shape. +type terraformRawOptions struct { + TfVarsFiles []string `json:"tfVarsFiles,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + +// resolveRawOptions returns the blob to persist for a project: the plugin's own blob when it +// provided one (either per-environment or the directory-level seed), otherwise a backfilled blob +// derived from config's own attribution. +func resolveRawOptions(pluginRawOptions, seedRawOptions []byte, projectType types.ProjectType, varFiles []string, workspace string) []byte { + if len(pluginRawOptions) > 0 { + return pluginRawOptions + } + if len(seedRawOptions) > 0 { + return seedRawOptions + } + return backfillRawOptions(projectType, varFiles, workspace) +} + +// backfillRawOptions builds a raw_options blob from config-derived data for plugins that don't +// emit one. Only the terraform family consumes the shape it produces; other types get no blob. +func backfillRawOptions(projectType types.ProjectType, varFiles []string, workspace string) []byte { + switch projectType { + case types.ProjectTypeTerraform, types.ProjectTypeTerragrunt, types.ProjectTypeCiscoStacks: + default: + return nil + } + + if len(varFiles) == 0 && workspace == "" { + return nil + } + + blob, err := json.Marshal(terraformRawOptions{TfVarsFiles: varFiles, Workspace: workspace}) + if err != nil { + return nil + } + return blob +} diff --git a/autodetect/search.go b/autodetect/search.go index dd266ca..83e8dc4 100644 --- a/autodetect/search.go +++ b/autodetect/search.go @@ -340,7 +340,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN var authoritative bool if identifier != nil { var err error - pluginEnvironments, authoritative, err = identifier.IdentifyEnvironments(ctx, project.AbsolutePath, projectType, attributedFiles) + pluginEnvironments, authoritative, err = identifier.IdentifyEnvironments(ctx, project.AbsolutePath, projectType, attributedFiles, project.RawOptions) if err != nil { return nil, nil, err } @@ -384,6 +384,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(envDeps), Env: escapeStringForYAML(env.Name), Type: projectType, + RawOptions: resolveRawOptions(env.RawOptions, project.RawOptions, projectType, varFiles, env.Name), }) // record the directories this environment claims so they aren't also emitted as @@ -408,6 +409,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: "", // deliberately empty Type: projectType, + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles, ""), }) case len(envFiles) > 0 && (project.IsTerraform() || (project.IsTerragrunt() && project.Terragrunt.LinkTFVars)): @@ -455,6 +457,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: escapeStringForYAML(envName), Type: projectType, + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, tfvarFiles, envName), }) } @@ -466,6 +469,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: "", // deliberately empty Type: projectType, + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles, ""), }) } diff --git a/autodetect/tree.go b/autodetect/tree.go index c920971..d678a42 100644 --- a/autodetect/tree.go +++ b/autodetect/tree.go @@ -182,6 +182,7 @@ func (b *treeBuilder) buildSubtree(ctx context.Context, path string, depth int, } node.ProjectType = pt + node.RawOptions = idResult.RawOptions switch pt { case types.ProjectTypeTerragrunt: node.Terragrunt.HasFiles = true @@ -204,6 +205,7 @@ func (b *treeBuilder) buildSubtree(ctx context.Context, path string, depth int, Parent: node, Depth: depth + 1, ProjectType: fileType, + RawOptions: idResult.RawOptions, }) } } diff --git a/go.mod b/go.mod index f688a6c..eec783a 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.157.1-0.20260710070544-eb828dc99c8c 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..29fec65 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.157.1-0.20260710070544-eb828dc99c8c h1:veuhoJuoV2JoF6c+kT8xTr9L4C5TXoxw0wNM5hhHKr4= +github.com/infracost/proto v1.157.1-0.20260710070544-eb828dc99c8c/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..ae6c14a 100644 --- a/plugin/identifier.go +++ b/plugin/identifier.go @@ -36,6 +36,10 @@ type IdentificationResult struct { DirectoryType types.ProjectType FileTypes map[string]types.ProjectType DependencyPaths []string + // RawOptions is the directory-level seed blob (JSON) the plugin returned from IdentifyProjects. + // It is threaded into IdentifyEnvironments so the plugin can refine it per environment. Empty + // when the plugin does not emit one. + RawOptions []byte } // Environment is the plugin-agnostic view of a single environment (e.g. a dev/staging/prod @@ -47,6 +51,10 @@ type Environment struct { Path string Files []string DependencyPaths []string + // RawOptions is the plugin-authored, opaque parse-options blob (JSON) for this environment. + // Empty when the plugin does not emit one (e.g. a plugin predating raw_options); the caller + // backfills it in that case. + RawOptions []byte } // AttributedVarFile is the plugin-agnostic view of a var file the caller has already attributed @@ -87,6 +95,7 @@ func (i *Identifier) IdentifyDirectory(ctx context.Context, dir string, singleFi DirectoryType: pluginType, FileTypes: nil, DependencyPaths: result.DependencyPaths, + RawOptions: result.RawOptions, } } if len(result.Files) > 0 && (output == nil || output.DirectoryType == types.ProjectTypeUnknown) { @@ -104,6 +113,7 @@ func (i *Identifier) IdentifyDirectory(ctx context.Context, dir string, singleFi DirectoryType: "", FileTypes: m, DependencyPaths: result.DependencyPaths, + RawOptions: result.RawOptions, } } } @@ -130,7 +140,10 @@ func (i *Identifier) IdentifyDirectory(ctx context.Context, dir string, singleFi // attributedFiles carries the var files the caller has already attributed to this project so the // owning plugin can reproduce that attribution rather than re-derive it. It is a Terraform/Terragrunt // migration aid; other plugins ignore it. -func (i *Identifier) IdentifyEnvironments(ctx context.Context, dir string, projectType types.ProjectType, attributedFiles []AttributedVarFile) ([]Environment, bool, error) { +// +// seedRawOptions is the directory-level blob the plugin returned from IdentifyProjects, threaded +// back so the plugin can refine it per environment. It may be empty. +func (i *Identifier) IdentifyEnvironments(ctx context.Context, dir string, projectType types.ProjectType, attributedFiles []AttributedVarFile, seedRawOptions []byte) ([]Environment, bool, error) { for _, plugin := range i.plugins { pluginType := types.ProjectType(plugin.info.Name) if plugin.parserConfig.ConfigFileProjectType != nil { @@ -149,7 +162,7 @@ func (i *Identifier) IdentifyEnvironments(ctx context.Context, dir string, proje }) } - result, err := plugin.parser.IdentifyEnvironments(ctx, &pb.IdentifyEnvironmentsRequest{Directory: dir, AttributedFiles: pbAttributedFiles}) + result, err := plugin.parser.IdentifyEnvironments(ctx, &pb.IdentifyEnvironmentsRequest{Directory: dir, AttributedFiles: pbAttributedFiles, RawOptions: seedRawOptions}) if err != nil { // The RPC is optional: a plugin that doesn't implement it returns codes.Unimplemented. // That is the expected forward-compat signal, so we treat it as "no authoritative answer" @@ -172,6 +185,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 From 09d53ad94977780d82cd5baea5eda52ddfc13e38 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Fri, 10 Jul 2026 09:34:28 +0200 Subject: [PATCH 2/4] feat: emit plugin raw_options blob to the config file and merge repo-level defaults Add a generic, plugin-keyed `plugins` section to the config file, keyed by the consuming plugin name (e.g. "terraform"). autodetect now emits each project's raw_options blob under plugins., stored as a native YAML map so it stays readable and editable. The existing terraform.* fields are still emitted alongside as backwards-compatible sugar for consumers that don't yet read the blob. Also add repo-level plugin defaults (top-level `plugins`) that deep-merge into each project's matching blob during normalization: nested maps merge recursively, lists concatenate, and per-project values win over the global default. The merge is schema-agnostic - config never interprets the blob. The field is additive within config version 0.3 (no version bump, which would make older parsers reject the file outright). --- config.go | 8 ++++ config_file.go | 83 ++++++++++++++++++++++++++++++++ generate.go | 31 +++++++++++- plugins_blob_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 2 deletions(-) create mode 100644 plugins_blob_test.go diff --git a/config.go b/config.go index 3cf5797..675b78c 100644 --- a/config.go +++ b/config.go @@ -50,6 +50,10 @@ 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 plugin name. They are + // deep-merged into each project's matching Plugins entry during normalization (per-project + // values win). 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 +131,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 diff --git a/config_file.go b/config_file.go index 977cc76..0189edb 100644 --- a/config_file.go +++ b/config_file.go @@ -111,6 +111,23 @@ 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) into the project's matching plugin blob. + // This is a generic, schema-agnostic deep merge over the raw YAML types: nested maps + // merge recursively, and every other value (scalars and lists alike) is atomic with the + // per-project value winning over the global default. Config never needs to understand the shape. + for pluginName, globalOpts := range c.Plugins { + if len(globalOpts) == 0 { + continue + } + merged := mergePluginBlobs(globalOpts, project.Plugins[pluginName]) + if len(merged) > 0 { + if project.Plugins == nil { + project.Plugins = make(map[string]map[string]any, len(c.Plugins)) + } + project.Plugins[pluginName] = merged + } + } } // first sort by path + env to ensure duplicate name resolution uses the same path for each iteration @@ -148,6 +165,72 @@ func (c *Config) normalize() error { } +// 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 { + merged := deepMergeAny(global, project) + if m, ok := merged.(map[string]any); ok { + return m + } + return nil +} + +// deepMergeAny merges project into 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 +// that 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 + } +} + func fileExists(path string) bool { info, err := os.Stat(path) if os.IsNotExist(err) { diff --git a/generate.go b/generate.go index 26a7726..dce9329 100644 --- a/generate.go +++ b/generate.go @@ -3,6 +3,7 @@ package config import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -255,7 +256,7 @@ 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, @@ -266,7 +267,19 @@ func Generate( Workspace: project.Env, }, Type: ProjectType(project.Type), - }) + } + + // Emit the plugin-specific raw_options blob under plugins., keyed by the + // consuming plugin. Stored as a native YAML map so it stays readable and editable. + // The Terraform.* fields above are kept too, as backwards-compatible sugar for + // consumers that don't yet read the blob. + if opts, err := decodePluginRawOptions(project.RawOptions); err != nil { + return nil, fmt.Errorf("%w: failed to decode raw options for project %q: %s", ErrInvalidConfigYAML, project.Path, err) + } else if len(opts) > 0 { + p.Plugins = map[string]map[string]any{string(project.Type): opts} + } + + output.Projects = append(output.Projects, p) } } @@ -289,3 +302,17 @@ func Generate( return output, nil } + +// decodePluginRawOptions decodes a plugin's JSON raw_options blob into a native map so it can be +// stored as a readable/editable YAML map under a project's plugins. key. Returns nil for an +// empty blob. +func decodePluginRawOptions(raw []byte) (map[string]any, error) { + if len(raw) == 0 { + return nil, nil + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} diff --git a/plugins_blob_test.go b/plugins_blob_test.go new file mode 100644 index 0000000..ec9a175 --- /dev/null +++ b/plugins_blob_test.go @@ -0,0 +1,111 @@ +package config_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/infracost/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// toStringSlice converts a decoded YAML/JSON list (a []any of strings) to []string for comparison. +func toStringSlice(v any) []string { + items, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, fmt.Sprint(it)) + } + return out +} + +func findProject(projects []*config.Project, name string) *config.Project { + for _, p := range projects { + if p.Name == name { + return p + } + } + return nil +} + +// When a plugin does not emit a raw_options blob (as here, with no plugins configured), autodetect +// backfills it from the data config derived itself, and it is emitted under plugins.. +func Test_Generate_EmitsBackfilledPluginBlob(t *testing.T) { + root := NewFilesystem(t) + + infraDir := root.AddDirectory("infra") + componentsDir := infraDir.AddDirectory("components") + fooDir := componentsDir.AddDirectory("foo") + fooDir.AddTerraformFileWithProviderBlock("main.tf") + + variablesDir := infraDir.AddDirectory("variables") + devDir := variablesDir.AddDirectory("dev") + devDir.AddTFVarsFile("bla.tfvars") + variablesDir.AddTFVarsFile("defaults.tfvars") + + generated, err := config.Generate(t.Context(), root.Path()) + require.NoError(t, err) + + p := findProject(generated.Projects, "infra-components-foo-dev") + require.NotNil(t, p, "expected project infra-components-foo-dev to be generated") + + // the terraform.* sugar is still emitted for backwards compatibility... + assert.Equal(t, []string{ + "../../variables/defaults.tfvars", + "../../variables/dev/bla.tfvars", + }, p.Terraform.VarFiles) + + // ...and the same data is now also emitted as the plugins.terraform blob. + require.Contains(t, p.Plugins, "terraform") + blob := p.Plugins["terraform"] + assert.Equal(t, "dev", blob["workspace"]) + assert.Equal(t, []string{ + "../../variables/defaults.tfvars", + "../../variables/dev/bla.tfvars", + }, toStringSlice(blob["tfVarsFiles"])) +} + +// Repo-level plugin defaults deep-merge into each project's plugin blob during normalization: +// per-project values win (scalars and lists alike), and projects without a blob inherit the defaults. +func Test_LoadConfigFile_MergesGlobalPluginDefaults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "infracost.yml") + require.NoError(t, os.WriteFile(path, []byte(`version: "0.3" +plugins: + terraform: + workspace: default + tfVarsFiles: + - global.tfvars +projects: + - path: prod + type: terraform + plugins: + terraform: + workspace: prod + tfVarsFiles: + - prod.tfvars + - path: staging + type: terraform +`), 0600)) + + cfg, err := config.LoadConfigFile(path, dir, nil) + require.NoError(t, err) + + prod := findProject(cfg.Projects, "prod") + require.NotNil(t, prod) + // per-project scalar wins over the global default + assert.Equal(t, "prod", prod.Plugins["terraform"]["workspace"]) + // lists are atomic too: the per-project list replaces the global one (no concatenation) + assert.Equal(t, []string{"prod.tfvars"}, toStringSlice(prod.Plugins["terraform"]["tfVarsFiles"])) + + staging := findProject(cfg.Projects, "staging") + require.NotNil(t, staging) + // a project with no blob inherits the repo-level defaults wholesale + assert.Equal(t, "default", staging.Plugins["terraform"]["workspace"]) + assert.Equal(t, []string{"global.tfvars"}, toStringSlice(staging.Plugins["terraform"]["tfVarsFiles"])) +} From b3645b701c64b51de2dcd762148e12a2c1dc27d8 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Fri, 10 Jul 2026 09:42:04 +0200 Subject: [PATCH 3/4] feat: make plugins. the canonical config format Generation now emits parse options only under plugins. - the deprecated terraform.* / aws.* fields are no longer written. On read, any hand-authored deprecated fields (and the repo-level terraform.source_map) are folded into the plugins blob so old and hand-written configs still present the new style to consumers. The CLI and runner (updated to this config version) read plugins. and are responsible for building the blob sent to the plugin. Folding is isolated in config_plugins_compat.go, marked for removal once the deprecated fields are gone. --- config.go | 12 ++++ config_file.go | 5 ++ config_plugins_compat.go | 127 +++++++++++++++++++++++++++++++++++++++ config_test.go | 18 ++++++ generate.go | 15 ++--- plugins_blob_test.go | 69 +++++++++++++++++++-- setup_test.go | 12 +++- 7 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 config_plugins_compat.go diff --git a/config.go b/config.go index 675b78c..60cc29c 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" @@ -169,6 +170,17 @@ func (p *Project) ConfigSHA() string { sort.Strings(orderEnv) inputs = append(inputs, orderEnv...) + // Include the plugin blobs so the SHA reflects options that live only in plugins.. + // The deprecated terraform.* fields hashed above are no longer emitted by generation, so + // without this the SHA would miss var-file/workspace changes for generated configs. json.Marshal + // sorts map keys, so this is deterministic. (Once the deprecated fields and the compat shim are + // removed, the terraform.* inputs above can go and this becomes the sole source.) + 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 0189edb..398abbd 100644 --- a/config_file.go +++ b/config_file.go @@ -128,6 +128,11 @@ func (c *Config) normalize() error { project.Plugins[pluginName] = merged } } + + // Fold the deprecated structured terraform.* / aws.* fields into the plugins blob so that + // consumers can read only the new plugins. style. Structured values win over the + // blob (they were either there before or added by the user). 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_compat.go b/config_plugins_compat.go new file mode 100644 index 0000000..1b3d3a0 --- /dev/null +++ b/config_plugins_compat.go @@ -0,0 +1,127 @@ +package config + +import "encoding/json" + +// BACKWARDS-COMPATIBILITY SHIM. +// +// This file folds the deprecated, structured per-project fields (terraform.* and aws.*) 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. +// +// 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 call to foldDeprecatedFieldsIntoPlugins in normalize() then. +// +// Precedence: the structured fields WIN over any existing blob value for the same key - a set +// structured field is intentional (either it predates the blob or the user added it). + +// 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 p.Type { + case ProjectTypeTerraform, ProjectTypeTerragrunt, ProjectTypeCiscoStacks: + p.foldTerraformIntoPlugins(string(p.Type), repoSourceMap) + case ProjectTypeCloudFormation, ProjectTypeCDKTypeScript, ProjectTypeCDKJavaScript, ProjectTypeCDKPython: + // aws.* is CloudFormation stack context; CDK is parsed by the cloudformation plugin too, + // so both fold into plugins.cloudformation. Only these types carry aws.* - guarding here + // avoids a spurious plugins.cloudformation entry on a terraform project that happens to + // set aws.*. + p.foldAWSIntoPlugins() + case "": + // A config with no explicit type that carries terraform.* fields (e.g. a legacy 0.1/0.2 + // config or a hand-written template) 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) { + fold := map[string]any{} + + if len(p.Terraform.VarFiles) > 0 { + fold["tfVarsFiles"] = p.Terraform.VarFiles + } + if p.Terraform.Workspace != "" { + fold["workspace"] = p.Terraform.Workspace + } + if len(p.Terraform.Vars) > 0 { + fold["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 != "" { + fold["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 + } + fold["regexSourceMap"] = sm + } + + p.applyPluginFold(key, fold) +} + +func (p *Project) foldAWSIntoPlugins() { + a := p.AWS + awsContext := map[string]any{} + if a.Region != "" { + awsContext["region"] = a.Region + } + if a.AccountID != "" { + awsContext["accountId"] = a.AccountID + } + if a.StackID != "" { + awsContext["stackId"] = a.StackID + } + if a.StackName != "" { + awsContext["stackName"] = a.StackName + } + if len(awsContext) == 0 { + return + } + p.applyPluginFold("cloudformation", map[string]any{"awsContext": awsContext}) +} + +// applyPluginFold overwrites the given keys on the project's plugins. blob with the folded +// (structured) values. The fold is first canonicalised through JSON so its value types match the +// autodetect-emitted blob (lists as []any, maps as map[string]any, etc.). +func (p *Project) applyPluginFold(key string, fold map[string]any) { + if len(fold) == 0 { + return + } + + canonical := fold + if b, err := json.Marshal(fold); err == nil { + var decoded map[string]any + if err := json.Unmarshal(b, &decoded); err == nil { + canonical = decoded + } + } + + if p.Plugins == nil { + p.Plugins = map[string]map[string]any{} + } + if p.Plugins[key] == nil { + p.Plugins[key] = map[string]any{} + } + for k, v := range canonical { + p.Plugins[key][k] = v + } +} diff --git a/config_test.go b/config_test.go index f09c532..60a00da 100644 --- a/config_test.go +++ b/config_test.go @@ -181,6 +181,11 @@ projects: }, }, UsageFile: "usage/file", + // terraform.* is folded into the plugins blob on read (token is a credential, + // so it is not folded; cloud config needs a workspace, which is unset here). + Plugins: map[string]map[string]any{ + "terraform": {"workspace": "development"}, + }, }, }, }, @@ -214,6 +219,11 @@ projects: }, }, UsageFile: "usage/file", + // terraform.* is folded into the plugins blob on read (token is a credential, + // so it is not folded; cloud config needs a workspace, which is unset here). + Plugins: map[string]map[string]any{ + "terraform": {"workspace": "development"}, + }, }, }, }, @@ -274,6 +284,14 @@ projects: { Name: "main", Path: "path/to/my_terraform", + // the repo-level source_map folds into each terraform project's blob 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 dce9329..639a5e1 100644 --- a/generate.go +++ b/generate.go @@ -262,17 +262,14 @@ func Generate( 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), } - // Emit the plugin-specific raw_options blob under plugins., keyed by the - // consuming plugin. Stored as a native YAML map so it stays readable and editable. - // The Terraform.* fields above are kept too, as backwards-compatible sugar for - // consumers that don't yet read the blob. + // Emit the plugin-specific parse options under plugins., keyed by the consuming + // plugin, stored 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. Consumers read plugins. (and config folds + // any hand-authored deprecated fields into it on read - see config_plugins_compat.go). if opts, err := decodePluginRawOptions(project.RawOptions); err != nil { return nil, fmt.Errorf("%w: failed to decode raw options for project %q: %s", ErrInvalidConfigYAML, project.Path, err) } else if len(opts) > 0 { diff --git a/plugins_blob_test.go b/plugins_blob_test.go index ec9a175..4021954 100644 --- a/plugins_blob_test.go +++ b/plugins_blob_test.go @@ -54,13 +54,11 @@ func Test_Generate_EmitsBackfilledPluginBlob(t *testing.T) { p := findProject(generated.Projects, "infra-components-foo-dev") require.NotNil(t, p, "expected project infra-components-foo-dev to be generated") - // the terraform.* sugar is still emitted for backwards compatibility... - assert.Equal(t, []string{ - "../../variables/defaults.tfvars", - "../../variables/dev/bla.tfvars", - }, p.Terraform.VarFiles) + // the deprecated terraform.* fields are no longer emitted... + assert.Empty(t, p.Terraform.VarFiles) + assert.Empty(t, p.Terraform.Workspace) - // ...and the same data is now also emitted as the plugins.terraform blob. + // ...the data lives only in the plugins.terraform blob now. require.Contains(t, p.Plugins, "terraform") blob := p.Plugins["terraform"] assert.Equal(t, "dev", blob["workspace"]) @@ -109,3 +107,62 @@ projects: assert.Equal(t, "default", staging.Plugins["terraform"]["workspace"]) assert.Equal(t, []string{"global.tfvars"}, toStringSlice(staging.Plugins["terraform"]["tfVarsFiles"])) } + +// An old-style config that only uses the deprecated terraform.* / aws.* fields is folded into the +// plugins blob on read, so consumers can read only the new plugins. style. +func Test_LoadConfigFile_FoldsDeprecatedFieldsIntoPlugins(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "infracost.yml") + require.NoError(t, os.WriteFile(path, []byte(`version: "0.3" +terraform: + source_map: + - match: foo + replace: bar +projects: + - path: app + type: terraform + terraform: + workspace: prod + var_files: + - prod.tfvars + vars: + region: us-east-1 + cloud: + org: my-org + workspace: app-prod + host: app.terraform.io + - path: stack + type: cloudformation + aws: + region: us-east-1 + stack_name: my-stack +`), 0600)) + + cfg, err := config.LoadConfigFile(path, dir, nil) + require.NoError(t, err) + + app := findProject(cfg.Projects, "app") + require.NotNil(t, app) + tf := app.Plugins["terraform"] + require.NotNil(t, tf, "expected terraform.* to be folded into plugins.terraform") + assert.Equal(t, "prod", tf["workspace"]) + assert.Equal(t, []string{"prod.tfvars"}, toStringSlice(tf["tfVarsFiles"])) + assert.Equal(t, map[string]any{"region": "us-east-1"}, tf["vars"]) + assert.Equal(t, map[string]any{ + "organization": "my-org", + "workspace": "app-prod", + "hostname": "app.terraform.io", + }, tf["terraformCloudConfiguration"]) + // the repo-level source_map is folded into each terraform project's blob + assert.Equal(t, map[string]any{"foo": "bar"}, tf["regexSourceMap"]) + + stack := findProject(cfg.Projects, "stack") + require.NotNil(t, stack) + cfn := stack.Plugins["cloudformation"] + require.NotNil(t, cfn, "expected aws.* to be folded into plugins.cloudformation") + // unset aws fields are omitted, not written as empty strings. + assert.Equal(t, map[string]any{ + "region": "us-east-1", + "stackName": "my-stack", + }, cfn["awsContext"]) +} diff --git a/setup_test.go b/setup_test.go index f34cc66..4d60170 100644 --- a/setup_test.go +++ b/setup_test.go @@ -231,8 +231,16 @@ 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) + // var files are no longer emitted under terraform.var_files; they live in the plugins blob. + // Test expectations still specify them under Terraform.VarFiles for readability, so compare + // that against the tfVarsFiles carried in plugins. (an unset type defaults to terraform). + pluginKey := string(got.Type) + if pluginKey == "" { + pluginKey = "terraform" + } + gotVarFiles := toStringSlice(got.Plugins[pluginKey]["tfVarsFiles"]) + if len(want.Terraform.VarFiles) > 0 || len(gotVarFiles) > 0 { + assert.EqualValues(t, want.Terraform.VarFiles, gotVarFiles, "%s: expected project var files %q, got %q", prefix, want.Terraform.VarFiles, 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) From 695c9a9d9cdb1e76f24b356ce67d294fd747f682 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Fri, 10 Jul 2026 16:23:16 +0200 Subject: [PATCH 4/4] feat: sideload only tfVarsFiles into the plugin blob, leave the rest to plugins Previously autodetect backfilled a whole {tfVarsFiles, workspace} blob for terraform-family projects when the plugin didn't author one. That duplicated option schema config shouldn't own: workspace (and anything else) is the plugin's to author via IdentifyEnvironments/IdentifyProjects. Narrow config's responsibility to the one thing only it can compute: tfVarsFiles, which depends on the cross-directory (sibling/pibling) attribution that lives in config's tree passes. Instead of synthesising and replacing the blob, sideloadTFVarsFiles now merges tfVarsFiles into whatever the plugin authored (its per-env blob, the IdentifyProjects seed, or nothing), leaving every other key untouched so the blob stays extensible. workspace is no longer written by config - it comes from the plugin. Downstream this is safe: an unset workspace and an empty one are identical in the CLI, runner, and parser (all gate on == "" and the parser defaults to "default"). This is a deliberate, minimal special case expected to retire once IdentifyAllProjects moves the multi-directory attribution plugin-side. Test_Generate_EmitsPluginBlob's workspace assertion now depends on a terraform plugin that emits raw_options from IdentifyEnvironments (proto >= v1.158.0). installTestPlugins downloads the latest released plugin, which predates that, so the assertion fails for now and starts passing once the new plugins are released; the test is documented accordingly. tfVarsFiles and all other tests pass. --- autodetect/rawoptions_backfill.go | 59 ------------------------------ autodetect/rawoptions_sideload.go | 61 +++++++++++++++++++++++++++++++ autodetect/search.go | 8 ++-- plugins_blob_test.go | 20 ++++++++-- 4 files changed, 81 insertions(+), 67 deletions(-) delete mode 100644 autodetect/rawoptions_backfill.go create mode 100644 autodetect/rawoptions_sideload.go diff --git a/autodetect/rawoptions_backfill.go b/autodetect/rawoptions_backfill.go deleted file mode 100644 index 7dd9c0f..0000000 --- a/autodetect/rawoptions_backfill.go +++ /dev/null @@ -1,59 +0,0 @@ -package autodetect - -import ( - "encoding/json" - - "github.com/infracost/config/types" -) - -// BACKWARDS-COMPATIBILITY SHIM. -// -// This whole file exists to keep plugins that predate the raw_options blob working. Once every -// parser plugin emits its own raw_options from IdentifyProjects/IdentifyEnvironments, delete this -// file and the calls to resolveRawOptions in search.go. -// -// When a plugin does not return a raw_options blob, we synthesise one from the data config -// derived itself (the attributed var files and the resolved environment) so the persisted blob -// matches what the callers historically built from the config file. That deliberately duplicates -// a subset of the plugin's option schema here; it is isolated in this file so it can be removed -// wholesale later. - -// terraformRawOptions mirrors the subset of the terraform-family plugin options that config can -// derive on its own. The JSON tags MUST match the plugin's options.Options wire shape. -type terraformRawOptions struct { - TfVarsFiles []string `json:"tfVarsFiles,omitempty"` - Workspace string `json:"workspace,omitempty"` -} - -// resolveRawOptions returns the blob to persist for a project: the plugin's own blob when it -// provided one (either per-environment or the directory-level seed), otherwise a backfilled blob -// derived from config's own attribution. -func resolveRawOptions(pluginRawOptions, seedRawOptions []byte, projectType types.ProjectType, varFiles []string, workspace string) []byte { - if len(pluginRawOptions) > 0 { - return pluginRawOptions - } - if len(seedRawOptions) > 0 { - return seedRawOptions - } - return backfillRawOptions(projectType, varFiles, workspace) -} - -// backfillRawOptions builds a raw_options blob from config-derived data for plugins that don't -// emit one. Only the terraform family consumes the shape it produces; other types get no blob. -func backfillRawOptions(projectType types.ProjectType, varFiles []string, workspace string) []byte { - switch projectType { - case types.ProjectTypeTerraform, types.ProjectTypeTerragrunt, types.ProjectTypeCiscoStacks: - default: - return nil - } - - if len(varFiles) == 0 && workspace == "" { - return nil - } - - blob, err := json.Marshal(terraformRawOptions{TfVarsFiles: varFiles, Workspace: workspace}) - if err != nil { - return nil - } - return blob -} diff --git a/autodetect/rawoptions_sideload.go b/autodetect/rawoptions_sideload.go new file mode 100644 index 0000000..95ce66b --- /dev/null +++ b/autodetect/rawoptions_sideload.go @@ -0,0 +1,61 @@ +package autodetect + +import ( + "encoding/json" + + "github.com/infracost/config/types" +) + +// The plugins author their own raw_options blob during identification (per-environment from +// IdentifyEnvironments, or a directory-level seed from IdentifyProjects) and config persists it +// verbatim under plugins.. The one exception is a terraform-family project's var files: +// tfVarsFiles depends on the cross-directory (sibling/pibling) attribution that lives in config's +// tree passes, which the plugins do not have. So config sideloads just that one field into the blob. +// +// This is a deliberate, minimal special case - it only ever sets tfVarsFiles and leaves everything +// else the plugin (or a user) put in the blob untouched, so the blob stays extensible. It is +// expected to go away once IdentifyAllProjects moves the multi-directory attribution plugin-side. + +// resolveRawOptions returns the blob to persist for a project. It starts from the blob the plugin +// authored - the per-environment blob from IdentifyEnvironments, else the directory-level seed from +// IdentifyProjects - and, for terraform-family projects, sideloads config's attributed var files +// into it without disturbing anything else the plugin produced. +func resolveRawOptions(pluginRawOptions, seedRawOptions []byte, projectType types.ProjectType, varFiles []string) []byte { + base := pluginRawOptions + if len(base) == 0 { + base = seedRawOptions + } + return sideloadTFVarsFiles(base, projectType, varFiles) +} + +// sideloadTFVarsFiles merges config's attributed var files into a terraform-family blob under the +// tfVarsFiles key, preserving every other key. Non-terraform-family types, and terraform-family +// projects with no attributed var files, are returned unchanged. +func sideloadTFVarsFiles(blob []byte, projectType types.ProjectType, varFiles []string) []byte { + switch projectType { + case types.ProjectTypeTerraform, types.ProjectTypeTerragrunt, types.ProjectTypeCiscoStacks: + default: + return blob + } + + if len(varFiles) == 0 { + return blob + } + + options := map[string]any{} + if len(blob) > 0 { + if err := json.Unmarshal(blob, &options); err != nil { + // don't clobber a blob we can't parse; leave it as the plugin/user authored it. + return blob + } + } + + options["tfVarsFiles"] = varFiles + + merged, err := json.Marshal(options) + if err != nil { + return blob + } + + return merged +} diff --git a/autodetect/search.go b/autodetect/search.go index 83e8dc4..ccacf6b 100644 --- a/autodetect/search.go +++ b/autodetect/search.go @@ -384,7 +384,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(envDeps), Env: escapeStringForYAML(env.Name), Type: projectType, - RawOptions: resolveRawOptions(env.RawOptions, project.RawOptions, projectType, varFiles, env.Name), + RawOptions: resolveRawOptions(env.RawOptions, project.RawOptions, projectType, varFiles), }) // record the directories this environment claims so they aren't also emitted as @@ -409,7 +409,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: "", // deliberately empty Type: projectType, - RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles, ""), + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles), }) case len(envFiles) > 0 && (project.IsTerraform() || (project.IsTerragrunt() && project.Terragrunt.LinkTFVars)): @@ -457,7 +457,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: escapeStringForYAML(envName), Type: projectType, - RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, tfvarFiles, envName), + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, tfvarFiles), }) } @@ -469,7 +469,7 @@ func expandProjects(ctx context.Context, identifier *plugin.Identifier, projectN DependencyPaths: escapeStringListForYAML(deps), Env: "", // deliberately empty Type: projectType, - RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles, ""), + RawOptions: resolveRawOptions(nil, project.RawOptions, projectType, globalFiles), }) } diff --git a/plugins_blob_test.go b/plugins_blob_test.go index 4021954..281d49d 100644 --- a/plugins_blob_test.go +++ b/plugins_blob_test.go @@ -33,9 +33,16 @@ func findProject(projects []*config.Project, name string) *config.Project { return nil } -// When a plugin does not emit a raw_options blob (as here, with no plugins configured), autodetect -// backfills it from the data config derived itself, and it is emitted under plugins.. -func Test_Generate_EmitsBackfilledPluginBlob(t *testing.T) { +// A terraform project's generated parse options are split across two authors in the plugins. +// blob: config sideloads the var files it attributed (tfVarsFiles, which depends on cross-directory +// analysis the plugin doesn't have), and the terraform plugin authors the rest - notably the +// workspace (the env name) from IdentifyEnvironments. +// +// NOTE: the workspace assertion requires a terraform plugin that emits raw_options from +// IdentifyEnvironments (proto >= v1.158.0). Until that is the latest released plugin, +// installTestPlugins downloads an older one that does not emit it, so this assertion fails - it +// starts passing once the new plugins are released. +func Test_Generate_EmitsPluginBlob(t *testing.T) { root := NewFilesystem(t) infraDir := root.AddDirectory("infra") @@ -61,11 +68,16 @@ func Test_Generate_EmitsBackfilledPluginBlob(t *testing.T) { // ...the data lives only in the plugins.terraform blob now. require.Contains(t, p.Plugins, "terraform") blob := p.Plugins["terraform"] - assert.Equal(t, "dev", blob["workspace"]) + + // config sideloads the var files it attributed across directories. assert.Equal(t, []string{ "../../variables/defaults.tfvars", "../../variables/dev/bla.tfvars", }, toStringSlice(blob["tfVarsFiles"])) + + // the terraform plugin authors the workspace (the env name). Requires the new plugin - see the + // note above; fails against older downloaded plugins. + assert.Equal(t, "dev", blob["workspace"]) } // Repo-level plugin defaults deep-merge into each project's plugin blob during normalization: