The surfaces the Pulumi CLI consumes

September 18, 2026 · View on GitHub

The Pulumi-side counterpart to galaxy-cli-surface.md and tfe-cli-surface.md. Two surfaces, captured separately:

  1. the plugin download surface — what the CLI asks a plugin server for (below);
  2. the service surface — what it asks a state backend for once you pulumi login <https-url>, which is the analogue of Terraform's cloud {} block. That is its own section.

Captured from a real client rather than read from documentation (scripts/pulumi-capture.py), for the reason the Galaxy work established — five of that surface's findings contradicted the obvious reading of the docs, and two were invisible to any synthetic test.

Captured with: pulumi v3.145.0.

The whole protocol is one request

GET {override}/pulumi-resource-random-v4.16.3-linux-amd64.tar.gz

That is it. There is no index, no metadata call and no version list, because the CLI already knows the kind, name, version, OS and architecture before it asks. The template is literally pulumi-%s-%s-v%s-%s-%s.tar.gz inside the binary.

So nothing here needs a TTL. The cache-expiry rule turns on whether a thing can change upstream: a plugin at a version is immutable, and this proxy serves nothing else. There is no mutable listing to go stale because the client never asks for one — worth stating plainly, since every other proxy Terrapod runs has one and the asymmetry looks like an omission otherwise.

Language plugins (pulumi-language-python-v…) use the same shape and are served by the same path.

Pointing the CLI at it

PULUMI_PLUGIN_DOWNLOAD_URL_OVERRIDES takes a comma-separated pattern=url list:

export PULUMI_PLUGIN_DOWNLOAD_URL_OVERRIDES=".*=https://x:$TERRAPOD_TOKEN@terrapod.example.com/api/v1/package-cache/pulumi"

Two traps, both captured

An anchored ^name$ pattern silently does nothing. The pattern is an unanchored regex search against a string wider than the plugin's name, so ^random$ never matches — and when nothing matches, the CLI falls back to get.pulumi.com without a warning. The install succeeds, which is precisely the failure this proxy exists to prevent: it looks like it is working right up until someone has no route out.

Measured against pulumi plugin install resource random:

patternresult
.*matches — everything through Terrapod
randommatches
random$matches
^random$silently falls back upstream
^pulumi-resource-random$silently falls back upstream
awsdoes not match (correctly — different plugin)

Use .* to route everything, or a bare unanchored name per plugin. Do not anchor with ^.

Credentials go in the URL. The CLI sends no Authorization header of its own, but userinfo in the override URL becomes one: http://x:TOKEN@host produces Authorization: Basic …. Terrapod's credential parsing already accepts Basic and takes the password, so the username is ignored — x above is a placeholder.

Version resolution is a separate concern

An unpinned program does not just download a plugin; it first resolves what "latest" means, and that resolution does not go through the plugin download URL:

error: could not find latest version for provider random

So a program intended to build without upstream access must pin its plugin versions — in Pulumi YAML via options.version, and in the language SDKs by pinning the provider package. That is a property of the CLI, not something this proxy can supply, and pinning is good practice in an air-gapped estate anyway.

Integrity

Upstream publishes no digest alongside the tarball, so Terrapod records none. This is weaker than the PyPI and npm proxies, where the client checks our bytes against a digest upstream published, and it is worth being plain about rather than implying a check that does not happen. Pulumi's own verification is that the plugin unpacks and runs.

Deliberately not implemented

Publishing private plugins. The gate asks for pulumi plugin install to succeed with the override set; a publish path can follow if a real need appears, and it would want the same design conversation the Galaxy one did.

