Skip to content

feat: plugin-keyed config options (typed/blob split, file-syntax layer)#21

Merged
liamcervante merged 5 commits into
mainfrom
plugin-options
Jul 17, 2026
Merged

feat: plugin-keyed config options (typed/blob split, file-syntax layer)#21
liamcervante merged 5 commits into
mainfrom
plugin-options

Conversation

@liamcervante

Copy link
Copy Markdown
Contributor

Summary

Makes plugins the source of truth for their own parse options. Config persists each project's options as an opaque raw_options blob keyed by the consuming plugin and forwards it verbatim — it never interprets the blob. Anything read outside the plugin (terraform workspace, TF-cloud identity, credentials) stays a typed field. The config file stays unbreaking — only where a field lives in the in-memory API moves; the on-disk shape is unchanged. Config version stays 0.3.

Pairs with infracost/parser#185 (plugins emit/read raw_options), infracost/cli#185 and infracost/runner#1303 (read the blob + typed GenericOptions), and infracost/proto#82 (GenericOptions.workspace + terraform_cloud_configuration).

Design

Three layers, cleanly separated:

  • configfile package (file-syntax layer) — models the on-disk shape only: structural fields + opaque Headings captured as raw yaml.Nodes. Knows nothing about heading semantics; imports only yaml, so there is no import cycle with package config.
  • Semantic bridge (fromConfigFile/toConfigFile) + Config.(Un)MarshalYAML — interprets the headings. On read, each heading is split (typed keys → typed fields; everything else → Plugins[key] blob); on write, the inverse recombines them into one heading. Routing every yaml (un)marshal through this layer means output is always one-heading-per-plugin with no plugins: block.
  • Legacy 0.1/0.2 (configlegacy.go) — relocated verbatim into a self-contained, deletable file that produces *Config directly.

How options are classified

  • Typed fields read outside the plugin (terraform workspace/cloud/spacelift) → travel via GenericOptions/credentials, extracted out of the blob.
  • Everything else under a plugin heading → the opaque blob, forwarded as raw_options.
  • The terraform family (terraform/terragrunt/cisco_stacks) shares the terraform: heading on disk but keys the blob by exact project type; aws:cloudformation (CDK resolves there too).

Repo-level defaults (one convention for every plugin)

Repo-level plugin defaults live under <plugin>.defaults:, matching terraform's existing terraform.defaults. They deep-merge into each project they apply to, per-project winning. terraform.defaults splits typed/blob just like a project heading (the typed keys are extracted, so they and the blob keys are disjoint and recombine under defaults: on write with no duplicate key). The deprecated terraform.source_map (a list) is transformed into each terraform-family project's regex_source_map (a map) blob key.

Backwards compatibility

  • On-disk config format is unchanged; existing files load unmodified.
  • Deprecated typed fields (Terraform.VarFiles/.Vars, AWS) are populated on load as read-only mirrors of the canonical blob.
  • An unknown top-level/project key whose value is not a mapping is treated as a typo and rejected (a plugin blob is always a mapping), restoring typo detection without needing a plugin list.

Testing

  • go build ./..., go vet ./..., gofmt clean.
  • go test ./... green, including the _WithPlugins tests that run against the real downloaded plugin binaries.
  • New tests cover the file-syntax layer, the typed/blob split round-trip, the repo-level defaults: merge, legacy fold, and the non-mapping-key rejection.

Notes for reviewers

  • Reviewed with /code-review; findings addressed (vars-mirror aliasing, non-mapping-key validation) or dissolved by the repo-level defaults: redesign (the duplicate-key marshal case).

Add the forward-facing config surface for plugin-owned parse options: a
generic, plugin-keyed plugins.<name> section that downstream consumers read.

- Config.Plugins holds repo-level, plugin-specific defaults keyed by the
  consuming plugin name.
- Project.Plugins holds the per-project raw_options blob - the persisted,
  user-editable options a plugin owns. Config never interprets it.

Both are map[string]map[string]any and opaque to config: the plugin that
matches the key owns the schema.

Mark the structured terraform.* / aws.* fields (var_files, vars, source_map,
aws region/stack_id/stack_name/account_id) deprecated, pointing each at its
plugins.<name> home. They are still read for backwards compatibility.
terraform.workspace and terraform.cloud stay typed top-level fields (not
folded): they are caller-sourced and read outside the plugin, so they travel
via GenericOptions rather than the opaque blob.
Populate the plugins.<name> blob during config generation and carry the
plugin-authored raw_options through identification.

