Terraform integration: consuming (and optionally driving) existing Terraform-managed infra
On Fri, 25 Sep 2026, by @lucasdicioccio, 1439 words, 4 code snippets, 0 links, 0images.
Generated from specs/terraform-integration.md — the repository is the canonical source, and may be ahead of this page.
Terraform integration: consuming (and optionally driving) existing Terraform-managed infra
Status: draft / not implemented. This is a design sketch to react to, not a committed plan.
Problem
Salmon’s own job stops at “given a machine that already exists, converge it
to a declared state” — nothing in salmon-core/salmon-ops creates
compute/network/DNS-zone resources at a cloud provider, and nothing in the
repo talks to Terraform today (confirmed: no terraform/.tf/HCL
references anywhere in salmon-*). Meanwhile there may already be
Terraform state describing the machines/networks/DNS zones this project
needs to target — the [[pg-ha control plane]] spec’s “Machines” leaf in
particular (“control-plane seeds must be unfoldable to Machines…”) has to
come from somewhere, and if that somewhere is already Terraform, salmon
shouldn’t duplicate or fight it.
What “integration” could mean — two different things
It’s worth naming these as separate models up front, because they have very different blast radii and this spec recommends starting with only one of them.
Model A — read-only consumption. Terraform (run by hand, by CI, by
whatever already runs it) is the source of truth for resource existence;
salmon only ever reads its outputs (machine IPs, DNS zone ids, generated
credentials Terraform provisioned) to build seeds/directives. Salmon never
calls terraform apply/destroy.
Model B — salmon-driven apply. Salmon shells out to terraform itself,
treating a Terraform root module as one more Op in the graph (up runs
apply, down runs destroy), so a single salmon-x run up can bring up
both the Terraform-managed layer and everything salmon layers on top in one
traversal.
Recommendation: build Model A first, treat Model B as optional/future.
Model A composes with “we may have already-existing terraform usage”
directly — it’s non-invasive by construction, since salmon never mutates
anything Terraform owns. Model B requires deciding who owns lifecycle
(salmon down calling terraform destroy against existing hand-managed
infra is a real footgun — see §3’s caveats) and isn’t needed to unblock the
pg-ha control-plane work, which only needs machine inventory as an input.
Design goals / non-goals
Goals:
- No new core (
salmon-core) mechanism — reuse the same insight as the previous-seed-state design inspecs/pg-ha-control-plane.md:Configure’sgen :: seed -> m ais already impure, so “read Terraform outputs” is just another impure read at seed-to-directive time, exactly like reading a previous-state JSON file. - Salmon never becomes a Terraform state owner. It reads
terraform output -json(or, for Model B, drivesapply/destroyagainst a root module it’s pointed at) — it does not generate, template, or hand-edit.tffiles. Whoever wrote the existing Terraform config keeps owning it. - Salmon never manages cloud credentials itself. Both models assume
terraform/its providers are already configured to run ambiently (env vars, an assumed role, a configured backend) exactly as if a human ranterraform applyat that path — salmon just shells out to theterraformbinary the same wayBinary.withBinaryalready wraps every other external tool in this codebase.
Non-goals (v1):
- Generating/templating Terraform HCL from salmon seeds (the inverse
direction — “salmon describes infra, emits
.tf”). A materially different, much larger feature; not needed to consume existing usage. - Remote state backend management (S3 bucket + DynamoDB lock table, TFC workspace creation, etc.) — assumed to already exist if it exists at all.
- Import of unmanaged resources into Terraform state — out of scope; if
something needs importing, that’s a one-time
terraform importa human runs, upstream of anything salmon touches.
Proposed design
1. Reading outputs into a seed (Model A)
A small new module, e.g. SreBox.TerraformState (recipes-level, not
core — this is derivation logic, not an IO primitive with its own up/
down):
-- shells out to `terraform output -json`, or reads a cached copy of that
-- JSON from a file (useful for offline/repeatable seed generation, and
-- avoids requiring the `terraform` binary + provider credentials just to
-- build a directive)
readOutputs :: FilePath -> IO (Either String Aeson.Value) -- workdir, or a captured -json fileA seed carries either a working directory (to shell out live) or a path to
a previously-captured terraform output -json file — same “carry a
Maybe FilePath” shape the previous-seed-state design already established:
data TerraformSource
= LiveWorkdir FilePath (Maybe Text) -- terraform root dir, optional workspace name
| CapturedOutputs FilePath -- a `terraform output -json > file` snapshotgen then parses the specific outputs a given recipe needs (e.g.
machine_ab_0_ip, machine_ab_1_ip, dns_zone_id) out of the JSON into
strongly-typed seed fields, the same way any other Configure step turns
loosely-typed input into a typed directive — failure to find an expected
output key is a gen-time error (surfaced before any Op runs), not
something recipes downstream ever need to handle.
This directly answers the pg-ha control-plane spec’s open question of
“where do machine addresses come from”: ControlPlaneSeed’s machine
fields become optional-override-else-read-from-Terraform, i.e. the seed
carries a TerraformSource and the two pair_machine_a/pair_machine_b
addresses are populated from it during gen, rather than being typed in by
hand every time.
2. Recommended workflow shape
# Terraform, run however it already is (CI, human, whatever) — salmon doesn't touch this
terraform -chdir=infra/prod apply
# capture outputs once (or every time before a converge, cheap either way)
terraform -chdir=infra/prod output -json > tf-outputs.json
# salmon reads the capture, same two-phase protocol as everything else
salmon-x config --terraform-outputs tf-outputs.json ... | salmon-x run up
This keeps the existing hermetic seed→directive→ops boundary completely
intact (per CLAUDE.md’s description of why that split exists) — Terraform
becomes just one more impure input gen reads, alongside the previous-
state file from the pg-ha spec. Nothing about Actions/Serve.hs’s World/
Epoch model needs to change either: a serve loop re-reads whatever
tf-outputs.json currently says on every seed declaration, same as it
would re-read any other input file.
3. Optional: Salmon.Builtin.Nodes.Terraform as an Op (Model B)
If/when salmon-driven apply is actually wanted, it fits the existing node
shape cleanly — unlike nft/ip link, terraform apply is naturally
idempotent (a no-op plan applies as a no-op), so this is a “prefer
replace”-bucket node per CLAUDE.md’s conventions, not a prelim-skip one:
data TerraformRoot
= TerraformRoot
{ tf_workdir :: FilePath
, tf_workspace :: Maybe Text
, tf_var_file :: Maybe FilePath
}
apply :: Reporter Report -> Track' (Binary "terraform") -> TerraformRoot -> Opup: terraform -chdir=<workdir> [workspace select <ws>] apply -auto-approve [-var-file=<f>], via the ordinary withBinary/untrackedExec path (which
already throws on non-zero exit, satisfying CLAUDE.md’s “failure must not
be swallowed” convention for free — no special handling needed here).
down: terraform -chdir=<workdir> destroy -auto-approve — deliberately
not wired to run automatically from a generic downTree walk in v1.
destroy against a root module that predates salmon’s involvement, or that
other tooling/humans also touch, is exactly the kind of hard-to-reverse,
shared-state action this project’s own operating conventions (see the
“Executing actions with care” guidance salmon is developed under) say needs
an explicit, deliberate trigger — not something that happens as a side
effect of some unrelated node’s teardown pulling in a shared predecessor.
Concretely: expose apply as an Op usable in up/tree/dag, but ship
its down as Actionless/no-op by default, with a separate, explicitly-
named destroy value (not wired into the same Op) that a caller has to
opt into deliberately if they really want salmon-x run down to reach it.
4. Reading Terraform-generated secrets
Terraform providers commonly generate credentials (a random DB password, a
provider-issued API key) as sensitive outputs. Treat these exactly like
every other secret in this codebase per the existing convention ([[recipe
key exchange agnostic]]): readOutputs surfaces them as plain values at
gen time (already local, already trusted — no new transport invented),
and downstream recipes take them as ordinary pre-provisioned secret values,
same as they’d take a secret read from any other file today. No new secret-
handling machinery needed.
Open questions
- Does “already-existing Terraform usage” mean one root module or
several (e.g. separate network/compute/DNS roots, possibly separate
workspaces per tier/environment)? Determines whether
TerraformSourceneeds to be a list (read/merge outputs from multiple roots) rather than one workdir — leaning towards supporting a list from the start since “one big root module” vs “several small ones” is a common enough split not to special-case away. - Output naming contract: does salmon assume specific output names
(
machine_ab_0_ip, etc.) that the existing.tffiles would need to expose (possibly requiring someone to add outputs to already-existing config), or doesgenneed a mapping/config layer between “whatever outputs already exist” and “what the seed needs”? Depends entirely on what the existing Terraform code currently outputs — worth looking at before finalizing the parsing shape in §1. - Model B’s
destroyexposure: even as an explicit opt-in value (not wired todown), should it require something stronger than “a Haskell value the caller chooses to reference” — e.g. a separate CLI subcommand gated behind its own confirmation prompt/flag — given the blast radius? Leaning yes, but the concrete UX depends on howdestroywould actually be invoked in practice (interactively vs. from CI). - Version/binary pinning: does the environment already pin a Terraform
version (a
.terraform-version/required_versionin existing config), and should the newTrack' (Binary "terraform")check/require it, or just shell out to whateverterraformis onPATHlike every otherBinarytrack in this codebase does today (recommend the latter — consistent with existing conventions, and version mismatches surface as ordinaryterraformerrors rather than needing salmon-side detection)?
Phased plan
SreBox.TerraformState.readOutputs(§1) against a capturedtf-outputs.jsonfile only — no liveterraform outputshell-out yet, noOpinvolved at all, just JSON parsing into typed values. Cheapest possible slice, immediately unblocks the pg-ha control-plane spec’s “where do machine addresses come from” question.- Wire a
TerraformSourceintoControlPlaneSeed(from the pg-ha spec) as an alternative to hand-typed machine addresses. - Live shell-out variant (
terraform output -json, not just a captured file), once the captured-file path has been exercised for real. - Model B (
Terraform.apply, §3), only if/when there’s a concrete need for salmon to driveapplyitself rather than assuming it already ran.
Future work
- HCL generation/templating from salmon seeds (the inverse direction), if the “already-existing usage” turns out to be small enough that salmon owning it outright becomes more attractive than treating it as a fixed external input.
- A
destroy-with-plan confirmation flow analogous toadvance-querying.md’sPlan/digest mechanism, if Model B’s opt-in destroy needs more ceremony than a bare CLI flag.