Runner integration
Reading a backend tells Lumaft what the backend recorded. It cannot tell Lumaft that a run started, that it was cancelled, or that a preview happened, because Pulumi writes none of that to state. Runner integration closes the gap: the deployment reports its own operation to Lumaft as it runs.
Two ways in, with equal standing:
lumaft exec— a thin wrapper that runs your Pulumi command and reports for you. One prefix word, three settings.- The HTTP contract underneath it, documented below in full. A pipeline that calls it directly submits evidence exactly as valid as the wrapper's.
Runner integration is optional. A backend-only installation retains complete operation history; runners add previews, lifecycle states, and provenance.
The promise
Lumaft being unavailable never changes the Pulumi command's exit code and never waits indefinitely. Evidence-delivery failure is reported separately, as a warning. The wrapper enforces that structurally: configuration is resolved before anything is spawned, the announcement runs beside the deployment rather than in front of it, every delivery is bounded and then abandoned, and the exit code comes from the child process alone.
What you see when Lumaft is down:
lumaft exec: evidence delivery failed. The deployment ran unchanged and this warning did not alter its exit code.
lumaft exec: announce: network-unreachable
There is no local spool. Undelivered evidence is lost and says so; the backend observer still records the applying run, visibly without the wrapper's assertions.
Before you start
Ask a Lumaft administrator for an integration token scoped to the stacks you deploy. The procedure is in Integration tokens. The token:
- carries exactly one permission,
operations:evidence:write; - is scoped to explicit backend, project, and stack patterns;
- can never read state, history, or transcripts, and is not a session;
- is shown once at issuance and stored only as a digest.
Install lumaft exec
The CLI is a tarball attached to the same release as your image. The release record binds its SHA-256, size, and version to the image, so verify before installing. Node.js 26 and npm are required; the package has no runtime dependencies and no install scripts.
set -eu
LUMAFT_RELEASE_VERSION='<version>'
curl --fail --location --proto '=https' --max-filesize 262144 \
"https://raw.githubusercontent.com/dekglas/lumaft/v${LUMAFT_RELEASE_VERSION}/releases/${LUMAFT_RELEASE_VERSION}/release.json" \
--output lumaft-release.json
curl --fail --location --proto '=https' --max-filesize 1048576 \
"https://github.com/dekglas/lumaft/releases/download/v${LUMAFT_RELEASE_VERSION}/lumaft-cli-${LUMAFT_RELEASE_VERSION}.tgz" \
--output "lumaft-cli-${LUMAFT_RELEASE_VERSION}.tgz"
node --input-type=module - "$LUMAFT_RELEASE_VERSION" <<'NODE'
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
const version = process.argv[2];
const record = JSON.parse(readFileSync('lumaft-release.json', 'utf8'));
assert.equal(record.version, version);
assert.equal(record.cli.version, version);
assert.equal(record.cli.archive, `lumaft-cli-${version}.tgz`);
const archive = readFileSync(record.cli.archive);
assert.equal(archive.length, record.cli.bytes);
assert.equal(`sha256:${createHash('sha256').update(archive).digest('hex')}`, record.cli.digest);
NODE
npm install --global --offline --ignore-scripts --no-audit --no-fund \
"./lumaft-cli-${LUMAFT_RELEASE_VERSION}.tgz"
lumaft --version
Stop if download or verification fails. Retain the verified tarball as a CI artifact; do not replace it with an unversioned registry install — no npm-registry package is published.
Configure
export LUMAFT_URL=https://lumaft.example.com
export LUMAFT_TOKEN=lumaft_it_...
export LUMAFT_BACKEND_ID=production
| Setting | Flag | Meaning |
|---|---|---|
LUMAFT_URL |
— | Base HTTPS address of the installation |
LUMAFT_TOKEN |
— | The integration token |
LUMAFT_TOKEN_FILE |
— | Alternative: a file inside your home directory, outside the working tree |
LUMAFT_BACKEND_ID |
--backend |
The backend id the stack belongs to |
LUMAFT_STACK |
--stack |
Stack name; read from the command's own --stack when omitted |
LUMAFT_PROJECT |
--project |
Project name; read from Pulumi.yaml when omitted |
LUMAFT_STACK_LAYOUT |
--layout |
project-scoped (default) or legacy |
LUMAFT_CAPTURE |
--capture |
none (default) or transcript |
- Credentials. Set exactly one of
LUMAFT_TOKENandLUMAFT_TOKEN_FILE. A token file outside your home directory, or inside the working tree, is refused. The wrapper removes both variables from the environment it hands to Pulumi, so the token never reaches a Pulumi program or provider. - Transport.
httpsis required;httpis admitted only for loopback. Certificate verification is never relaxed — the wrapper refuses to run whenNODE_TLS_REJECT_UNAUTHORIZED=0is set. Supply a private CA withNODE_EXTRA_CA_CERTS. Redirects are not followed and proxy variables are not honored. - Exit codes before the command starts. Misconfiguration exits
78; an unreadable invocation exits64. Both spawn nothing. Once the command starts, the exit code is always the command's own.
Use it
lumaft exec -- pulumi up --yes
lumaft exec -- pulumi preview --stack dev
lumaft exec -- pulumi up --message "ship the rate limiter" --yes
lumaft exec --backend production --stack prod -- pulumi destroy --yes
Supported commands: up, update, preview, refresh, destroy, import.
The wrapper appends a marker to the update message — ship the rate limiter [lumaft:v1:0b6d…]
— and preserves your text exactly. The marker survives into pulumi stack history, which is
how Lumaft joins the runner's assertion with the backend record it observes later. A preview
gets no marker, because a preview writes no history record.
If the marker is stripped, the wrapper is not used, or history is pruned before Lumaft sees it, nothing breaks: both sources stand as independent, labelled operations.
Resource-change counts
To report how many resources an operation touched, ask Pulumi for a fresh event log and let the wrapper read it after the command ends:
lumaft exec -- pulumi up --yes --skip-preview --event-log /tmp/up-$(date +%s).jsonl
The path must be new for that command, a regular file, at most 16 MiB and 100,000 events. Only normalized counts reach Lumaft; no diagnostic or resource payload is uploaded. Without a usable log, the row reads Changes not reported rather than a fabricated zero.
Planned versus actual
Pair a preview with the apply that followed it:
lumaft exec -- pulumi preview --event-log /tmp/preview-$(date +%s).jsonl
# prints the accepted evidence UUID
lumaft exec --correlate <preview-evidence-uuid> -- pulumi up --yes --skip-preview --event-log /tmp/up-$(date +%s).jsonl
The console then shows planned and actual counts side by side. Lumaft never infers a pair from timestamps, stack names, or similar counts; the explicit pair is the only trusted correlation.
CI examples
GitHub Actions
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
with:
node-version: 26
- name: Install the verified Lumaft CLI
run: npm install --global --offline --ignore-scripts ./lumaft-cli-${LUMAFT_RELEASE_VERSION}.tgz
- name: Deploy
env:
LUMAFT_URL: https://lumaft.example.com
LUMAFT_TOKEN: ${{ secrets.LUMAFT_TOKEN }}
LUMAFT_BACKEND_ID: production
PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }}
run: lumaft exec -- pulumi up --yes --stack prod
The step fails exactly when pulumi up fails and passes exactly when it passes. Lumaft's
availability changes neither.
GitLab CI
deploy:
image: node:26
variables:
LUMAFT_URL: https://lumaft.example.com
LUMAFT_BACKEND_ID: production
script:
- npm install --global --offline --ignore-scripts ./lumaft-cli-${LUMAFT_RELEASE_VERSION}.tgz
- lumaft exec -- pulumi up --yes --stack prod
Set LUMAFT_TOKEN as a masked, protected CI/CD variable. Do not put it in .gitlab-ci.yml.
Both examples assume the download-and-verify steps above ran earlier in the pipeline and left the pinned archive in the job's working directory.
The HTTP contract
Everything the wrapper does goes through three routes. Authentication is
Authorization: Bearer lumaft_it_… on every request; a browser session cannot reach this surface
at all. Content type is application/json, requests are capped at 1 MiB, and a token is limited
to 60 requests per minute per serving process.
Announce
POST /api/v1/ingestion/operations
{
"schemaVersion": 1,
"idempotencyKey": "3f1c2b4a-5d6e-4f70-8a91-b2c3d4e5f607",
"correlationId": "0b6d4f2a-1c3e-4a5b-8d9f-0e1a2b3c4d5e",
"kind": "update",
"startTime": "2026-08-18T12:00:00.000Z",
"target": { "backendId": "production", "projectName": "platform", "stackName": "prod" }
}
kind:destroy,import,preview,refresh,rename,resource-import, orupdate.correlationId: the UUID inside the[lumaft:v1:<uuid>]marker you put in the update message, ornullwhen you injected none.projectName:nullfor a legacy-layout backend.- Timestamps are exactly
YYYY-MM-DDTHH:MM:SS.mmmZ. Every field is required; unknown fields are rejected.
Returns 201 {"operationId":"…","status":"accepted"}, or 200 with "status":"replayed" when
the same idempotency key and content were already accepted.
Heartbeat
POST /api/v1/ingestion/operations/{operationId}/events
{
"schemaVersion": 1,
"events": [
{
"eventId": "9c8b7a65-4321-4098-b765-43210fedcba9",
"kind": "heartbeat",
"sequence": 1,
"assertedAt": "2026-08-18T12:01:00.000Z"
}
]
}
Send one at least every five minutes: the activity lease is five minutes, and an operation whose
lease expires without a finalize reads incomplete. Up to 100 events per request and 1,000 per
operation. Twenty-four hours after the announce, the operation stops accepting assertions and
returns 409 operation-expired; it keeps reading incomplete.
Finalize
POST /api/v1/ingestion/operations/{operationId}/finalize
{
"schemaVersion": 2,
"result": "failed",
"reason": "command-failed",
"exitCode": 1,
"endTime": "2026-08-18T12:05:00.000Z",
"resourceChanges": [
{ "operation": "create", "count": 1 },
{ "operation": "delete", "count": 2 }
]
}
result:succeeded,failed, orcancelled.reason:command-failedorpolicy-denied, only whenresultisfailed; otherwisenull.policy-deniedrequires explicit policy evidence; an exit code alone never establishes it.exitCode:0–255, ornull.resourceChanges(schema 2):nullfor unavailable,[]for explicitly zero, or rows over the closed action vocabulary (create,update,delete,replace,same,read,refresh,import, and their replacement variants). Schema 1 omits the field and reads as Changes not reported.
Returns 200 {"operationId":"…","outcome":"failed","status":"accepted"}.
Correlate
POST /api/v1/ingestion/correlations
{
"schemaVersion": 1,
"plannedEvidenceId": "…",
"actualEvidenceId": "…",
"externalId": "build-4821"
}
Pairs a preview with an actual operation on the same stack. Both sides must be inside the
token's scope. An assertion has one role in one trusted pair; a reassignment attempt preserves
the original and returns conflict.
Failures
Every failure is one closed reason, {"error":"<reason>"}, and never echoes your payload.
| Status | Reason | Retry? |
|---|---|---|
| 400 | invalid-request, unsupported-schema-version |
No |
| 401 | unauthorized |
No; the token is bad |
| 404 | not-found |
No; out of scope or nonexistent, indistinguishably |
| 409 | conflict, operation-expired, operation-finalized |
No |
| 413 | request-too-large |
No |
| 422 | clock-skew-rejected, event-limit-exceeded, operation-event-limit-exceeded |
No |
| 429 | rate-limited |
Yes, with backoff |
| 503 | unavailable |
Yes, with backoff |
Announce, heartbeat, and finalize are idempotent, so a retry after an uncertain failure is safe. Source timestamps are flagged past ±5 minutes of skew and rejected past ±24 hours.
Writing your own client
If you call the API yourself, the promise is yours to keep. The shape that keeps it:
#!/usr/bin/env bash
set -uo pipefail # deliberately not -e: the deployment owns the exit code
marker="[lumaft:v1:$(uuidgen | tr 'A-Z' 'a-z')]"
report() { curl --silent --show-error --max-time 5 --fail-with-body "$@" >/dev/null 2>&1 || \
echo "evidence delivery failed" >&2; }
report -X POST "$LUMAFT_URL/api/v1/ingestion/operations" ... # announce; capture operationId
pulumi up --yes --message "deploy $marker"
status=$?
report -X POST "$LUMAFT_URL/api/v1/ingestion/operations/$operation_id/finalize" ...
exit "$status"
The load-bearing details: --max-time on every call, a reporting function that can only warn,
and exit "$status" at the end. A client that lets a failed evidence call abort the script turns
a Lumaft outage into a failed deployment, which is the one outcome this integration exists to
prevent.
Transcript capture
The wrapper can also stream the command's stdout and stderr to Lumaft as an encrypted, redacted,
bounded transcript. It is off by default and needs three separate opt-ins: a nonzero transcript
retention window on the installation, a token an administrator explicitly permitted to capture,
and --capture=transcript on the invocation. The server additionally needs its external
encryption key and storage headroom.
Capture never changes the command's exit code, keeps no local file, and admits at most 512
chunks, 8 MiB, and four hours per operation. Redaction is applied on the runner and again on the
server, and it cannot find every secret; treat transcripts as sensitive. Reading one requires the
distinct transcript-reader role. The installation-side configuration is in
Deployment and setup.
What you get
Runner-sourced operations appear in Operations labelled Runner (deployment runner) or
Runner (developer workflow), with in progress, cancelled, and incomplete outcomes that
backend records alone cannot express. A wrapped update that Lumaft also observes from the
backend appears as two labelled rows; Lumaft stores the trusted join but does not merge them in
this build.
Limits of this version
- No local spool: undelivered evidence is lost and says so.
policy-deniedrequires a mandatory policy event in a fresh engine log.- Comparisons require an explicit
--correlatepair and retained counts on both sides. - Actor labels, commit, branch, and build URL are not yet accepted.
- Install the verified release tarball; there is no registry package.