Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions autodetect/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions autodetect/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>.
// 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
Expand Down
54 changes: 49 additions & 5 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"crypto/sha1" // nolint:gosec
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
Expand Down Expand Up @@ -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 {
Expand All @@ -58,6 +64,9 @@ type ConfigWithAutodetect struct {
}

type Terraform struct {
// Deprecated: set the regex source map per-project under plugins.terraform.regexSourceMap instead.
// Still read for backwards compatibility (folded into each terraform project's plugins blob on
// load); no longer written by config generation.
SourceMap []TerraformRegexSource `yaml:"source_map,omitempty"`
Defaults TerraformDefaults `yaml:"defaults,omitempty"`
}
Expand All @@ -68,6 +77,11 @@ type TerraformDefaults struct {
Workspace string `yaml:"workspace,omitempty"`
}

// TerraformCloud is the authoring surface for Terraform Cloud / Enterprise. It is NOT deprecated and
// NOT folded into the plugins blob: Host/Org/Workspace are caller-sourced and read outside the plugin
// (the caller passes them to the plugin via GenericOptions.TerraformCloudConfiguration, and reads the
// host to build the terraform-cloud credential set). The token is a credential supplied here or via
// INFRACOST_TERRAFORM_CLOUD_TOKEN and travels via GenericOptions.CredentialSets.
type TerraformCloud struct {
Host string `yaml:"host,omitempty"`
Org string `yaml:"org,omitempty"`
Expand Down Expand Up @@ -105,9 +119,17 @@ type ProjectWithLegacySupport struct {
type ProjectTerraform struct {
Cloud TerraformCloud `yaml:"cloud,omitempty"`
Spacelift Spacelift `yaml:"spacelift,omitempty"`
Vars map[string]any `yaml:"vars,omitempty"`
VarFiles []string `yaml:"var_files,omitempty"`
Workspace string `yaml:"workspace,omitempty"`
// Deprecated: set terraform variables under plugins.terraform.vars instead. Still read for
// backwards compatibility (folded into the plugins blob on load); no longer written by generation.
Vars map[string]any `yaml:"vars,omitempty"`
// Deprecated: set var files under plugins.terraform.tfVarsFiles instead. Still read for backwards
// compatibility (folded into the plugins blob on load); no longer written by generation.
VarFiles []string `yaml:"var_files,omitempty"`
// Workspace is the terraform workspace to select. It is NOT part of the plugins blob: it is a
// caller-sourced runtime option passed to the plugin via GenericOptions.Workspace, and is also read
// outside the plugin (the project's workspace in the cost output / provider input). It stays a
// top-level field and is still written by generation.
Workspace string `yaml:"workspace,omitempty"`
}

// Project defines a specific terraform project config. This can be used
Expand All @@ -127,6 +149,10 @@ type Project struct {
DependencyPaths []string `yaml:"dependency_paths,omitempty"`
AWS ProjectAWSConfig `yaml:"aws,omitempty"`
CDKSynthError string `yaml:"cdk_synth_error,omitempty"`
// Plugins holds plugin-specific parse options keyed by the consuming plugin name (e.g.
// "terraform"). It is the persisted raw_options blob - autogenerated during identification and
// user-editable. Opaque to config; the plugin that matches the key owns the schema.
Plugins map[string]map[string]any `yaml:"plugins,omitempty"`
}

// ConfigSHA computes a deterministic SHA for the project config based on its
Expand Down Expand Up @@ -161,16 +187,34 @@ 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.<name>:
// 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, "|")))
return hex.EncodeToString(gen.Sum(nil))
}

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"`
}

Expand Down
22 changes: 22 additions & 0 deletions config_file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name> 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
Expand Down
111 changes: 111 additions & 0 deletions config_plugins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package config

// This file holds the plumbing for the generic, plugin-keyed `plugins.<name>` 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.<name> 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.<key> 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
}
90 changes: 90 additions & 0 deletions config_plugins_compat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package config

