A toolkit that programmatically generates terraform-plugin-framework providers from an API specification plus recorded API behaviour, so that state mapping is compile-checked generated code instead of runtime reflection — and so that the first acceptance run confirms what the evidence already proved, instead of discovering what it missed.
Status: the pilot is real. The pipeline runs end to end and has been walked across five recorded batches: 23 resource blueprints, 22 resources green against the live API in CI, 20 committed recordings, plus data sources, an ephemeral and an action. The full cross-repo chain — OpenAPI document bump → SDK regeneration and release → provider re-generation → live acceptance — has been validated once, deliberately. See Roadmap.
An OpenAPI document tells you what an API's fields are called. It does not tell you the things that decide whether a Terraform provider actually works:
- which fields are genuinely writable, versus accepted and silently discarded
- which are immutable, and so need
RequiresReplace - what the server rewrites on the way in — case, whitespace, URL form, list order, timestamp format — every one of which is a perpetual diff
- what it defaults when you omit a field, and whether that default is a real
constant or a derived value that must simply be
Computed - whether
PATCHmerges or replaces, whether create returns the object, and how long after create the thing is actually readable
A generator that trusts the OpenAPI document produces providers with perpetual
diffs, spurious replacements, and provider produced inconsistent result after apply. The alternative, historically, is discovering each fact one production
bug at a time and encoding it as a hand-maintained special case.
So this toolkit pokes the live API and records what it does. The probe transcripts are committed as recordings, facts are re-derived from them offline in CI, and they feed the generator alongside the OpenAPI document.
The pilot taught one more lesson, big enough to reshape the pipeline: it is not enough to probe fields one behaviour at a time and let acceptance tests find the rest. Fixing failures after generation is whack-a-mole against the most expensive oracle available. So the probe now ends by rehearsing the exact lifecycles the generated acceptance tests will run — same values, same bodies, both directions — before any provider code exists. Acceptance is confirmation, not discovery.
- A 16-probe catalogue against a live sandbox: six read-only probes and
ten mutating ones, ending with
write.rehearsal— minimal→maximal and maximal→minimal lifecycles, per-hop echo comparison, in-bounds contrast values, and single-culprit bisection of refused bodies. - Recordings you can replay. Every recorded run freezes its transcript, facts, scenario, subject and rehearsal bodies; CI re-derives the facts offline with egress blocked and fails on any difference.
- Static facts from the SDK itself. Behaviour written into the SDK's struct tags (a zero value the encoding cannot send) is derived by inspection, not probed, and drift-checked against the pinned SDK version.
- One fixture derivation, two renderings. The values the probe rehearses on
the wire are the values the generator renders into
minimal.tf/maximal.tf— format-aware (date-time,uuid,ipv4, …), with curated hints and omissions where no derivation can know. - Deterministic, committed artefacts with a drift check on every arrow — snapshots, blueprints, scenarios, recordings, static facts, the generated tree, the exported Provider Code Specification (codegen-spec v0.1). Byte-identical regeneration is a tested property.
- Generation finishes with the tools that check it.
provider generateruns a postcheck battery — compile, tfplugindocs,terraform fmt— so a tree that would fail CI fails at generation time. - A hard generated/hand-written boundary, enforced five ways, with an escape hatch that scaffolds once and never regenerates.
- Safety as structure, not convention: mutating probes sit behind a runtime-verified sandbox guard, every create is ledgered before it is issued, cleanup is per-probe, and cassettes pass a refuse-on-detect secret scan before anything is written.
flowchart LR
upstream["upstream OpenAPI URL"] -- "openapi fetch" --> snap["pinned snapshot\n(openapi/)"]
snap -- "blueprint draft" --> bp["blueprints/\n(+ scenario drafts)"]
live["live API"] -- "probe record" --> rec["recordings/"]
rec -- "blueprint merge" --> bp
sdk["pinned SDK"] -- "bindings facts" --> bp
curation["human curation\n(scenarios, hints, presence)"] --> bp
bp -- "provider generate" --> prov["provider Go tree\n(pilot/)"]
prov -- "postcheck\n(build, docs, fmt)" --> done["checked provider"]
bp -. "provider generate -check\n(fails on drift)" .-> prov
with drift checking: provider generate -check. An optional sdk generate
stage sits between the snapshot and the blueprints: it derives a Go SDK from
the same pinned OpenAPI document with Microsoft
Kiota — embedded in the provider tree or
as a standalone module — and the kiotaFluent dialect binds fluent
request-builder chains against it, so a provider no longer requires a
hand-written SDK. Alongside the main line,
bindings facts derives static facts from the pinned SDK for blueprint merge
to fold in, human curation (scenarios, hints, presence) feeds the same merge,
and provider generate finishes with a postcheck battery (build · docs · fmt).
Each arrow writes a committed, reviewable artefact. CI regenerates every one of them and fails on drift, then builds and tests the result — because a generator change can produce a clean diff and broken code. The checks page lists every job and its local reproduction.
A blueprint names SDK symbols as strings, so tfpfgen bindings check type-checks
them against the SDK the provider actually pins. That turns a wrong symbol from a
pile of identical compile errors in generated code into one message naming the
blueprint field to edit.
| Path | Contents |
|---|---|
cmd/tfpfgen/ |
the one installable binary; stdlib flag subcommand dispatch |
internal/blueprint/ |
the IR, its validation, and the layered-merge engine |
internal/openapi/ |
OpenAPI document → draft blueprints |
internal/snapshot/ |
pinned OpenAPI snapshots: list, read, checksum, pin |
internal/spec/ |
reads and writes terraform-plugin-codegen-spec v0.1 |
internal/probe/ |
the API behaviour prober |
internal/cassette/ |
HTTP record/replay, redaction, deterministic canonicalisation |
internal/fixturespec/ |
the one derivation of fixture values, rendered as HCL and as wire JSON |
internal/generate/ |
blueprint → Go: rendering, formatting, writing, postcheck |
internal/sdkbind/ |
type-checks blueprint bindings against the pinned SDK; derives static facts |
internal/manifest/ |
what the last run produced, so orphaned files can be found |
internal/templates/ |
embedded .tmpl files — the generated shape, as reviewable text |
blueprints/ |
committed blueprints, scenarios and static facts, one directory per provider |
recordings/ |
committed probe recordings and derived facts |
openapi/ |
pinned, immutable OpenAPI snapshots |
specs/ |
the committed spec export, drift-checked |
pilot/thousandeyes/ |
a nested module: a fully generated provider, built, unit-tested and live-tested in CI |
docs/ |
architecture, guides and the CLI reference — see below |
The loop, end to end (each step's full story is in the onboarding runbook):
tfpfgen openapi fetch -url https://…/api.yaml -out openapi/PROVIDER
tfpfgen blueprint draft -tag THING -out blueprints/PROVIDER -scenario-drafts blueprints/PROVIDER
tfpfgen bindings check -blueprint blueprints/PROVIDER -module pilot/PROVIDER
tfpfgen probe record -blueprint blueprints/PROVIDER -resource THING \
-allow-mutations -profile .tfpfgen/sandbox/PROVIDER.json
tfpfgen blueprint merge -blueprint blueprints/PROVIDER -facts recordings/…/facts.json
tfpfgen provider generate -blueprint blueprints/PROVIDER -out pilot/PROVIDERThe safe verbs are the defaults: a bare probe means probe replay, which reads
the committed recording and never the network; mutating runs demand a sandbox
profile that proves itself at runtime, and the token comes from
TFPFGEN_PROBE_TOKEN — never a flag, never a file.
The boundary is the most important thing to understand about a generated provider. Generated files carry exactly this header, and nothing else may:
// Code generated by tfpfgen from blueprints/<path>
// (sha256:…). DO NOT EDIT.Broadly: CRUD, models, schemas, registration, acceptance tests and both fixture files are generated; authentication, the client, plan modifiers and read-back predicates are yours — scaffolded once where declared, then never touched again. There is deliberately no preserved-region mechanism inside a generated file: ownership is all-or-nothing per file.
docs/generated-boundary.md has the full ownership
table, the five enforcement mechanisms, and the escape hatch.
This toolkit is not a wrapper around tfplugingen-openapi or
tfplugingen-framework. Those generate a schema and a model struct and stop:
they generate no CRUD logic at all, and they cannot express dynamic attributes,
int32/float32, blocks, resource identity, or write-only attributes. Their
renderers live under internal/, so there is nothing to import.
What this project does adopt is the Provider Code Specification as an
interop format: tfpfgen spec export and spec import write and read v0.1
JSON, so tfplugingen-openapi output can be imported and the schema slice can
be handed to other tools. Everything that format cannot carry — CRUD wiring,
SDK binding, observed behaviour, test scaffolding — lives in this project's own
richer IR.
CI feeds the committed export to HashiCorp's real tfplugingen-framework on
every PR, as a conformance oracle that does not share this repository's
assumptions (docs/interop.md).
| Doc | What it covers |
|---|---|
| docs/architecture.md | the pipeline, package map, and where logic may live |
| docs/onboarding-a-new-api.md | the end-to-end runbook, walked ~20 times |
| docs/cli.md | every subcommand, flag and exit code |
| docs/blueprint.md | the IR, field by field |
| docs/probing.md | the probe catalogue, sandbox guard, ledger, budgets |
| docs/fixtures-and-rehearsal.md | fixture derivation and the rehearsal fixpoint |
| docs/generated-boundary.md | what is generated, what is yours, how it is enforced |
| docs/checks.md | every CI check and its local reproduction |
| docs/interop.md | the codegen-spec v0.1 bridge |
| docs/glossary.md | the glossary — every domain term, one meaning each |
| Phase | Delivers | State |
|---|---|---|
| 0 | module, CLI skeleton, CI checks | done |
| 1 | walking skeleton: one resource, hand-authored blueprint → terraform plan |
done |
| 1b | nested attributes | done |
| 2 | blueprint draft: OpenAPI → the same blueprint, byte-identical |
done |
| 3 | terraform-plugin-codegen-spec v0.1 interop |
done |
| 4 | the prober: record, replay, guarding, cleanup | done |
| 5 | block kinds: data sources, actions, identity, list resources, arbitrary-depth nesting, generated validators, read-after-write, escape hatches | done |
| 6 | breadth: 23 resources across five batches; probe resequencing (rehearsal, static facts, generated fixtures); postcheck; the OpenAPI refresh loop; SDK chain validated end to end | done |
| 7 | a second API, proving nothing is pilot-shaped | next |
Three pilot resources are deferred with their reasons recorded in the blueprints:
agent-to-agent, voice and endpoint scheduled tests all need lab hardware
(enterprise or endpoint agents) that a disposable tenant does not have. Read-only
results surfaces were never in scope. The dashboard layout attribute is dropped
pending a widgets model.
Generated today: resources, data sources, actions, ephemerals, resource
identity, list resources. Provider-defined functions, state upgrader bodies
(the scaffold exists) and statestore are not modelled at all; none appears in
the reference provider, which is why they are last rather than next.
- Probing needs a sandbox tenant and consumes its quota. Mutating probes
refuse to run unless the profile asserts, at runtime, that it really is a
sandbox —
sandbox: trueis a claim, and the assertions are the evidence. Read-only probing is safe anywhere. - A mutating run can still leave something behind, and says so. The ledger
records every create before it is issued, so an object whose response was never
seen is still findable; the sweeper removes by identifier and then by name
prefix. Anything left is reported with a runnable
curland exits 5 even if every fact was gathered. See docs/probing.md. - A wrong fact is worse than no fact. Inferred field interdependencies are recorded as documentation, never as active constraints.
- A generated validator errs toward permitting.
OneOfcomes from the value set the OpenAPI document declares, not from the narrower set one tenant was observed to accept: the documented set is the wider of the two, so a stale document surfaces as a real API error carrying the API's own message rather than as aterraform planfailure nobody can work around. Where the prober saw a documented value refused, it stays permitted and the refusal is named in a comment beside the validator. - The rehearsal bisects one culprit at a time. A body refused because of two interacting fields exceeds the bisection budget and is recorded as a refusal note for a human, not silently guessed at.
- The prober cannot learn everything. Licence-dependent behaviour, cross-object constraints, RBAC, production latency, and whether a field is semantically a secret all need a human. The scenario's deny list and the blueprint's curated hints are where that boundary is drawn honestly.
blueprint draftrefuses partial resources by default. A resource whose CRUD set is incomplete is a curation decision, not something to guess at.- There is no numeric-integrality probe. A field the OpenAPI document types as
numberis generated asfloat64even when every recorded observation of it is integral; the observations are suggestive, not a derived fact.
See CONTRIBUTING.md. The one rule specific to this
repository: generated artefacts are regenerated, never hand-edited. Change
the blueprint, the templates, or the generator, and re-run. CI enforces this.
MIT.