- plugin.Environment and autodetect.Project gain a RawOptions []byte carried
  from the IdentifyEnvironments RPC (proto bumped to v1.158.0 for the field).
- Generation persists options under plugins.<name>, keyed by the consuming
  plugin (CDK folds into cloudformation via pluginKeyForType). When a plugin
  authored a raw_options blob it is persisted verbatim; otherwise, for the
  terraform family, config sideloads the var files it attributed locally into
  plugins.<name>.tfVarsFiles. This sideload is the one deliberate special case,
  expected to go away once IdentifyAllProjects moves that attribution
  plugin-side. The deprecated terraform.var_files field is no longer written.
- terraform.workspace stays a top-level field (caller-sourced, read outside the
  plugin), not part of the blob.
- ConfigSHA now hashes the plugins blob so generated configs still reflect
  var-file changes once terraform.* is no longer written.
Persist and read plugin parse options as one heading per plugin (terraform:, aws:,
kubernetes:, ...) with no `plugins:` block, while the in-memory API keeps a
plugin-keyed Plugins blob. The config file stays unbreaking; only where fields
live in the API moves.

configfile package (file-syntax layer):
- configfile.Config/Project model the on-disk shape as structural fields plus
  opaque `Headings` captured as raw yaml.Node via custom (Un)MarshalYAML. It knows
  nothing about heading semantics and imports only yaml, so there is no import
  cycle with package config.

Semantic bridge (fromConfigFile/toConfigFile) + Config.(Un)MarshalYAML:
- On read, each heading is split: terraform: -> typed workspace/cloud/spacelift on
  ProjectTerraform + everything else -> Plugins[type]; aws: -> Plugins[cloudformation];
  other headings -> Plugins[name]. On write, the inverse merges typed + blob back
  into one heading. Config.(Un)MarshalYAML route every yaml (un)marshal through this
  layer (generation re-parse, env-var replacement, callers), so output is always the
  heading shape and the Plugins yaml tags are dropped.
- Keying helpers (blobKeyForHeading/headingForBlobKey/terraformBlobKey) collapse the
  terraform family to the terraform: heading and map aws<->cloudformation, matching
  how the CLI/runner look the blob up.

Legacy 0.1/0.2 (configlegacy.go):
- Relocated verbatim out of config.go / config_file.go into a self-contained,
  deletable file, producing *Config directly (same package, no cycle). It folds its
  typed var files/vars/aws into the blob so consumers find them and the env-var
  round-trip preserves them.

Deprecated typed fields are populated mirrors:
- Terraform.VarFiles/.Vars and AWS are filled from the canonical blob on load
  (mirrorBlobToDeprecated); the blob is the source of truth and the only thing
  serialized. ConfigSHA hashes the blob, not the mirrors.

Repo-level defaults (one convention for every plugin):
- Repo-level plugin defaults live under <plugin>.defaults:, matching terraform's
  existing terraform.defaults. foldRepoPluginDefaults deep-merges them into each
  project they apply to, per-project wins (terraform-family share the terraform
  default; cloudformation covers cdk). terraform.defaults splits like a project's
  terraform: heading - typed keys (cloud/spacelift/workspace) into TerraformDefaults,
  everything else into the terraform blob default; because the split extracts the
  typed keys, they and the blob keys are disjoint and recombine under defaults: on
  write with no duplicate key. A repo-level key placed directly (not under defaults:)
  is not treated as a default.
- The deprecated terraform.source_map (a list) is transformed into each
  terraform-family project's regex_source_map (a map) blob key - the new CLI/runner
  read only the blob, so this avoids regressing existing source maps.

Validation: an unknown top-level/project key whose value is not a mapping is a typo
of a structural field (a plugin blob is always a mapping), so it is rejected with a
clear error rather than silently captured - restoring typo detection without needing
a plugin list.

ConfigWithAutodetect is removed (configfile captures the autodetect: node itself).
Config version stays 0.3.
@liamcervante
liamcervante requested a review from a team July 14, 2026 13:47
@liamcervante
liamcervante marked this pull request as ready for review July 14, 2026 13:47
@liamcervante
liamcervante merged commit d90549e into main Jul 17, 2026
3 checks passed
@liamcervante
liamcervante deleted the plugin-options branch July 17, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants