Skip to content

Runner deployment (containers + Kubernetes)

@hellmai/lumenflow-host’s deployment/ module provides builders for Docker and Kubernetes deployment plans. The builders emit launch configuration; they do not build or publish the runner image supplied to the plan.

For Linux, the documented setup path uses the pnpm compute:connect, pnpm compute:doctor, and pnpm compute:run commands from an existing LumenFlow operator checkout. It requires bubblewrap and a systemd --user service; compute:service installs that service. The Windows and macOS workspace-generated hosted bootstrap remains a test/diagnostic path. Windows/macOS installers and customer-installable Linux packages require their own published artifacts, checksums or signatures, and relevant platform/service smoke evidence.

compute:service installs the connected runner as a long-running Linux service, so a host serves assignments across logouts, crashes and reboots without a hand-written unit. It renders the same service plan the other builders use.

  1. Node 26. Install the supported Node 26 line. The unit records the absolute path of the node that ran install.
  2. Confinement. Install bubblewrap (bwrap on PATH) and enable unprivileged user namespaces. compute:doctor reports the confinement posture; linux-default is the production posture.
  3. The CLI package. Configure the registry grant issued for the @hellmai scope (credential in your user npm configuration, never in a repository), then install @hellmai/lumenflow-cli.
  4. Connect. compute:connect (local mode with --workspace-root, or HTTP mode with --base-url and --token-env). It stores runtime.json at mode 0600.
  5. Doctor. compute:doctor --runner-dir <dir> must pass.
  6. Approve the runner capabilities and posture in the workspace.
  7. Install. --operator-approved is an operator attestation that the capabilities are approved in the workspace; nothing verifies it automatically. Session registration is opt-in (--register-session): use it only when the control plane resolves registration and claim to one session key, because otherwise an unsolicited registration can shadow the runtime-owned session and every claim fails. With it, export the token first; it is read from the environment and written once to an environment file at mode 0600, never an argument and never in the unit.
export LUMENFLOW_CLOUD_TOKEN=...   # only with --register-session
pnpm compute:service install --runner-dir <dir> --operator-approved [--register-session]
pnpm compute:service status --runner-dir <dir>

Add --dry-run to print the unit and environment-file plan without writing anything. On a headless host run loginctl enable-linger <user> once so the user service starts at boot.

CommandWhat it does
compute:service installWrites the unit and environment file, enables and (re)starts the service. Run it again to resume after a drain or to pick up a newer CLI.
compute:service statusUnit state, restart count and the full compute:doctor report. Exits 0 only when the unit is active and doctor passes.
compute:service drainFinishes the assignment in progress, claims no more work and stops the service.
compute:service uninstallDisables and stops the service, removes the unit, environment file and drain marker, and leaves runtime.json.

Scope: systemd --user is the default and runs unprivileged. --system installs a system unit; it needs root and --service-user <name> (root is refused as the service user). In system scope root never loads code from, or opens a path in, anything the service user can modify: install the CLI to a root-owned prefix such as /opt (every ancestor root-owned and not writable by others, or install refuses), keep the runner directory owned by the service user, and add --read-write-path <dir> for any other directory the runner must write. The doctor runs as the service user, output goes to the journal, and drain uses systemctl disable --now. Root runs id, runuser and systemctl only from /usr/bin, /bin, /usr/sbin or /sbin when root-owned (never from PATH), executes the validated real path of the code, and refuses root or an unknown name as --service-user on every verb. On macOS and Windows install --dry-run prints the launchd or Scheduled Task plan, and every other action refuses with a named reason.

The unit runs compute-run with no iteration cap and Restart=on-failure, RestartSec=10, bounded by StartLimitBurst=5 per 600 seconds. A crash or non-zero exit restarts after ten seconds. A clean exit 0, which is what a drain and systemctl stop produce, stays stopped, so a drained runner is not resurrected. The claim loop never exits on a degraded claim state, so a refused claim cannot loop; the start limit bounds a crash at start-up. A drain is checked between claim iterations: the assignment already claimed completes first, and a second SIGTERM exits at once.

The token is stored only in the environment file (mode 0600, created exclusively and renamed into place, symlinks refused). It does not appear in argv, the unit text, compute:service output or the environment of the systemctl children, and the runner removes it from its own environment after registering, so the processes it spawns do not inherit it. Logs go to the journal (journalctl). The installer refuses a runner or unit directory that another user can write, and refuses a token value that could inject a second variable. The full threat model is in the runner host deployment view in the engineering docs.

Use a container when you have no sudo, cannot create a service user and must not run the runner as your own login (it would inherit your cloud, git and SSH credentials). The container is the isolation boundary, and the bubblewrap sandbox still runs inside it.

import {
  buildContainerRunnerDeploymentPlan,
  renderRunnerComposeFile,
} from '@hellmai/lumenflow-host';

const plan = buildContainerRunnerDeploymentPlan({
  image: 'registry.example.com/your-team/connected-runner:tested-tag', // replace with your qualified runner image
  runnerDir: '/var/lib/lumenflow-runner',
  sessionId: 'session-1',
  credentialSecretName: 'lumenflow-runner-credential',
  tokenEnvFile: '/path/to/runner.env', // mode 0600, read by the engine, never rendered
  profilesDir: '/path/to/runner-profiles',
  internalNetworkName: 'runner-egress', // optional, see Egress
});

plan.lifecycle.run; // "docker run -d --name lumenflow-connected-runner --user 10001:10001 --read-only ..."
plan.ports; // [] -- never publishes a port
plan.isolationClass; // "container"
renderRunnerComposeFile(plan); // the same plan as a compose file

The plan always runs the container non-root (10001:10001), with a read-only root filesystem, --cap-drop ALL, no-new-privileges, a pids limit, a memory limit, the journald log driver, a tmpfs /tmp, one named state volume and the bootstrap credential as a read-only named volume. It never publishes a port and never mounts a host home, credential directory or container-engine socket; the builder refuses a credential source that is a host path or a socket.

The @hellmai/lumenflow-host package ships the recipe in its container-profiles directory:

FilePurpose
DockerfileNode 26 plus bubblewrap, a fixed non-root user, the CLI package copied root-owned. Build with docker build --target runner --build-context cli=<dir with node_modules/@hellmai/lumenflow-cli> ..
lumenflow-runner (container security profile)The container security profile for the runner. Load it as the steps below describe.
compose.yamlThe plan rendered as a compose service.

Load the runner’s container security profile once, as root, before starting the container, as the recipe describes. The engine refuses to start the container when the profile is not loaded. Run the plan’s lifecycle.preflight gate first: it exits non-zero unless the engine reports the container security support the runner needs. The plan’s lifecycle.run chains it before docker run; with compose, run the gate yourself before docker compose up, and do not start the container otherwise. The credential volume must hold runtime.json before the first start. Create the named volume, then write the file into it from your own secret store with a throwaway container (mode 0600, owner 10001:10001); the container refuses to start against an empty volume. Put the registration token in a mode 0600 file and pass it as the tokenEnvFile; accept the licence terms yourself in the same file when the runner first starts, because the recipe never sets that for you.

docker stop --timeout 900 (stop_grace_period: 900s in compose, --stop-timeout 900 on the run line, terminationGracePeriodSeconds: 900 on the Kubernetes pod) is a drain: the runner finishes the assignment in progress and exits 0 (the plan passes --drain-file <runner-dir>/drain.request, without which compute-run would exit 143 at once), and the on-failure restart policy does not restart a clean exit.

With internalNetworkName the runner joins an engine network created with --internal and reaches only its peers there. Run a forward proxy on that network that allows the control plane and your allowlisted hosts, and set egress.httpsProxyUrl. The proxy is yours to supply. Without an internal network the runner uses the engine default network and outbound restriction is your firewall. Commands the runner executes are network-denied by the sandbox itself.

Operator values rendered into the run line are held to exact allowlists. Egress values (httpsProxyUrl, each noProxy entry, customCaBundlePath) accept only letters, digits and _ @ % + = : , . / -. tokenEnvFile and profilesDir are paths and accept only letters, digits and _ . / - (relative paths allowed, never a leading -). runnerDir must be an absolute path of the same alphabet. internalNetworkName and containerName are plain resource names (letters, digits, _ . -), which excludes the name=host, container: and none network forms. Kubernetes namespace and runnerName are DNS-1123 names. The ${VAR:?message} compose interpolation fields (image, sessionId) are exempt from the alphabet and were verified inert. Also refused: ? & in URLs, raw ! # $ in passwords (percent-encode them), spaces, ~, backslash (Windows-style) paths, @ in a path (copy the profile files to a plain directory rather than pointing profilesDir through a scoped package under node_modules/@…), and dots in Kubernetes names. Consequences: no IPv6 literal proxy hosts (use a hostname), no wildcard NO_PROXY entries such as *.internal, and proxy passwords must be percent-encoded.

  • buildx. Building the image uses --build-context, which needs the docker buildx plugin. Ubuntu’s docker.io package does not ship it: install docker-buildx. The plan’s lifecycle.buildPreflight fails with a clear message when it is missing.
  • Some Linux distributions. Unprivileged user namespaces can be restricted by default, so the sandbox cannot start until the lumenflow-runner profile is loaded. Load the profile; do not disable the host setting.