// BACKWARDS-COMPATIBILITY SHIM.
//
// This file folds the deprecated, structured fields (per-project terraform.* / aws.* and the
// repo-level terraform.source_map) into the generic plugins.<name> blob when a config file is read,
// so downstream consumers can read only the new plugins style regardless of which style the file was
// authored in. Old and hand-written configs keep working unchanged.
//
// It deliberately encodes a subset of each plugin's option schema (the JSON keys the plugin
// consumes). That is isolated here so it can be deleted wholesale once the deprecated fields are
// removed - delete this file and the foldDeprecatedFieldsIntoPlugins call in normalize() then.
//
// Precedence: the deprecated structured fields WIN over any existing blob value for the same key. A
// set structured field is hand-written (generation no longer emits them), so it is the most
// authoritative source. foldDeprecatedFieldsIntoPlugins therefore runs after the repo-level plugin
// defaults have been merged into the per-project blob, giving the overall order
// deprecated > per-project blob > repo-level default.

// foldDeprecatedFieldsIntoPlugins populates project.Plugins from the deprecated structured fields.
// repoSourceMap is the repo-level terraform.source_map, folded into each terraform-family project.
func (p *Project) foldDeprecatedFieldsIntoPlugins(repoSourceMap []TerraformRegexSource) {
switch {
case isTerraformFamily(p.Type):
p.foldTerraformIntoPlugins(string(p.Type), repoSourceMap)
case p.Type == ProjectTypeCloudFormation,
p.Type == ProjectTypeCDKTypeScript,
p.Type == ProjectTypeCDKJavaScript,
p.Type == ProjectTypeCDKPython:
// aws.* is CloudFormation stack context; CDK is parsed by the cloudformation plugin too, so
// both fold into plugins.cloudformation (see pluginKeyForType). Guarding on type avoids a
// spurious plugins.cloudformation entry on a terraform project that happens to set aws.*.
p.foldAWSIntoPlugins()
case p.Type == ProjectTypeUnknown:
// A config with no explicit type that carries terraform.* fields (a legacy 0.1/0.2 config or a
// hand-written one) is terraform by default - fold it into plugins.terraform.
if p.hasTerraformFields() || len(repoSourceMap) > 0 {
p.foldTerraformIntoPlugins(string(ProjectTypeTerraform), repoSourceMap)
}
}
}

// hasTerraformFields reports whether any deprecated per-project terraform.* field that folds into the
// blob is set. Workspace and cloud are excluded: they are not folded (they stay top-level,
// caller-sourced fields passed to the plugin via GenericOptions).
func (p *Project) hasTerraformFields() bool {
return len(p.Terraform.VarFiles) > 0 || len(p.Terraform.Vars) > 0
}

func (p *Project) foldTerraformIntoPlugins(key string, repoSourceMap []TerraformRegexSource) {
if len(p.Terraform.VarFiles) > 0 {
p.setPluginOption(key, "tfVarsFiles", p.Terraform.VarFiles)
}
// Workspace is deliberately NOT folded into the blob: it is a caller-sourced runtime option passed
// via GenericOptions.Workspace (and read outside the plugin), so it stays on the top-level
// terraform.workspace field.
if len(p.Terraform.Vars) > 0 {
p.setPluginOption(key, "vars", p.Terraform.Vars)
}
// terraform.cloud.* is deliberately NOT folded into the blob: it is caller-sourced and read
// outside the plugin, so the caller passes it via GenericOptions.TerraformCloudConfiguration (and
// reads the host for the credential set). See config.go TerraformCloud.
if len(repoSourceMap) > 0 {
sm := make(map[string]any, len(repoSourceMap))
for _, s := range repoSourceMap {
sm[s.Match] = s.Replace
}
p.setPluginOption(key, "regexSourceMap", sm)
}
}

func (p *Project) foldAWSIntoPlugins() {
awsContext := map[string]any{}
if p.AWS.Region != "" {
awsContext["region"] = p.AWS.Region
}
if p.AWS.AccountID != "" {
awsContext["accountId"] = p.AWS.AccountID
}
if p.AWS.StackID != "" {
awsContext["stackId"] = p.AWS.StackID
}
if p.AWS.StackName != "" {
awsContext["stackName"] = p.AWS.StackName
}
if len(awsContext) == 0 {
return
}
p.setPluginOption(string(ProjectTypeCloudFormation), "awsContext", awsContext)
}
Loading
Loading