Manage the deployment with Terraform
Put a cluster’s deployment configuration under Terraform: providers, credentials, budgets, profiles and guardrails, the keys a deployment document holds. It goes beside whatever else already writes configuration, without any of them being able to flatten the others.
The arrangement rests on one idea, which is not Terraform’s: configuration is stored as named sources, each owned by one writer. Give Terraform a source of its own and there is nothing to contend over. That holds not because the provider is careful, but because a source has one owner and the composition refuses an overlap out loud. Read Manage configuration through the API first if that is new. Everything below assumes it.
Before you start
Section titled “Before you start”You need a cluster. Sources are stored in the raft log, so a single-node deployment being served from its file has nowhere to keep one and these endpoints answer 501.
You also need a token, and there is no way to make one here. The
gateway is a resource server that verifies tokens and mints none. In CI
the token comes from your identity provider and lands in
PISTRA_TOKEN. It is either a client-credentials grant for the
pipeline, or the CI platform’s own OIDC token if your IdP will exchange
it. Against a dev gateway whose admin listener is on loopback and needs
no token, leave it empty.
The provider is not on the Terraform registry yet. Build it and point Terraform at the binary with a dev override:
$ cd terraform-provider-pistra && go build -o ../bin/terraform-provider-pistra .Then write the override into ~/.terraformrc:
provider_installation { dev_overrides { "pistra-dev/pistra" = "/path/to/pistra/bin" } direct {}}The address has to be pistra-dev/pistra exactly. A dev override under
any other name is not an error. Terraform does not apply it, and then
fails to find a provider it was told where to get.
Give Terraform its own source
Section titled “Give Terraform its own source”Pick a name and grant it, in the same policy file that grants everybody else:
@id("terraform-owns-its-own")permit( principal in Role::"terraform", action == Action::"putConfigSource", resource in Resource::"config/terraform");That line is the whole of who may write it. in covers the source and
every object in its document; a write is decided once per object it
changes, and this grant answers for all of them. See
Restrict what an admin caller can do
for the rest of the policy.
Point the provider at the gateway
Section titled “Point the provider at the gateway”Use the following example:
terraform { required_providers { pistra = { source = "pistra-dev/pistra" } }}
provider "pistra" { endpoint = "https://gw.example:8485" # or PISTRA_ENDPOINT token = var.admin_token # or PISTRA_TOKEN ca_certificate_pem = file("${path.module}/gateway-ca.pem")}ca_certificate_pem is there because the front-door deployment mints
its own certificate, so the system trust pool is usually the wrong
answer for it. Leave it out where the gateway carries a certificate the
machine already trusts.
Choose between the three resources
Section titled “Choose between the three resources”They differ by what they claim ownership of:
| resource | claims | reach for it when |
|---|---|---|
pistra_config_object |
one element of a name-keyed collection | most of the time |
pistra_config_field |
one single-owner top-level field | the thing you are managing is a block, not a list element |
pistra_config_source |
a whole source document | the deployment is rendered entirely from HCL and nothing else writes it |
pistra_config_object takes a kind of providers, credentials,
budgets, profiles, mcp, a2a or access_rules, and
a name that is the element’s identity. The first object written to a
source creates it.
resource "pistra_config_object" "openai" { source = "terraform" kind = "providers" name = "openai" body = <<-EOT preset: openai credential: openai-key EOT}
resource "pistra_config_object" "openai_key" { source = "terraform" kind = "credentials" name = "openai-key" body = "api_key: $${secret:OPENAI_API_KEY}"}The $${ is Terraform’s escape for a literal ${. The gateway
receives ${secret:OPENAI_API_KEY}, a reference to a value written
once into the sealed store and never read back. Terraform never sees
the credential. A secret in HCL is a secret in the state file. See
Store a secret through the API.
pistra_config_field takes the fields composition gives exactly one
owner across all sources: guardrails, reconcile, models, auth,
max_body_bytes, default_provider, on_fidelity_loss, public_url.
resource "pistra_config_field" "guardrails" { source = "terraform" kind = "guardrails" value = file("${path.module}/guardrails.yaml")}Keep the guardrail policy in its own file rather than a heredoc. That
file is the one pistra guardrails eval and
the rule suite can be pointed at
before the apply.
There is no way to write an empty field, and the zero value is refused. “Set this to nothing” and “do not set this” are different intentions, and only one of them is expressible. Destroy the resource to unset it.
What fails the apply, and when
Section titled “What fails the apply, and when”Every write is validated the whole way to a snapshot before anything is
stored, so the failure arrives at terraform apply rather than at the
next fleet restart:
$ terraform apply╷│ Error: invalid deployment: providers[0] (openai): unknown credential "openai-key"╵That is the gateway’s own sentence, from the same validation the config file goes through. A document the deployment cannot build does not become a stored revision that every node then declines to load.
Three behaviours are worth knowing before you meet them:
- Object writes race safely. Terraform applies resources in
parallel, and several objects of one source arriving at once retry
past each other’s revisions. You do not need
depends_onbetween objects to serialise them. - Whole-source writes never retry. A moved revision under
pistra_config_sourcemeans a second writer on a document that is supposed to have one, and the error says to go find them rather than quietly winning the race. - YAML compares semantically. The server re-renders what it splices. State absorbs the rendering without reading it as drift, so respelling your HCL plans as the no-op it is.
Read what you do not own
Section titled “Read what you do not own”A team’s configuration usually references the platform’s, for example a profile naming a provider somebody else defines. Read it. Do not copy it:
data "pistra_config_source" "platform" { name = "platform"}document is the stored YAML byte for byte and revision is what it
was last written at. Both are useful in a precondition when your
configuration is only valid against a platform document that has
something in it.
Adopt what is already there
Section titled “Adopt what is already there”Nothing has to be recreated to come under management:
$ terraform import pistra_config_object.openai providers/openai$ terraform import pistra_config_field.guardrails guardrails$ terraform import pistra_config_source.platform platformAn object is imported by kind and name, and the source it lives in is what the read reports. Importing does not move an object between sources. Moving it means writing it into the new source and deleting it from the old. Taking those steps in that order leaves both defined for the moment in between, and the composition refuses that. Delete first.
What Terraform does not manage
Section titled “What Terraform does not manage”Three things are left out deliberately:
- The node file: listeners,
cluster,admin,tls, the directories. Those configure a process rather than a deployment and take effect from that node’s own file, which is the chart’s or the machine image’s job. The split is the subject of Two configuration documents. - Secret values.
${secret:NAME}references them. The sealed store holds them. A Terraform resource that carried the value would put it in the state file. That is the problem the store exists to solve. - Virtual keys. They are minted, shown once and revoked, not
declared, so a resource whose
terraform destroyrevoked a key somebody is using would be a footgun with a plan file. Usepistra adminor the console.
Related
Section titled “Related”- Manage configuration through the API, sources, revisions and the compare-and-swap this is built on
- Restrict what an admin caller can do, the Cedar policy that grants the source
- Give a team its own policy, what a second source is usually for