Execution and isolation
The executor driver tiers of disabled, enforcing and container, offline-restorable job dependencies, and boot-time reconciliation.
The queue executes every job through one ExecutorDriver interface, present from v0.1. The
interface costs approximately nothing to introduce early — the queue already isolates
execution behind a single call — and it is what makes the isolation tier a deployment
choice rather than a rewrite.
Queue semantics
One job table, one daemon claiming whatever reaches it. exocomp serve's queue claims
every queued row, whichever process enqueued it, and never idles. A one-shot CLI command
(workspace plan with no daemon up, for instance) runs its own queue instead, and that
queue's Drain claims only the rows it enqueued itself — a CLI never touches a daemon's
rows, so it cannot run a job through a driver built for a different job's kind by racing a
daemon that got there first.
Concurrency keys are enforced by a single SQL UPDATE at claim time
(store.ClaimJob) — the key check and the state transition to running are one
statement, conditioned on no other running row sharing the key — not by a map held in
process memory. A daemon and a CLI claiming from the same database file are both bound by
the same statement, so which one wins is a database fact, not a race either has to reason
about.
A job's subprocess runs in its own process group (Setpgid); cancelling a job signals the
whole group (SIGKILL to -pid), not just the process the executor started, because a
shell that exec'd something else or forked a helper is only reachable by group.
Three tiers, in increasing isolation and increasing cost:
| Tier | Status | Isolation mechanism |
|---|---|---|
disabled |
shipped, and the only topology bootstrap and DR require | subprocess boundary, rootless container, per-job 0700 private dir, env allowlists |
enforcing |
shipped (nono, Landlock), the default on Linux |
the above plus a kernel default-deny sandbox per job subprocess |
container |
designed, deferred alongside Wails and OpenBao | per-job sibling containers |
Selecting a tier is --sandbox/$EXOCOMP_SANDBOX, defaulting to enforcing. A third
name, opportunistic, is not an isolation mechanism of its own: it asks for enforcing
and accepts a loud degradation to disabled where the host cannot deliver the sandbox.
Under disabled a job is trust-equivalent to the daemon: isolation is process boundary and
file permissions, not kernel policy, so anything a job subprocess's uid can reach, the
daemon's own code could reach too. enforcing is what breaks that equivalence, and it is
the default wherever the kernel supports it — a host that cannot deliver it fails closed at
startup rather than running the fleet's jobs unsandboxed.
disabled
Pinned tools in the image, one subprocess per job, run in its own process group
(Setpgid) so a cancel or timeout signals the whole tree rather than one pid a shell may
have exec'd past. Secret material — a workspace's backend bearer and state passphrase, an
ansible job's vault password — is written only to a per-job private directory
(os.MkdirTemp, mode 0700, removed when the job ends) inside the rootless container, and
the subprocess's environment is built from an allowlist rather than inherited, so an
operator's exported TF_CLI_ARGS cannot rewrite the argv a job row recorded.
This tier ships without a per-job tmpfs mount or a separate executor UID: every job subprocess runs as the same container user, distinguished only by its own private directory and the argv it was given. One image plus one volume is a working system. Nothing else has to be pulled at restore time — which is exactly why this tier, and not a fancier one, is what the DR path is allowed to use.
enforcing
Every job subprocess runs under nono run (Landlock on Linux, Apache-2.0, ≥ 0.55.0
sha256-pinned) with a default-deny filesystem policy built per job kind. The sandbox is
spliced in underneath the ansible and tofu drivers: it sees the argv and environment
those drivers finished building, and hands the result to the same local subprocess driver,
so streaming, process groups, and exit-code semantics have one implementation.
| Paths | |
|---|---|
| Allowed | the job's own private directory, the working directory, the repo paths its argv names, the tool tree, the provider and collection caches |
| Denied | exocomp.db, host.key, keyring.age, serve.lock, state-backend.addr, every other job's private directory |
Every job gets a private directory of its own, and the driver's secret material — the vault password, the backend bearer — is written there rather than into a root shared by all jobs, so one job's grant never covers another's.
The denial is enforced twice: by the kernel, because nothing outside the profile is granted, and by a guard that refuses to build a profile whose grants would cover the data dir or any of the files above. That second check is the one that matters in practice — the caches a job legitimately needs all live inside the data dir, so the mistake to catch is a grant one directory too high.
Ansible and tofu jobs run under it. Shell runbooks and backup legs do not: the backup
engine reads keyring.age by design — it is the file every target root gets a copy of —
so those kinds need a profile shaped around the engine's targets rather than the deny list
above.
Network is scoped per kind: ansible gets TCP 22, tofu gets TCP 443 plus the loopback state backend's port. Scoped by port, not by destination: Landlock's network rules match the port alone, and the only per-destination filtering available routes traffic through an HTTPS-terminating proxy, which is unusable for SSH and wrong for provider signature verification.
exocomp doctor reports the launcher version against the floor, the kernel's Landlock ABI
(network rules need ABI v4+, kernel ≥ 6.7), the tier in effect, and what the startup probe
asserted — and under opportunistic the daemon degrades loudly to disabled, never
silently, on a kernel or a launcher that cannot deliver it. enforcing — the secure default
— is a policy, not a preference: an enforcing request (including the empty-flag default)
that cannot be enforced makes serve and doctor refuse rather than degrade, since running
the fleet's jobs unsandboxed is not what was asked for.
The probe asserts rather than assumes: it stands up a file standing in for host.key, two
listening unix sockets, and a listening loopback TCP port, then runs a canary inside a
sandbox granting none of them and requires the refusals. Live targets are the point — a
refusal has to come from the kernel policy, not from there being nothing to reach.
A connect(2) to a pathname AF_UNIX socket is not mediated by any Landlock ABI shipped
so far: Landlock hooks path opening, and connecting to a unix socket opens nothing.
Abstract sockets, which Landlock does scope, are refused; pathname ones are not. The socket
denial in Decision 0005 therefore holds by placement rather
than by policy — exocomp ships no unix socket for a job to reach, its own surface is
loopback TCP, and no podman socket is mounted into the container — and doctor prints the
gap instead of implying a denial it cannot make. This is why the probe asserts against live
targets rather than assuming kernel behavior: an assumption here would have hidden exactly
this gap.
The consequence that matters is unchanged: a playbook is arbitrary code from a git
repository, and under this tier it cannot read the database, the host key, or the keyring.
That is most of the container tier's prize with no podman socket handed to exocompd
and no registry on the DR path — Landlock is local and unprivileged, so the sandbox
applies inside a rootless container and during a restore.
Two operational constraints come with it. A job's HOME is redirected into its own
private directory, so an operator's ~/.ansible.cfg or ~/.terraformrc does not reach a
queued run. And <data dir>/run must stay under 57 bytes: the tier redirects TMPDIR
beneath it, the tools that run there coordinate over unix sockets placed inside it, and
AF_UNIX addresses cap at 108. The probe measures it at startup and degrades loudly
rather than letting each job discover it as a timeout.
Sandbox denial hints. A sandboxed ansible run that fails because the kernel denied a
read or an escalation appends one operator-facing hint to the job's events, naming the
sandbox-preserving fix. An ssh identity key the run could not read points at
--ssh-identity <path> (a single-file read grant, never its directory); a sudo the
sandbox refused because the connection was local points at reaching the host over SSH —
ssh-to-self for the controller's own host — so the escalation runs on the far side.
--privileged is named only as the last resort. The hint is diagnosis: the exit code
still decides the outcome (invariant 3), and the denied operation stays denied. The two
classes and their exact copy are in playbook-run.md.
container
Per-job sibling containers: Ansible via ansible-runner's native podman process isolation
and execution environments — the AWX EE pattern, and a capability of a dependency already
shipped rather than new orchestration — and plain podman run for OpenTofu.
What it buys: privilege separation with per-project EE images carrying independent ansible and collection pins, and hard per-job memory caps.
The trades are stated so the choice is informed, not assumed:
exocompdmust mount the podman socket, which is effectively host-user power. The sensitive capability moves rather than disappears.- EE images become a restore-time dependency. Any EE used by a runbook on the DR path must
be rebuildable from the ops repo or mirrored in the data volume. The default is stricter:
DR-path runbooks are
disabled-only, enforced by the doctor. - Per-project tool versions widen the golden-file test matrix, so EEs are tested per-project, not globally.
With enforcing shipped, the remaining justification for this tier narrows to
per-project EE versioning and hard per-job resource caps, so it stays deferred alongside
Wails and OpenBao.
OpenTofu runs
A tofu job is one Spec of kind tofu whose argv is the operation plus the operator's own
arguments — everything a job row is allowed to remember. The driver supplies the rest per
run:
- a
terraform { backend "http" {} }override file in the workspace directory, and the values that fill it — address,LOCK/UNLOCKmethods, the workspace bearer — in a 0700 private directory handed totofu init -backend-config. The bearer never reaches the workspace directory or the process table. TF_DATA_DIRinside that private directory..terraform/terraform.tfstaterecords the backend configuration verbatim, bearer included, so it dies with the run.TF_ENCRYPTIONnaming thepbkdf2key provider with a passphrase derived per workspace. Tofu encrypts its own state;exocompdstores the envelope it is handed (Decision 0004).- an environment built from an allowlist.
TF_VAR_names pass through; otherTF_names do not, becauseTF_CLI_ARGSwould let whatever the operator exported rewrite the argv the job row records.
init runs before every operation, and a failing init is the job's exit code: the
operation never runs against a half-configured backend. plan runs with
-detailed-exitcode, where exit 2 means changes are present — neither success nor failure,
so the driver records a plan outcome in job_event and reports exit 0.
A saved plan is the least-protected place a secret appears: it carries every attribute of
every resource the apply would change, in the clear, and it outlives the run that made it.
So it is encrypted at rest — age to the keyring's recipients, the same recipients that
wrap the DEK — the moment the run that wrote it ends, and the artifact row records the
ciphertext's path and digest. An apply decrypts it into the run's own 0700 private
directory and removes it when the run ends; the approval diff reads it the same way.
Plan artifacts are not part of the backup staging tree, so nothing about the restore drill
changes.
Two runs cannot share a workspace directory: they would remove each other's backend
override mid-flight. The queue's tofu:<workspace> concurrency key keeps them apart, and
the backend's own lock row holds against anything the queue does not know about.
Offline-restorable job dependencies
An executor is only as restorable as what it needs to fetch. Disaster recovery must not depend on registry.opentofu.org or Ansible Galaxy being reachable:
TF_PLUGIN_CACHE_DIRplus a filesystem provider mirror live in the data volume, populated on firstinitand refreshed by a scheduled job..terraform.lock.hclis respected, and the mirror is part of every backup.- Galaxy collections and roles are vendored into the data volume the same way — including
community.sops, which the design itself depends on. - The restore drill asserts that a play and a plan both run with network egress disabled.
Boot-time reconciliation
A container restart kills ansible-playbook mid-run, and a restored snapshot contains such
rows by construction. On startup, jobs in queued or running whose process is gone are
swept to interrupted — a terminal state, notified, never auto-retried.
Stale tflock rows are handled the same way, and it is implemented, not aspirational: the
boot sweep runs first, then exocomp serve warns once per workspace whose lock still
names a job that is gone or terminal — a lock naming no live job row is stale by policy,
whether that job never existed, finished, or was swept above. exocomp workspace unlock <name> breaks the lock, refusing outright while the job it names is still running (the
lock is the system working, not a leftover), and records the break as an audited
job_event system entry on the job the lock named, or on the workspace's most recent job.
The restore drill asserts both the job sweep and the lock flag.