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_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 dd266ca..ccacf6b 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), }) // 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), }) } @@ -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/config.go b/config.go index 3cf5797..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" @@ -50,6 +51,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 +132,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 +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 977cc76..398abbd 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) 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 + } + } + + // 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 @@ -148,6 +170,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/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 26a7726..639a5e1 100644 --- a/generate.go +++ b/generate.go @@ -3,6 +3,7 @@ package config import ( "bytes" "context" + "encoding/json" "errors" "fmt" "os" @@ -255,18 +256,27 @@ 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), + } + + // 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 { + p.Plugins = map[string]map[string]any{string(project.Type): opts} + } + + output.Projects = append(output.Projects, p) } } @@ -289,3 +299,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/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 diff --git a/plugins_blob_test.go b/plugins_blob_test.go new file mode 100644 index 0000000..281d49d --- /dev/null +++ b/plugins_blob_test.go @@ -0,0 +1,180 @@ +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 +} + +// 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") + 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 deprecated terraform.* fields are no longer emitted... + assert.Empty(t, p.Terraform.VarFiles) + assert.Empty(t, p.Terraform.Workspace) + + // ...the data lives only in the plugins.terraform blob now. + require.Contains(t, p.Plugins, "terraform") + blob := p.Plugins["terraform"] + + // 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: +// 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"])) +} + +// 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)