Skip to content
Lumaft documentation contents
Lumaft documentation

Runner integration

Report deployments to Lumaft from CI or a developer machine with lumaft exec or the ingestion API.

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_TOKEN and LUMAFT_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. https is required; http is admitted only for loopback. Certificate verification is never relaxed — the wrapper refuses to run when NODE_TLS_REJECT_UNAUTHORIZED=0 is set. Supply a private CA with NODE_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 exits 64. 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, or update.
  • correlationId: the UUID inside the [lumaft:v1:<uuid>] marker you put in the update message, or null when you injected none.
  • projectName: null for 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, or cancelled.
  • reason: command-failed or policy-denied, only when result is failed; otherwise null. policy-denied requires explicit policy evidence; an exit code alone never establishes it.
  • exitCode: 0255, or null.
  • resourceChanges (schema 2): null for 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.

ScreenshotThe Operations page showing a runner-sourced in-progress row above backend-sourced succeeded rows, with the Evidence column naming each source

Limits of this version

  • No local spool: undelivered evidence is lost and says so.
  • policy-denied requires a mandatory policy event in a fresh engine log.
  • Comparisons require an explicit --correlate pair 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.