API reference: Terraform
Snowtrol Terraform API: blueprints, workspaces (folders), plan/approve/apply/drift/discard, workspace membership and edits, multi-blueprint folders, import/adopt, state versions and the http state backend.
The Terraform execution engine — the platform's single mutation path (plan → policy/cost gates → approve → apply the saved plan → inventory). Reads are open to any member; every mutation requires mutate_infra (Viewers get 403); cross-org ids return 404. Runs serialize per workspace. When the deployment runs the async runner (CC_TF_ASYNC=1), plan and apply return { state: "queued" } and the run progresses queued → running → planned/succeeded/failed — poll the workspace detail; with async off they run inline and return the finished result.
/api/terraform/blueprintsSession or BearerThe deployable blueprint catalog: built-in registry blueprints + enabled platform-managed + your org's own custom blueprints (tier custom). Supports q= and provider= filters.
{ "blueprints": [ { "id": "aws/s3-bucket", "title": "S3 bucket", "provider": "AWS",
"tfType": "aws_s3_bucket", "resourceType": "storage", "fields": [ { "key": "name", "type": "string" } ] } ] }| Status | When |
|---|---|
401 | Not signed in. |
/api/terraform/workspacesmutate_infraCreate a Terraform workspace (folder) from a blueprint against a healthy cloud account — or a BLANK workspace with no blueprint. The criticality sets the approval policy: 'production' (default) requires a second approver; 'standard' lets the plan author self-approve. Optional varBindings bind variables to other workspaces' outputs; gitConnectionId picks which infra repo the workspace hydrates into; autoPipeline:false opts out of the auto-created infrastructure pipeline.
| Name | In | Required | Description |
|---|---|---|---|
blueprintId | body | no | A blueprint id from GET /api/terraform/blueprints (omit for a blank workspace). |
cloudAccountId | body | yes | A healthy cloud account in your org, matching the blueprint's provider. |
name | body | yes | Lowercase letters, digits, hyphens (2–41 chars). |
region | body | no | Deploy region (defaults from the blueprint). |
criticality | body | no | 'production' (default — approver ≠ author) or 'standard' (self-approve allowed). |
varBindings | body | no | { varKey: { fromWorkspaceId, outputKey } } output bindings. |
gitConnectionId | body | no | An infra git connection id (defaults to the org's first writable infra repo). |
{ "blueprintId": "aws/s3-bucket", "cloudAccountId": 3,
"name": "web-assets", "region": "us-east-1", "criticality": "standard" }{ "workspace": { "id": 7, "provider": "AWS", "region": "us-east-1",
"blueprintId": "aws/s3-bucket", "name": "web-assets", "status": "active",
"criticality": "standard", "currentStateVersion": 0 } }| Status | When |
|---|---|
400 | Unknown blueprint, bad name, provider mismatch, unhealthy account, invalid criticality/bindings, or a non-infra gitConnectionId. |
403 | Role lacks mutate_infra (Viewer). |
GET /api/terraform/workspaces lists workspaces; GET /api/terraform/workspaces/{id} returns the detail — runs (with persisted {seq,level,line} logs), members, applied outputs, dependency links, and the blueprint instances the folder combines. PATCH /api/terraform/workspaces/{id} replaces { varBindings }. DELETE /api/terraform/workspaces/{id} with { confirm: "<exact name>" } destroys the managed resources (a workspace whose outputs feed live dependents refuses with 409); DELETE …?purge=1 removes a destroyed workspace entirely and prunes its hydrated directory from the connected repo.
/api/terraform/workspaces/{id}/planmutate_infraCompose the module from the blueprint(s)/members and the submitted variables, run the plan, evaluate policy (no-public-bucket / mandatory-tags / region-allowlist) and estimate the cost delta. Bound variables resolve server-side from the source workspace's applied outputs. The plan is saved on a run; new resources appear in Inventory as drafts (◌ planned). Async deployments return { state: "queued" }.
{ "runId": 12, "state": "planned", "diff": { "add": 1, "change": 0, "destroy": 0 },
"policy": { "pass": true, "checks": [ … ] }, "costDelta": 8.5, "hash": "…" }| Status | When |
|---|---|
400 | Variable validation failed, or a bound output is missing/destroyed (the error names the workspace and available outputs). |
403 | Role lacks mutate_infra. |
409 | A run is already open on this workspace (discard it or finish it). |
/api/terraform/workspaces/{id}/approvemutate_infraApprove a planned run. The policy must pass. On a 'production' workspace the approver must differ from the plan's author (separation of duties, 403 on self-approve); on a 'standard' workspace the plan author may approve their own run.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | Workspace id in your org. |
runId | body | yes | The planned run to approve. |
{ "runId": 12 }{ "runId": 12, "state": "approving", "approverId": 5 }| Status | When |
|---|---|
403 | Role lacks mutate_infra; policy failed; or (production workspace) the approver is the plan's author. |
404 | No such workspace/run in your org (cross-org / unknown). |
/api/terraform/workspaces/{id}/applymutate_infraApply the SAVED, approved plan (integrity-hashed — never re-planned). By default the apply is recorded-only (modeled in inventory + state). It is REAL — OpenTofu runs against your live cloud with freshly-minted short-lived credentials — only when the platform runs CC_TF_RUNNER=real AND the target account has provisioning enabled; otherwise a real-runner apply is refused 409 { provisioningDisabled: true }. A failed apply marks the run failed with its logs, leaves state and inventory untouched, and keeps the workspace usable.
{ "runId": 12, "state": "succeeded", "created": 1, "stateVersion": 1, "outputs": { "bucket_name": "web-assets" } }| Status | When |
|---|---|
403 | Run not approved / role lacks mutate_infra. |
409 | Real runner active but the account has not opted into provisioning, or the blast-radius guard tripped (blastRadius: true — confirm to proceed). |
Also on a workspace: POST …/drift (refresh run comparing applied state to live inventory), POST …/discard (discard the open run and remove its draft rows), GET/POST …/state (list encrypted state versions / roll back to { version }), GET …/hydrated (the composed module — object + rendered native HCL main.tf).
Folders: membership, edits and combined blueprints
/api/terraform/workspaces/{id}/resourcesmutate_infraAssign / MOVE a synced resource INTO this workspace (folder). A workspace is a folder of resources; this reassigns membership and recompiles the target AND the resource's previous folder from all their members, returning both recompiled diffs + any address conflicts.
| Name | In | Required | Description |
|---|---|---|---|
id | path | yes | Target workspace (folder) id in your org. |
nativeId | body | yes | The resource's native id (from GET /api/cloud/resources). |
{ "nativeId": "i-0abc123def4567890" }{ "nativeId": "i-0abc123def4567890", "previousWorkspaceId": null, "targetWorkspaceId": 7,
"to": { "workspaceId": 7, "workspaceName": "web-tier", "memberCount": 1,
"diff": { "add": 1, "change": 0, "destroy": 0 }, "conflicts": [], "committed": true,
"stateVersion": 1, "repoWritten": true }, "from": null }| Status | When |
|---|---|
400 | Missing nativeId / body isn't JSON / the workspace targets a different provider. |
403 | Role lacks mutate_infra (Viewer). |
404 | No such workspace or resource in your org (cross-org / unknown). |
409 | The target workspace has been destroyed. |
/api/terraform/workspaces/{id}/resources/{nativeId}mutate_infraEdit a MANAGED member's settings (Edit-from-Inventory). The edits are validated against a per-type allowlist (e.g. compute instanceType, storage storageClass, network/subnet cidr, routetable routes, tags), staged on the resource, and the folder recompiles into a PLANNED run (diff ~1 to change + policy + Δ$/mo) that the normal approve/apply routes finish.
| Name | In | Required | Description |
|---|---|---|---|
edits | body | yes | Object of allowlisted attribute edits for the resource's type. |
{ "edits": { "instanceType": "t3.large", "tags": { "env": "prod" } } }| Status | When |
|---|---|
400 | A non-allowlisted key, bad shape, or a no-op edit. |
404 | Not a member of this folder in your org. |
409 | Destroyed workspace or a run already open. |
/api/terraform/workspaces/{id}/blueprintsmutate_infraAdd another blueprint to this folder ({ blueprintId, vars }). One workspace can deploy MANY blueprints — the folder composes ALL its blueprint instances into one module, so the next plan shows the addition alongside what it already manages. GET lists the folder's blueprint instances.
{ "blueprintId": "aws/ec2-instance", "vars": { "name": "worker-1" } }| Status | When |
|---|---|
400 | Unknown blueprint, provider mismatch, or bad vars. |
409 | Destroyed workspace or a run already open. |
/api/terraform/workspaces/{id}/resources/{nativeId}mutate_infraRemove a resource from this folder (unmanage it) and recompile the folder — the removed member shows as a delete in the diff. Native ids containing slashes (GCP/S3) can be passed via a ?nativeId= query, which wins over the path segment.
{ "nativeId": "i-0abc…", "previousWorkspaceId": 7, "targetWorkspaceId": null,
"from": { "workspaceId": 7, "diff": { "add": 0, "change": 0, "destroy": 1 }, "memberCount": 0 }, "to": null }| Status | When |
|---|---|
403 | Role lacks mutate_infra (Viewer). |
404 | The resource is not a member of this folder in your org (cross-org / unknown). |
/api/terraform/workspaces/{id}/compilemutate_infraRecompile the folder module from its member resources and return the diff + any conflicts, WITHOUT changing membership — the drift/recompile check. { commit: true } regenerates + commits (new state version + best-effort repo write) when there are no conflicts.
{ "commit": true }{ "workspaceId": 7, "workspaceName": "web-tier", "memberCount": 2,
"diff": { "add": 0, "change": 0, "destroy": 0 }, "conflicts": [], "committed": false, "stateVersion": 3 }| Status | When |
|---|---|
403 | Role lacks mutate_infra (Viewer). |
404 | No such workspace in your org (cross-org / unknown). |
/api/terraform/importmutate_infraImport (adopt) any discovered resource into Terraform — including IAM (roles, policies, users, groups) and, via a generic fallback, any other service: generates real HCL + a Terraform import block (with the correct AWS import id per type — IAM roles/users/groups by name, a managed policy by ARN), records the adopt, marks the resource managed, and best-effort commits the HCL to your connected repo. See the Import existing resources guide.
| Name | In | Required | Description |
|---|---|---|---|
nativeId | body | yes | The discovered resource's native id (from GET /api/cloud/resources, managed:false). |
{ "nativeId": "vpc-0aa1b2c3d4e5f0001" }{ "managed": true, "nativeId": "vpc-0aa1b2c3d4e5f0001", "resourceType": "network",
"tfType": "aws_vpc", "address": "aws_vpc.prod-use1", "mapped": true,
"workspaceId": 7, "workspaceName": "imported-aws-us-east-1", "runId": 12,
"stateVersion": 1, "repoPath": "terraform/imported/aws_vpc-prod-use1.tf",
"repoWritten": true, "commitRef": "63c0111…" }| Status | When |
|---|---|
400 | Missing nativeId / body isn't JSON. |
403 | Role lacks mutate_infra (Viewer). |
404 | No such resource in your org (cross-org / unknown id). |
409 | The resource is already under Terraform management. |
The http state backend (opt-in)
With CC_TF_STATE=turso_http, GET/POST /api/terraform/state/{id} (fetch / persist current state) and POST/DELETE /api/terraform/state/{id}/lock (advisory lock / unlock) serve Terraform's http backend protocol — Store-backed, encrypted per org, org-scoped (cross-org 404). A concurrent lock on a held workspace answers `423 Locked` with the holder's info; every POST appends a state version, preserving history and rollback. The real runner's child tofu authenticates with an ephemeral, workspace-scoped HTTP Basic state token minted per apply (username cc-state); humans use the normal session/Bearer path. With the default CC_TF_STATE=internal these routes are dark (404).