Linux with Docker Engine, or Kubernetes, is the verified target. The plan’s preflight refuses any engine that does not report the container security support the runner needs, which includes Docker Desktop on macOS and Windows. macOS and Windows operators use the native compute:service runner instead. macOS and Windows operators can install the native runner from the plan that compute:service install --dry-run prints (compute:service performs host actuation on Linux with systemd only; on macOS and Windows the plan is for the operator to apply by hand), but local tool execution (local:tool_exec) refuses with “unsupported platform” on any non-Linux host, so confined tool execution is Linux-only today. Tool work therefore needs a Linux host (the native compute:service runner) or a Linux virtual machine that meets the same requirements. The check is a POSIX shell pipeline that needs awk, and it fails closed without them. Not covered: SELinux hosts, rootless Podman, Windows containers. The shipped compose file uses the engine’s default network, which reaches the host’s listening services and, on a cloud VM, the metadata endpoint; use an internal network where those hold credentials. docker compose config prints the env file contents, so keep its output out of logs.

import { buildKubernetesRunnerDeploymentPlan } from '@hellmai/lumenflow-host';

const plan = buildKubernetesRunnerDeploymentPlan({
  image: 'registry.example.com/your-team/connected-runner:tested-tag', // replace with your qualified runner image
  runnerDir: '/var/lib/lumenflow-runner',
  sessionId: 'session-1',
  credentialSecretName: 'lumenflow-runner-credential',
  namespace: 'lumenflow',
});

plan.deployment.spec.replicas; // 1 -- single owner at a time
plan.networkPolicy.spec.policyTypes; // ['Egress'] -- Ingress is never a policy type

The generated plan contains a single-replica Deployment, no Service or container ports field, a PersistentVolumeClaim, and an egress-only NetworkPolicy. It mounts the runtime.json key of the credential Secret as a single read-only file at <runnerDir>/runtime.json, where the runner reads it, and keeps runner state on the claim across a pod reschedule. A subPath mount is not refreshed in place, so rotate the credential by restarting the pod.

The pod has the same hardening as the container plan: non-root user 10001, readOnlyRootFilesystem, every capability dropped, allowPrivilegeEscalation: false, the container security profiles shipped with the recipe, a memory limit, a memory-backed /tmp, no service-account token, and the user-namespace and mount settings the sandbox needs. A pod spec cannot set a pids limit, so the plan lists nodeRequirements: install the container security profiles on every node that can run the pod, and set the kubelet podPidsLimit. These are generated manifest properties; the builder does not validate the supplied image or prove that a cluster enforces the manifests as intended, and the profile field needs a minimum cluster version: on an older cluster that field is dropped. A node that cannot enforce the profile rejects the pod at admission, so enable the profile support on every candidate node. A cluster without podPidsLimit has no pid bound at all; the memory limit is then the only bound on a fork bomb.

Both builders generate the same compute-run argument list as the connected runner service plan: --experimental, --runner-dir, --session-id, and --register-session. This is source-level configuration parity. It does not qualify a published image, native installer, or platform deployment.

The plans also accept optional proxy, NO_PROXY, and custom CA settings and render them as environment entries. Use the generated command or manifests as the starting point for the deployment you operate.

The compute-run claim loop uses adaptive cadence for idle, transient, and permanent claim outcomes. --poll-interval sets the base cadence, not a fixed poll rate. Each claimed assignment uses a lease token and a separate fencing generation for its lifecycle operations. A stale generation fails closed after an ownership takeover and requires a new claim. These are compute-run runtime contracts, not evidence about a particular published artifact.

For the complete outcome table and server-hint precedence, see Connected Compute contracts.

On claim-loop state changes, the runner writes runner-status.json under its runner directory. The generated container volume and Kubernetes claim provide a persistent state path when their plans are applied with compatible runner images and storage. compute:doctor --experimental reads the status from the configured runner directory. A missing status file is neutral; malformed data fails the check; a degraded state fails it; backoff and stale states warn.

Recovering from a capability_mismatch degraded state

Section titled “Recovering from a capability_mismatch degraded state”

A capability_mismatch means the runner’s effective claim capability set does not match the capability set registered for its session. The claim loop makes at most one automatic re-registration attempt for each degraded transition when a session registration token is configured, then retries the claim.

To remediate the mismatch, re-register the session with the claim capability set using --register-session and --session-token-env (default LUMENFLOW_CLOUD_TOKEN), or narrow --capabilities to the already registered session when no registration token is available. The status message reports why recovery did not complete, such as a missing token, failed re-registration, or a mismatch that persisted after retry.

compute:doctor reports the degraded code, server message, next poll time, age, and remediation. For bad_credential, re-enrol with lumenflow compute connect; unknown degraded codes advise checking the runner journal and re-registering or re-enrolling if the condition persists. A status file older than one hour is reported as stale but does not fail the check by itself. A missing file reports that no runner has reported yet, while an unparseable file fails with its parse error.