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.
Run the runner as an OS service
Section titled “Run the runner as an OS service”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.
Fresh Linux host, end to end
Section titled “Fresh Linux host, end to end”- Node 26. Install the supported Node 26 line. The unit records the
absolute path of the
nodethat raninstall. - Confinement. Install
bubblewrap(bwraponPATH) and enable unprivileged user namespaces.compute:doctorreports the confinement posture;linux-defaultis the production posture. - The CLI package. Configure the registry grant issued for the
@hellmaiscope (credential in your user npm configuration, never in a repository), then install@hellmai/lumenflow-cli. - Connect.
compute:connect(local mode with--workspace-root, or HTTP mode with--base-urland--token-env). It storesruntime.jsonat mode 0600. - Doctor.
compute:doctor --runner-dir <dir>must pass. - Approve the runner capabilities and posture in the workspace.
- Install.
--operator-approvedis 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.
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.
Operate
Section titled “Operate”| Command | What it does |
|---|---|
compute:service install | Writes 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 status | Unit state, restart count and the full compute:doctor report. Exits 0 only when the unit is active and doctor passes. |
compute:service drain | Finishes the assignment in progress, claims no more work and stops the service. |
compute:service uninstall | Disables 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.
Restart and drain
Section titled “Restart and drain”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.
Security
Section titled “Security”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.
Container mode
Section titled “Container mode”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.
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.
Recipe files
Section titled “Recipe files”The @hellmai/lumenflow-host package ships the recipe in its container-profiles directory:
| File | Purpose |
|---|---|
Dockerfile | Node 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.yaml | The 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.
Egress
Section titled “Egress”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.
Accepted input
Section titled “Accepted input”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.
Host prerequisites
Section titled “Host prerequisites”- buildx. Building the image uses
--build-context, which needs thedocker buildxplugin. Ubuntu’sdocker.iopackage does not ship it: installdocker-buildx. The plan’slifecycle.buildPreflightfails 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-runnerprofile is loaded. Load the profile; do not disable the host setting.
Platforms
Section titled “Platforms”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.
Kubernetes mode
Section titled “Kubernetes mode”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.
Generated deployment-plan contract
Section titled “Generated deployment-plan contract”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.
Runner claim cadence and status
Section titled “Runner claim cadence and status”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.
Related
Section titled “Related”- Connected Compute contracts — the assignment/result contract this runner executes against.