The Pulumi service API — pulumi login against Terrapod, stacks, state — is a separate and much larger surface, catalogued and scoped on its own when the Pulumi engine work reaches it (#1407 §12 phase 4).

Reproducing the capture

python3 scripts/pulumi-capture.py [path-to-pulumi]

Set PULUMI_HOME to a fresh directory when testing by hand, or an already-installed plugin makes the capture look shorter than it is.


The service surface

What pulumi asks a state backend for after pulumi login <https-url> — the analogue of Terraform's cloud {} block, and the surface #1407 §2 scopes to "only the slice the CLI consumes".

Captured with scripts/pulumi-service-capture.py against pulumi v3.261.0, by pointing the real CLI at a request-logging stub and answering it until a full up completed: 106 requests, 19 distinct endpoints. A stub that 404s teaches only that the client gave up, so each response was filled in until the CLI walked further — the same method as the four protocol captures before it.

The endpoints

{stack} below is always the triple {org}/{project}/{stack}.

MethodPathPurpose
GET/api/userlogin + whoami; returns the org list
GET/api/user/organizations/{org}org lookup; called constantly
GET/api/capabilitiesfeature negotiation; {"capabilities": []} is accepted
GET/api/user/stacks?project=stack ls
POST/api/stacks/{org}/{project}stack initalways refuses; see below
GET/api/stacks/{stack}stack lookup; 404 means "does not exist"
DELETE/api/stacks/{stack}stack rm
GET/api/stacks/{stack}/exportread state
POST/api/stacks/{stack}/importwrite state wholesale; gzipped; async
POST/api/stacks/{stack}/encryptencrypt a secret — body {"plaintext"}
POST/api/stacks/{stack}/decryptdecrypt one
POST/api/stacks/{stack}/batch-decryptdecrypt many
POST/api/stacks/{stack}/previewbegin a preview
POST/api/stacks/{stack}/updatebegin an update
POST/api/stacks/{stack}/refreshbegin a refresh
POST/api/stacks/{stack}/destroybegin a destroy
POST/api/stacks/{stack}/update/{updateID}start it; must return a lease token
GET/api/stacks/{stack}/update/{updateID}poll status (used by stack import)
PATCH/api/stacks/{stack}/update/{updateID}/checkpointwrite state; gzipped. Held against the update; the last one becomes its state version (#1564)
POST/api/stacks/{stack}/update/{updateID}/events/batchengine events; gzipped
POST/api/stacks/{stack}/update/{updateID}/completeend it — {"status":"succeeded"}
POST/api/stacks/{stack}/update/{updateID}/renew_leaseextend the lease

Three of those rows were not exercised by this first capture. #1571 has since exercised two of them; see the secondary commands:

  • renew_leasenow observed, with body {"token": "", "duration": 300} part-way through a long update. The CLI adopts the token in the response, and Terrapod's renewal returns none, which breaks every long update (#1562).
  • batch-decryptnow observed. Every state command and every read of a deployment with secrets calls it, and Terrapod's map-shaped response works.
  • decrypt (the single-value form) — still not observed.

Who may call what

Every stack is a Terrapod workspace, and every call addressed at one is authorized against that workspace exactly as the Terraform routes are (#1550): owner, label RBAC, platform roles and the everyone floor all apply unchanged. What each call needs:

CallRequires
stack lsworkspace:read on each stack — stacks you cannot read are not listed
stack lookupworkspace:read
stack rmworkspace:delete
stack exportstate:read
stack importstate:write
encrypt, decrypt, batch-decryptstate:read — decrypt returns secret values
previewrun:plan
up, refreshrun:apply
destroyrun:apply-destroy
starting an updatewhatever beginning it required — read from the update, not the URL
polling an updaterun:read
cancelling an update (pulumi cancel)whatever beginning it required — read from the update, not the URL

Without workspace:read, every call answers exactly as it would for a stack that does not exist — the same 404 and the same message — so stack names cannot be probed. With read access but without the capability a call needs, the answer is a 403 naming it.

The in-update calls (checkpoint, events/batch, complete, renew_lease) carry a lease rather than a user. A lease authorizes one update on one stack: it is refused against any other stack, and it is checked before the stack is looked up, so a caller without a valid lease learns nothing about which stacks exist. A preview's lease cannot checkpoint — a preview never writes state, and allowing it would let run:plan buy state:write.

A local update holds the workspace lock (#1562). An up, refresh or destroy from a CLI logged in to Terrapod takes the same workspace lock a Terraform CLI apply takes, for as long as the update runs:

  • The stack shows as locked in the UI, and the run dispatcher will not start an agent apply against it meanwhile.
  • A workspace that is already locked, manually or by another update, refuses the update with a 409 naming the lock.
  • An update is also refused while an agent run's apply is in progress on the stack.
  • A VCS-connected agent workspace refuses a local update entirely, as Terraform refuses a CLI apply there, because its changes come from the repository. It can still be previewed.

Previews take no lock, so any number can run at once. The lock is released when the update completes or is cancelled with pulumi cancel. If the CLI dies instead, its lease lapses after 30 minutes without renewal, and a periodic sweep releases the lock within a minute of that. An operator can also clear it with force-unlock, as for any lock a crashed CLI leaves behind.

One state version per update (#1564). A local update checkpoints the stack many times as it runs. Each checkpoint replaces the one before it and is held against the update, and the last one becomes a single state version when the update ends:

  • when it completes, whatever its status. A failed update keeps its partial state, because that is the only record of what it created;
  • when it is cancelled with pulumi cancel;
  • when its CLI dies, through the same sweep that releases its lock.

An update that changes nothing leaves no version behind. Agent-mode runs have written one state version per state-changing update since #1576, so the two modes now match. Each version records its size, md5, sha256 and who made it, and the current one cannot be deleted, as for Terraform.

pulumi stack rm can be undone. It goes through the same delete as the UI and API, so the stack is listed under deleted workspaces and can be restored with its state history. A deployment carries no serial of its own, so a restored stack numbers its versions from 1, oldest first.

Manual upload takes pulumi stack export output. Upload the file as it is to a Pulumi workspace (POST /api/v1/workspaces/{id}/state-versions/actions/upload). It is stored unwrapped and exports back unchanged. An export made with --show-secrets is refused, because storing it would put the stack's secrets in state in the clear; load that one with pulumi stack import, which seals them first.

Runner tokens are refused. This surface serves the CLI in local mode only. An agent-mode run never uses it: its stack lives in a file backend inside the runner Job, and its state is handed over through the run's artifacts (see how Terrapod runs Pulumi on an agent). Every call made with a runner token is answered 403, whatever route it names (#1576).

stack init never creates a stack (below), and it reports a name as already taken only to someone who can read that stack.

stack init does not create a stack

POST /api/stacks/{org}/{project} is served, but it always refuses: a 404 whose message names where workspaces come from.

Terrapod workspaces are created in the UI, with the Terraform provider, or via POST /api/v1/workspaces — never by an engine's own CLI. Terraform's CLI has never created one (init looks a workspace up and fails if it is absent), and a CLI that could would be bringing a platform resource into being with no RBAC review and no record of where it came from.

So the flow is: create the workspace with engine = "pulumi", then

pulumi stack select default/{project}/{stack}

Name it project::stack. A Pulumi stack is identified by {org}/{project}/{stack} and a workspace has one flat name, so the two halves are joined with :: — a sequence neither a Pulumi project nor a stack admits. That composed name is what stack select resolves to, so a workspace named anything else is invisible to the CLI. Creating one with a single-part name is rejected, rather than accepted and then never found.

resource "terrapod_workspace" "app_dev" {
  name   = "app::dev"     # pulumi stack select default/app/dev
  engine = "pulumi"
}

The route stays mounted rather than being removed so the refusal can carry that instruction — the CLI prints the message verbatim, and an unmounted route would give the operator a bare 404 with nothing to act on.

Pulumi workspaces in the UI

A Pulumi workspace is listed, opened and edited like any other (#1554, #1555), through the native /api/v1/workspaces routes. The TFE-compatible surface serves Terraform alone.

  • List. A Pulumi row carries a Pulumi badge, and its project::stack name is shown as project / stack. An engine filter appears when more than one engine is enabled.
  • Page. It shows the engine and hides the settings that only mean something to Terraform: execution backend, version, Terragrunt and var files. It adds Lock the update to the approved preview (pulumi-bind-plan, off by default; see #1553).
  • Create. The form offers an engine only when more than one is enabled (GET /api/v1/engines). For Pulumi it asks for a project::stack name, which you then select with pulumi stack select default/{project}/{stack}.

A deployment with only Terraform enabled shows none of this: no badge, no filter and no engine picker.

Findings

Auth is token <value>, and it changes mid-run. Not Bearer. Everything addressed at a stack uses Authorization: token <api-token> — but the three calls made during an update (checkpoint, events/batch, complete) use Authorization: update-token <lease> instead, with the lease handed out when the update is started. Two schemes on one surface, and the second is invisible to any test that supplies its own authenticated client.

The service is the stack's secrets provider. POST .../encrypt is not optional colour: in httpstate mode the CLI delegates secret encryption to the backend and calls it during a plain up. This is a real obligation that "state backend" does not imply — Terrapod would have to run an encrypt/decrypt oracle per stack, and own the key that makes stack state readable.

State is a whole document, not a delta. It is read with GET .../export and written with PATCH .../update/{id}/checkpoint, each carrying the entire deployment. Terrapod's existing state-version storage therefore fits without a new merge model. Checkpoint and event bodies are gzipped (Content-Encoding: gzip), so a handler must decompress rather than parse the raw body.

A preview is an update. preview creates an update and then starts it via POST .../update/{updateID} — the same path an up uses. There is no separate preview lifecycle, and the start call must return a lease token: without one the CLI aborts with fatal: An assertion has failed: persisted actions require a token.

An update has an explicit begin and end, so a run that dies mid-update leaves the stack with a started-but-never-completed update — which is exactly what the lease exists to time out.

Concurrency is enforced by refusing to start. There is no lock endpoint. A 409 on the begin call ends the CLI immediately — exit 1, no retry, no wait — and the service's message field is printed verbatim:

error: [0] another update is currently in progress

So Terrapod's existing per-workspace run serialisation maps onto this directly: refuse the begin, and put the explanation in message.

An empty stack's deployment is null. {"version": 3, "deployment": null}. A synthetic empty deployment ({}, or a manifest with no resources) fails the CLI's snapshot integrity check.

The surface does not have to sit at the root. The CLI appends /api/... to whatever base URL it was given, path prefix included — verified by logging in to http://host/api/v1/pulumi and watching it request /api/v1/pulumi/api/user, then serving it successfully from there. This is the question #1484 had to settle for NuGet and it lands the opposite way: Terrapod can mount this natively rather than at the root, which it reserves for the two surfaces genuinely forced there (/v2/ and /.well-known/terraform.json).

Remote execution is not on this surface — but it does exist

Nothing above executes anything. The service is a state store, a secrets oracle and an event sink; the CLI runs the language host, the engine and the providers locally, and events/batch is the CLI pushing what it did rather than a remote run streamed back. The preview and update bodies carry the project's name, runtime, options and config — never the program source — so the service could not execute it even in principle.

Do not read that as "Pulumi has no remote execution". It does: pulumi deployment run <operation> queues a job on Pulumi Cloud, takes --agent-pool-id, and can stream its logs back (--suppress-stream-logs, default true). That lives on the Deployments API, a separate surface from this one, which is why a capture of login / preview / up never touches it.

It is also not the same shape as terraform apply against an agent. Terraform uploads the local working directory, so uncommitted edits execute remotely; deployment run is git-sourced (--git-branch / --git-commit / --git-repo-dir / --git-auth-*), so it deploys committed code and local edits do not participate.

How Terrapod runs Pulumi on an agent

The section above is about what Pulumi's own CLI can drive remotely. Terrapod's agent execution is a different thing and does not use the Deployments API at all: a run is queued in Terrapod, a listener launches a Kubernetes Job, and the Job runs pulumi preview then pulumi up against the fetched configuration — the same shape as a Terraform run, and the same one an operator selects with execution_mode = "agent". The engine does not decide where a run executes.

What the Job arranges that is worth knowing about as an operator:

State stays in the Job, as Terraform's does (#1576). An agent run does not use Terrapod as a live Pulumi backend, and does not call the service surface above at all. The Job runs the CLI against a file backend in its own workspace, the way a Terraform run keeps terraform.tfstate beside its configuration:

  1. At the start, the Job fetches the stack's deployment from GET /runs/{run_id}/artifacts/pulumi-deployment, with its secrets opened. It creates the stack locally with a passphrase that exists only for the life of the Job, and imports the deployment. The CLI stores it sealed under that passphrase.
  2. The preview and the update run against that local stack. Nothing is written to Terrapod while they run: no leases, no checkpoints, no engine events.
  3. After an update, the Job exports the stack with --show-secrets and hands it back once, with PUT to the same path. Terrapod seals the secrets again with its own key and stores the result as the next state version, linked to the run. It does this whether or not the update succeeded, because a failed up can still have created resources. An update that changed nothing hands back nothing, and a preview never hands anything back.
  4. If anything else wrote the stack while the run held it, the hand-back is refused and the workspace is flagged state-diverged, as for a Terraform run whose state upload fails.

What this means for a program:

  • A committed Pulumi.<stack>.yaml keeps working, with one change: its encryptionsalt, secretsprovider and encryptedkey lines are removed from the Job's working copy (never from your repository), because they name a provider the Job's stack does not use.
  • secure: values in that file are not yet supported in agent runs (#1577). They are sealed by the provider the stack used when they were set, and the Job's passphrase cannot open them. A run whose stack file holds any fails early, with a message saying so. Supply those values as workspace variables instead.
  • StackReference does not yet work in agent runs (#1578). It resolves against the Job's own backend, which holds only the run's stack.
  • A stack whose secrets are sealed by a passphrase or cloud KMS — one moved there with pulumi stack change-secrets-provider — cannot be run on an agent, because Terrapod holds no key for it. The run fails, saying which provider is in the way. pulumi stack change-secrets-provider default moves the stack back.
  • PULUMI_BACKEND_URL and PULUMI_CONFIG_PASSPHRASE set as workspace variables are overridden. Agent mode owns the backend, as it does Terraform's.

The binary is fetched, not baked in. pulumi is pulled through the same cache that serves tofu/terraform, and the version is the workspace's engine-version — per workspace, exactly as a Terraform version is. Partial versions work: 3.208 means the newest 3.208.*. A workspace that pins none gets default_pulumi_version from your values. If the cache cannot supply the binary the run fails rather than falling back to whatever pulumi might be on the image.

Nothing reaches for Pulumi Cloud. The backend is a directory in the Job, set through PULUMI_BACKEND_URL, so there is no pulumi login to perform. Plugin downloads are redirected to Terrapod's package cache, which the run's own short-lived token authenticates to. Both matter most in an air-gapped deployment, where the CLI's defaults would otherwise reach for app.pulumi.com and get.pulumi.com and simply hang.

The update is not bound to the preview unless you ask. By default the preview saves nothing and the update is a plain pulumi up, which works out its changes afresh — the way Pulumi is normally run in CI, with the preview there for a person to review. A workspace can opt in with pulumi-bind-plan (#1553): the preview then saves its plan (--save-plan), the plan is carried to the update's pod, and pulumi up --plan refuses any operation the approved preview did not show. A saved plan carries its secrets sealed under the preview's stack key, so the key travels with the plan and the update's stack is made with the same one. That leaves a plan's secrets as exposed as a Terraform plan file leaves its sensitive values, which it holds in the clear. It is off by default because Pulumi's update plans are still marked experimental upstream, and an open bug (pulumi/pulumi#17546) makes them fail spuriously when cloud credentials are resolved during the preview — exactly how a Terrapod runner gets its credentials. Either way, Terrapod refuses to confirm an approved run whose stack state has moved since its preview (#647).

Nothing from the management surface was required

A full login → stack init → stack ls → preview → up → refresh → export → destroy → stack rm cycle completed without touching Deployments, Policy Packs, Insights, Environments/ESC, Webhooks, Registry or organisation management — confirming the §2 scope. /api/capabilities returning an empty list is accepted.

That capture predates #1535: stack init now refuses, and the workspace is created in Terrapod first with stack select in its place. The rest of the cycle — which is what this section is about — is unchanged.

The secondary commands (#1571)

The main lifecycle above was captured in #1502. This section covers the rest of what a Pulumi user runs day to day. It was captured with scripts/pulumi-secondary-capture.py against CLI v3.262.0, in two passes:

  • Stub pass: a recording stub, which shows what the CLI sends.
  • Live pass: a real Terrapod behind its BFF, which shows what Terrapod answers.

"Live" statuses are the ones Terrapod returned on a Tilt stack.

CommandWhat the CLI sendsWhat Terrapod answers todayWhat it should answer
stack historyGET …/updates?pageSize=10&page=1404 — the command failsThe workspace's runs and updates, newest first
stack tag set / tag rmPATCH …/tags, whose body is the complete tag map, including pulumi:project and pulumi:runtime — it replaces, it does not merge404A decision on how stack tags relate to workspace labels, then a route
stack tag lsnothing new — it reads tags from GET on the stackworks (labels are returned as tags)
stack renamePOST …/rename with {"newName", "newProject"}404A rename of the workspace to newProject::newName, subject to the same validation as any rename
state protect, unprotect, delete, editGET …/exportbatch-decryptencryptPOST …/import → poll GET …/update/{id}works — all four round-trip through export and import
change-secrets-provider passphraseexport → batch-decrypt → importworks
change-secrets-provider default (back to the service)encrypt, then export → importFixed by #1573: encrypt is byte-safe. Before that it returned 500 (see below), and the stack was left on the passphrase provider
change-secrets-provider awskms://…a KMS call made by the CLI itself; the service only sees GET on the stacknothing to serveNothing: KMS credentials belong wherever the CLI runs
lease renewalPOST …/update/{id}/renew_lease with {"token": "", "duration": 300}, part-way through an update of a few minutesFixed by #1562: the lease comes back, and the update record and stack lock are extended to match. Before that, 200 {} with no token
cancelGET on the stack, to read its activeUpdate; then POST …/update/{activeUpdate}/cancel with an empty bodyFixed by #1562: the stack reports activeUpdate while an update runs, and the cancel route ends it and releases the stack. Before that, the CLI stopped at "stack has never been updated"

Three findings the table understates.

  • Every update longer than a few minutes fails. The CLI renews its lease part-way through and uses the token in the response. Terrapod's renewal returns none, so every call after it carries an empty lease and gets a 401. The update then ends with "this command requires logging in". Its complete never lands, so the stack lock stays held until the lease runs out: 30 minutes in which the next update is refused with a 409. The live pass hit exactly this on a 200-second update. Fixed by #1562, which returns the lease from the renewal.
  • encrypt is not byte-safe. The CLI encrypts binary values, not only text. Terrapod decodes the plaintext as UTF-8 with surrogateescape, and the encryption layer then fails to encode it. The result is a 500 (UnicodeEncodeError) that the CLI retries four times at the start of every up, and a hard failure when moving a stack back to the service's own secrets provider. decrypt has the mirror-image problem. Fixed by #1573: values are sealed as bytes. The encryption layer is handed their base64, and decrypt and batch-decrypt return the exact bytes. The sealed value is marked outside the envelope (terrapod-bytes:v1:), so anything sealed before the fix is still read the old way.
  • A Pulumi workspace cannot be deleted through the native API. DELETE /api/v1/workspaces/{id} still goes through the Terraform-only lookup, so it answers 404. That is how the live pass's cleanup failed. It is the delete half of #1554.

The passphrase and cloud-KMS providers can no longer be chosen at stack init, which refuses (#1535). They are reached with change-secrets-provider on a workspace that already exists, and the rows above cover that path.

Reproducing the service capture

python3 scripts/pulumi-service-capture.py                 # the main lifecycle
python3 scripts/pulumi-secondary-capture.py               # the secondary commands, against a stub
python3 scripts/pulumi-secondary-capture.py \
  --backend https://terrapod.local --token "$TOKEN"       # …and what a real Terrapod answers

The live pass creates the workspace proj::capture through the native API and tries to delete it afterwards. Until the native delete serves Pulumi workspaces, that clean-up fails and the workspace has to be removed by hand.

Requires Docker. Re-run it against a new CLI rather than assuming this still holds; that is what the script is for.