Bring-your-own container runtime runner
Why the container and bring-your-own-runner tier stays deferred, its shape if built, and the per-job resource caps that are built.
Status
The container tier stays deferred. The default and only shipped isolation is nono/Landlock
(enforcing); this document sets out why that is the right default and specifies the
exact shape a bring-your-own-runner (BYO-runner) would take if a concrete need justifies
building it.
One slice of the tier's value ships without the tier: per-job memory/CPU caps, enforced by cgroup v2 direct writes with an opt-in root-in-container privilege model (§6). The rest of the tier — BYO images, the podman socket, the build/cache machinery — remains deferred.
Not this tier: an operator who wants arbitrary system packages or a different toolchain can build an image that inherits from exocomp and run it. The daemon neither builds nor pulls any image and mounts no podman socket, so no registry enters the job or recovery path (Decision 0016). That is the supported image story; the in-daemon per-job image runner this document specifies stays deferred.
1. Vision and honest status
The vision people reach for when they say "BYO container runner" is: let an operator hand
exocomp a bootstrap script (or a Containerfile) and have jobs run inside an image built from
it, with hard resource caps. That is the container tier from
execution.md.
The central finding: exocomp has already made this architectural decision, and made it by rejecting the podman-socket path for v1. This is not a gap waiting to be filled — it is a deliberate deferral with a shipped alternative. Verified against the docs and the tree:
- The
containertier is designed but deferred (execution.md:34). It is not in the code:internal/executor/sandbox.go:21-32defines onlyTierDisabled,TierEnforcing, andTierOpportunistic. There is nocontainerTier constant. "Designed, deferred" is literally true. - Decision 0005 adopted nono/Landlock precisely to
capture most of the container tier's prize without the socket. Its Context (0005:16-21)
names the container tier's two costs as the disqualifier: it "costs
exocompda mounted podman socket (effectively host-user power: the sensitive capability moves rather than disappears)" and "puts registry-pulled execution-environment images on the restore path." Its Consequences (0005:44-47): "Most of thecontainerdriver's privilege-separation prize is captured … with no podman socket handed toexocompdand no registry on the DR path." - The invariants make the socket hostile. AGENTS.md invariant 1 ("one operator, one container, no external database — ever"), invariant 6 ("nothing here runs sudo"), invariant 7 ("disaster recovery never depends on a registry, a vendor, or this project's own code"). Podman-in-podman and sidecar topologies both break invariant 1; a registry pull on restore breaks invariant 7.
- uv-venv already delivers per-project dependency/tool versioning.
Decision 0011 +
internal/deps/venv.go: each source resolves its declared deps into a hash-gated, offline-restorable venv atcache/python/<source>/<manifest-hash>, carrying a project-pinnedansible-core. This is the first half of what execution.md:133 attributes to per-project EE images — delivered without any OCI machinery.
What already exists (ship state):
| Capability | Mechanism | Where |
|---|---|---|
| Per-job filesystem isolation | nono/Landlock default-deny, kernel-enforced | Decision 0005, sandbox.go |
| DB/key/socket denial from jobs | default-deny + name-check guard + doctor canary | sandbox.go:72-80, execution.md:98-101 |
| Per-project dep/tool versioning | hash-gated uv-venv per source | Decision 0011, venv.go |
| Offline/DR-safe execution | Landlock is local; venvs hardlink-seeded, --offline |
Decision 0005:47, venv.go:55-56 |
| Per-job network scoping (by port) | Landlock ABI v4 network rules | execution.md:85-89 |
What BYO-runner would ADD — and only these, because everything else is already covered:
- BYO execution-environment images — an operator supplies a bootstrap script or Containerfile so jobs run with arbitrary baked-in system packages (the one thing Decision 0011:86 explicitly leaves out: "bring-your-own-runner is the post-v1 seam for arbitrary baked-in system deps") and independent ansible/collection pins as a full image rather than a venv.
- Hard per-job resource caps — memory/CPU ceilings the kernel enforces (execution.md:147 names this as the container tier's other remaining justification). This half needs no image and no socket: it ships as cgroup v2 direct writes (§6).
Net: with enforcing shipped and uv-venv delivering per-project deps, the container
tier's justification has narrowed to BYO system-package images + hard caps (execution.md:146-147).
That narrow remainder is what this document scopes.
2. Two runner models — one spec, two build modes
An operator wants one of two things. Both are expressible with a single runner spec and a single hash-gate; they differ only in when the image is built.
Model A — cached runner (bootstrap → build once, reuse)
Operator supplies a base image + a bootstrap script / package list. exocomp builds a runner image once, tags it by a content hash, and reuses it across every run until the inputs change. Optional scheduled rebuilds refresh the base digest.
- Best for the common homelab case: a stable runner used by many runs.
- Maps onto execution.md:129-131: ansible via ansible-runner's native podman EE isolation
(the AWX Execution-Environment pattern) — ansible-runner already shells podman to run a
play inside an EE image, so the cached runner image is the EE; exocomp builds it and hands
its tag to ansible-runner. For tofu,
podman run <runner-image> tofu …. - Cache-hit is offline:
podman run --pull=never. Only a cache miss triggers an (online) build.
Model B — on-the-fly Containerfile (build every run, layer cache)
Operator supplies (or exocomp generates) a Containerfile; exocomp builds it every run
with podman build --layers so an unchanged Containerfile is a near-free layer-cache hit.
- Best when the recipe changes often (active development of the runner itself).
- Uses buildah/podman layer cache + BuildKit-style cache mounts (§7).
One spec expresses both
# .exocomp/runner.yaml (repo-side default; per-source, like the dep manifests)
runner:
mode: cached # cached (default) | onthefly
base: registry.fedoraproject.org/fedora-minimal:44 # resolved to @sha256 at build
bootstrap: | # Model A: script run as one RUN layer
microdnf install -y sshpass rsync
# OR, Model B:
containerfile: .exocomp/Containerfile # path; mutually exclusive with bootstrap
packages: [sshpass, rsync] # sorted, hashed; convenience over bootstrap
ansible_core: "2.18.1" # optional per-project pin (else image default)
build:
no_cache: false # CACHEBUST-style force-clean for debugging
caps: # §6 — enforced via systemd scope, not the socket
memory_max: 2G
cpu_quota: "200%"
Both modes feed the same runner-hash (§7), so switching mode never forces a rebuild.
bootstrap/packages is Model A; containerfile is Model B; the presence of containerfile
selects onthefly semantics by default.
Registration — server-level vs repo-side, DB-native
A runner may be server-level (registered once, shared by many sources) or repo-side
(the per-source runner.yaml above). The native home for a registered runner is the
database — the same place sources, inventories, schedules, and plan-policies already live
(internal/store), so it is backed up + audited with the data volume and reachable by the
reactive/audit machinery. This is a two-tier model mirroring how dependencies already work
(operator-registered vs repo-declared).
- Registered / global (DB) —
exocomp runner add <name> --base … --bootstrap …(alsorunner list|show|rm). Named, operator-managed, shared across sources. A source references it by name. Best for a handful of standard homelab runners. - Repo-side (
.exocomp/runner.yaml) — per-source, self-contained, GitOps; travels with the code.
A source picks one; precedence: --runner <name> flag → the source's .exocomp declaration →
a workspace/global default runner.
On location: /etc/exocomp/runners — no. /etc implies a system-level, multi-user,
root-managed daemon; exocomp is single-operator, rootless, no-sudo, one-container (invariants
1/6), and has no /etc config model. ~/.config/exocomp/runners/ — off-pattern:
exocomp's state lives in the backed-up data dir (XDG_DATA_HOME), not ~/.config — which is
used only for the remote CLI's client-side host pins/tokens (hosts.toml), the opposite
direction (a workstation reaching a daemon, not the daemon's own registry). For file-based /
declarative registration, prefer exocomp runner import <dir> loading YAML specs from a dir
under the data dir into the DB — GitOps ergonomics without splitting state across
~/.config. The runner definition (recipe) is backed up; the built image stays in the
rebuildable cache (§7), reconstructable from the definition + runner-lock.json.
3. Tooling — podman-first, shell the CLI
Decision: shell podman / buildah bud / podman build --layers / podman pull. Do
NOT embed the buildah Go library, the containers/storage or containers/image libraries, or
the podman Go bindings. Do NOT use the docker/docker Go SDK.
go.mod carries no docker / podman / buildah / containers/* / opencontainers / crun / containerd dependency. Shelling the CLI keeps it that way.
Rationale:
- Invariant 3 makes correctness exit-code-decided. The executor's
Spec/Drivermodel already runs ansible and tofu as shelled subprocesses judged by exit code. A shelledpodmanfits the exact same pattern with no new heavyweight dependency. - buildah/containers-storage as Go libraries are cgo-heavy and pull a large transitive
tree. Embedding them jeopardizes the
CGO_ENABLED=0static-binary build (packaging/Containerfilebuild stage) and the ~500 MB working-image ceiling (Decision 0006:58) — for a tier that is deferred and may never ship. - ansible-runner already shells podman for EE isolation (execution.md:129-131), so
exocompd's own podman surface is thin: build/pull a runner image,
podman runa tofu job. Thin surface → shell it.
Why the docker SDK does not work against podman. Podman implements the Docker REST
API (e.g. /v1.41/build), not Docker BuildKit's proprietary gRPC protocol. The
github.com/docker/docker client's modern build path targets BuildKit, so builds fail or
fall back unpredictably against podman.sock — the same class of failure as compose
dockerfile_inline against podman.sock. Rejecting the docker SDK is a correctness
constraint, not a style preference.
Reserve the podman Go bindings (pkg/bindings over the API socket) only if a later need
for streaming/events arises that the CLI cannot express cleanly. Do not adopt them
speculatively.
4. The nesting decision — host-user rootless socket mount (option a), gated
Decision: mount the host user's rootless podman socket
(unix:///run/user/1000/podman/podman.sock) into exocomp's container (option a), narrowly
scoped with SELinux label=disable on that mount only, gated so DR-path runbooks never touch
it. NOT podman-in-podman (b). NOT a sidecar (c).
How exocompd drives podman to build+run runner containers, per topology:
- (a) host-socket mount [chosen]. exocompd's one container talks to the host user's already-running rootless podman via the mounted socket. The EE build/run happens on the host's podman, where it belongs. Keeps one exocomp container (invariant 1). This is the topology execution.md:138 and platform-contract.md:11 already name as the container driver's mechanism, and the one Decision 0005 priced.
- (b) podman-in-podman [rejected]. Nests userns/subuid-subgid mappings, is fragile and slow, and on enforcing SELinux the nested MCS labeling is a known pain surface. Adds no benefit over (a) and multiplies the failure modes.
- (c) sidecar podman [rejected]. A second long-lived container breaks invariant 1's one-container commitment and reintroduces daemon/privilege surface without removing the socket-power problem — you still hand something the socket.
Trade-off, stated honestly. Option (a) is genuinely host-user-equivalent power inside exocompd's container — Decision 0005's exact objection (0005:18, execution.md:138: "the sensitive capability moves rather than disappears"). Any code path reaching the mounted socket can run arbitrary host-user containers. Therefore:
- The socket mount is opt-in with the container tier, off by default.
- It is kept off the DR path and away from job subprocesses (nono denies jobs socket access anyway — sandbox.go grants no socket; execution.md:107-109 confirms no podman socket is mounted today).
- Enabling it materially lowers exocomp's own threat tier, and should be treated and
documented as such.
exocomp doctorasserts the socket is reachable only from the container tier's build/run path and never from a job profile.
This decision is coupled to the sandbox's only guarantee against local sockets: Landlock
cannot refuse connect(2) on a pathname AF_UNIX socket (execution.md:103-112, Decision 0005:58-65),
so the sandbox's socket denial holds by placement — exocomp mounts no podman socket today.
Turning on the socket mount directly attacks that placement mitigation. The two decisions must
be made together, not independently.
5. SELinux / AppArmor / rootless on enforcing Fedora
The target is Fedora/RHEL enforcing; no example may depend on permissive mode (platform-contract.md:21-22). The anchors are platform-contract.md:33-36 and the volume-relabel prior art.
label=disableon the socket mount ONLY. The deferred container driver's podman-socket mount needs--security-opt label=disable(or QuadletSecurityLabelDisable) on that mount (platform-contract.md:35-36). Do NOT blanket-disable labels on the whole container — that would remove container-selinux's socket confinement, which platform-contract.md:33-34 and Decision 0005:55 rely on as a second, independent enforcement of "jobs reach no local sockets." Getting this wrong yields either broken jobs (forgot it → AVC denials that look like generic podman failures) or weakened isolation (over-applied).:z/:Zon bind mounts. Per-job workspace/data bind mounts need:Z(private relabel) or:z(shared) on enforcing Fedora (platform-contract.md:25). Caveat::Z/:zapplycontainer_file_tonly at container start, not to files added later, and:Zfails on NFS/CIFS — "the NAS leg is the sharp edge" (platform-contract.md:27-28). This constrains where the image/build store lives (§7).- Rootless userns / subuid-subgid. Rootless podman requires
loginctl enable-lingerand configured/etc/subuid+/etc/subgidranges for the operator. For host-volume sharing, use--userns=keep-id:uid=1000,gid=1000(keep-id preferred over entrypoint chown scripts) so runner-produced files map back to the operator's uid without ACL surgery.exocomp doctorverifies subuid/subgid ranges and linger before the first job, mirroring how sandbox.goProbe/RunCanaryassert rather than assume. - Run-secrets out of argv and correctly labeled. The
disabledtier already writes secret material (vault password, backend bearer) into a per-job 0700 private directory, never argv/env (execution.md:46-49, 158-160). BYO-runner must preserve this: resolved secrets go to a private dir bind-mounted into the runner container:Z, passed via--secretor a file path — never on thepodman runargv (invariant 4; the process table is world-readable). ansible-runner'senv/+--secretplumbing is the existing seam. - Hardening the runner container.
--read-onlyrootfs with a tmpfs for scratch;--security-opt no-new-privileges; keep the default seccomp profile (do not--privileged, do not--security-opt seccomp=unconfined);--cap-drop=ALLand add back only what a play needs (usually nothing);--networkscoped to match the Landlock per-kind port story (SSH for ansible, HTTPS for tofu).
6. Resource caps without the socket — cgroup v2 direct writes
The one thing nono + uv-venv does not provide is hard per-job memory/CPU caps (execution.md:147). It needs no podman socket, and it ships today.
The naive approach — systemd-run --user --scope under the rootless user slice — does not
apply inside exocompd's own container: the runtime image
(registry.fedoraproject.org/fedora-minimal:44, packaging/Containerfile) carries no
systemd package, so there is no systemd-run binary and no systemd --user manager, and
packaging/quadlet/exocomp.container starts no PID 1 to hand a scope to. Caps are therefore
enforced by the daemon writing cgroup v2 files directly (internal/executor/cgroup.go).
Mechanism, per opted-in job:
- Once at startup the daemon prepares a delegated subtree. cgroup v2's no-internal-
process rule forbids a cgroup from both holding processes and distributing controllers,
so the daemon vacates its own base cgroup — moves every pid in
cgroup.procsinto a childexocomp.svcleaf — and only then writes+memory +cpuinto the base'scgroup.subtree_control(skipping the vacate yieldsEBUSYon that write). Anexocomp.jobsparent is created and made a distributor too. - Per job it
mkdirs a child underexocomp.jobs, writesmemory.max,memory.swap.max=0(required — without it a job overmemory.maxswaps instead of being OOM-killed, so the ceiling throttles rather than enforces), andcpu.max, then places the job process into it at fork via clone3CLONE_INTO_CGROUP(Go'sSysProcAttr.UseCgroupFD), so the ceiling is in force from the process's first instruction. Teardowncgroup.kills the subtree andrmdirs it with the job. - Fail-closed. Caps requested with no delegated, writable subtree are refused, never run
uncapped — the same "never silently" posture Decision 0005 holds for the sandbox tier.
exocomp doctorreports availability under "per-job caps".
A job selects caps by naming a runner
A job names a runner purely for its caps via --runner <name> (there is no job→runner
image linkage — the container tier stays deferred, §1). store.JobArgs/queue.Spec carry
RunnerName as a JSON field on the existing args_json column (no migration). --runner is
accepted on playbook run, run (the hooked workflow), workspace plan/apply, run-cli,
command, and ansible; --remote carries it for the two verbs with a remote form
(workspace plan/apply, playbook run), so a remote-submitted job takes the same
dispatch-time path as a local one.
At dispatch — the daemon's per-kind drivers and every no-daemon inline path alike —
resolveRunnerCaps looks the named runner up and runs its memory_max/cpu_quota through
ParseCaps, refusing the job if the name or the caps do not resolve. withCaps then sets
SandboxOptions.Caps and prepares the *executor.CgroupController lazily (via sync.Once)
— only the first time a job actually asks for caps, so a fleet that never uses --runner
never has the daemon's own cgroup rearranged. Two fail-closed axes guard enforcement:
applyJobCaps refuses when the delegated subtree is not writable, and
requireSandboxedForCaps refuses when the effective tier is not enforcing — a plain
subprocess never reads SandboxOptions.Caps, so a capped job that fell back to it would
otherwise run uncapped. parseMemorySize guards against integer overflow on a large digit
string that would otherwise wrap to a wrong tiny cap.
Enforcement and privilege model
Enforcement is verified on real podman (5.8.4 host, 5.8.1 lima), not assumed: a 200 MB
allocation under a 50 MB cap is OOM-killed (memory.events oom_kill 1, SIGKILL → 137); a CPU
workload under a 10%-of-one-core cpu.max throttles (cpu.stat nr_throttled 33).
The privilege model is opt-in root-in-container: PodmanArgs=--systemd=always mounts
/sys/fs/cgroup read-write and runs PID 1 as uid 0 inside the rootless user namespace (no
host root). The rootless-preserving alternative, keep-id delegated non-root, does not work on
podman 5.8.x — a --userns=keep-id non-root process is denied writes to /sys/fs/cgroup
even with --systemd=always, and making it work needs explicit cgroup ownership delegation
podman does not grant by default. The default deployment stays rootless + keep-id and
capless; caps are opt-in.
7. Cache and lifecycle — port the uv-venv model to OCI images
The BYO-runner cache should be a near-mechanical port of the Decision 0011 uv-venv cache. That
cache is hash-gated on manifest content, keyed under the backed-up data volume, fail-closed,
and offline-restorable (internal/deps/venv.go). Reuse its exact shape.
7.1 Hash-gate and ready-marker
- runner-hash =
sha256("runner-v1" \x00 canonicalJSON{ base:"<registry>@sha256:…", bootstrap_sha256, packages:sorted([...]), ansible_core, python, … })[:32]. This portshashManifestverbatim (manifest.go:137-143: kind/version prefix + NUL + canonical bytes, sha256, first 32 hex). Bump"runner-v1"when the recipe format changes so old images never collide onto new semantics (same reasonkindis mixed into the venv hash). Fail-safe carries over: over-resolving on an unrelated edit is safe; under-resolving on a real change is not. - Ready-marker = OCI label. The venv's
.exocomp-venv-readymarker file (venv.go:37-38, written only after a clean provision) maps to an OCI labeldev.exocomp.runner.hash=<runner-hash>baked at build. Cache-hit test =podman image exists exocomp-runner:<hash>AND the inspected label equals the recomputed hash. A crashed build leaves a dangling image with no matching label → rebuilt, exactly the partial-venv removal semantics (venv.go:106-110, markerMatches venv.go:260-266).
7.2 Base pinned by digest
The Containerfile pins base images by mutable TAG today
(fedora-minimal:44, golang:1.26, uv:0.9.7 — Containerfile:1,8,16,57,78) while pinning
subprocess tools by sha256 (tool-digests.txt) and freezing versions into MANIFEST.json.
BYO-runner must not inherit this flaw. A runner-hash computed over a tag is not
reproducible — the same tag resolves to different bytes over time, so an "unchanged" runner
silently drifts. Resolve tag→digest at build, write FROM …@sha256:…, feed the DIGEST (not
the tag) into runner-hash, and record it in a runner-lock.json beside MANIFEST.json
(base digest, bootstrap sha, package list, resolved package versions, toolchain pins).
microdnf install <pkg> without pinned versions drifts even at a fixed base digest, so the
lock must record resolved versions or "reproducible" is skin-deep.
7.3 Build-per-run layer cache (Model B)
- Always
podman build --layers(buildah caches intermediate layers). - BuildKit-style cache mounts:
RUN --mount=type=cache,id=uv,target=/root/.cache/uvandid=dnf,target=/var/cache/dnfso a rebuild after a bootstrap edit reuses base layers and the uv/dnf download caches. - Stage order: base → dnf packages → uv-cache warm → bootstrap LAST (most-edited layer
last), mirroring the Containerfile's own
COPY go.mod→go mod download→COPY sourceordering (Containerfile:3-5) that keeps the download cached. runner.build.no_cacheforces a clean build (CACHEBUST precedent).
7.4 Freshness — into the existing cron scheduler, no new subsystem
internal/sched fires recurring jobs into the one queue (invariant 5). Today's kinds are
shell/backup/tofuplan (store/schedule.go:15, sched/driver.go:17-19). Add a
ScheduleRunnerRefresh kind: re-resolve each declared base tag→digest and rebuild iff
the digest moved or the runner-hash changed — a fast no-op otherwise (the venv "stable hash is
a fast no-op" property). Default cadence weekly. Reuses ValidateCron/fire/
RecordScheduleFire (sched.go) with no new machinery, and a concurrency key per runner-hash
(as schedules already carry ConcurrencyKey, sched.go:190) so two builds of the same runner
never race.
No CVE-feed rebuild trigger. Invariant 7 forbids depending on an external vendor feed, and
it would reinject network into the steady state Decision 0011 removed. The base-digest refresh IS
the CVE mechanism — Fedora ships CVE fixes in the base, so pulling a moved digest is the patch
path. exocomp doctor surfaces the running runner's base digest + age for an operator to force
a rebuild (mirroring how doctor diffs the toolchain against MANIFEST.json, Decision 0006).
7.5 GC / prune — one job, both caches (closes an existing gap)
restic backups are bounded (forget --prune, backup/restic.go:135) and tofu state is bounded
(PruneTFStates, store/tofu.go:262-287), but cache/python is unbounded today — venvs
accumulate one dir per manifest hash per source forever, with no reaper. A runner image store
would compound this. Ship one bounded-cache prune job covering BOTH:
runner.cache.keep = 3most-recent per source, plus keep-any-used-within = 30d.runner.cache.max_gb = 10ceiling; on overflow evict LRU, thenpodman image prune -f+buildah prunefor dangling build cache.- Apply the identical keep policy to
cache/python, closing the gap. - Run as a scheduled queue job on the restic-forget cadence.
7.6 Storage placement and DR
- Image/build store on a LOCAL relabelable path (
~/.local/share/containersor a local data-volume subdir), never the NAS leg —:Zfails on NFS/CIFS (§5). A mislabeled or NFS-backed store yields intermittent EACCES/AVC that look like build flakes. - Do NOT back up the image store. Back up
runner-lock.jsononly — images are rebuildable-from-lock, so backing them up wastes the restic budget. (Contrast venvs, which stay backed up because they are not trivially rebuildable offline.) - Online-build, offline-run, off the DR path. A runner-hash hit runs
podman run --pull=never— no network (mirrors the venv--offlinehit, venv.go:55-56). Only a miss triggers an online build (a build fundamentally needs the base + a package index). BYO-runner runbooks are refused on the DR path by the same doctor gate that pins DR to the image toolchain (execution.md:140-142, Decision 0011:79-80). A registry pull on restore breaks invariant 7 — enforce this gate, do not merely document it.
7.7 Build is operator-initiated, never a job side effect
Any build needs podman/buildah = the exact privileged surface Decision 0005 excluded. So the runner build must be operator-initiated, approval-gated, and doctor-visible — framed like the Decision 0006 escape hatch ("operator-initiated, at a shell, on purpose"), never triggered implicitly by a sandboxed job (whose profile grants no socket, sandbox.go:72-80). The mitigation is a process control, so it needs an explicit doctor assertion, not just a convention.
8. Recommendation
- Keep the status quo. nono/Landlock (
enforcing) stays the default and only shipped isolation. It is the correct podman-first-yet-no-podman-socket answer for a single-container, no-sudo, DR-offline system, and it keeps go.mod free of container dependencies. Do not build the container tier speculatively. - Build BYO-runner only when a concrete need justifies it — either (a) a real BYO execution-environment requirement (arbitrary baked-in system packages that uv-venv cannot provide, Decision 0011:86), or (b) hard per-job resource caps. If the need is caps only, build §6 (systemd scopes) instead of the container tier — it is far cheaper and does not lower the threat tier.
- When built, use exactly the shape above: shell podman/buildah (§3), host-socket mount
option (a) with
label=disableon that mount (§4), the uv-venv-mirrored hash-gated cache with base-by-digest (§7), operator-initiated builds off the DR path (§7.7).
Open questions
- Is there a real need at all? If no runbook needs system packages, and caps (§6) cover the resource case, the tier need not be built and §1's table stands.
- How do EE images map to sources? One runner per source (like venvs), or a shared runner pool? Affects the cache keyspace and prune policy.
- How is the socket mount fenced in code so no job profile can reach it and the DR gate is enforced, not documentary? (execution.md:142 states intent; the enforcement is unbuilt.)
- Model A vs Model B default —
cachedproposed. runner-lock.jsonschema and location relative toMANIFEST.json.
9. Phased implementation plan (dependency-ordered, one phase each)
Each phase is a self-contained unit of work. Phases 0–2 are worth doing even if the container tier is never built, because they close real gaps in the shipped system. Phase 1 is done; the rest are a proposed order if the tier is built.
- Phase 0 — Bound the existing caches (no container tooling). Add the
cache/pythonprune job (§7.5) as a scheduled queue job on the restic-forget cadence. Closes an unbounded-growth gap that exists today. Depends on nothing. - Phase 1 — per-job resource caps via cgroup v2 direct writes (done). The daemon writes
memory.max/memory.swap.max=0/cpu.maxinto a per-job child of its own delegated cgroup subtree and places the process withCLONE_INTO_CGROUP(internal/executor/cgroup.go). Opt-in and fail-closed; the privilege model is root-in-container (PodmanArgs=--systemd=always). A job selects caps by naming a runner (--runner). Full mechanism, wiring, and enforcement in §6. - Phase 2 — doctor prerequisites probe for the container tier. Add probes (rootless podman
present, user socket reachable, subuid/subgid configured, SELinux booleans/label posture)
that assert rather than assume, mirroring sandbox.go
Probe/RunCanary. Gates every later phase and surfaces "can this host even do BYO-runner?" before any code touches podman. - Phase 3 — runner-hash + runner-lock (pure Go, no podman calls yet). Port
hashManifesttorunner-hash, definerunner.yamlparsing andrunner-lock.jsonemission, base tag→digest resolution recorded but not yet built against. Testable in isolation. Depends on P2 for the digest-resolution probe. - Phase 4 — Model A cached build via shelled podman/buildah + host-socket mount. Wire the
socket mount (option a,
label=disable),podman buildfrom bootstrap, OCI ready-label, cache-hit viapodman image exists+ label check,podman run --pull=neveron hit. This is the first phase that lowers the threat tier — gate it opt-in, off the DR path. Depends on P2+P3. - Phase 5 — ansible-runner EE integration + tofu
podman run. Hand the cached runner image to ansible-runner as its EE (AWX pattern);podman run <image> tofu …. Secrets via private dir +--secret(§5), hardening flags (§5). Depends on P4. - Phase 6 — Model B on-the-fly build.
podman build --layers+ cache mounts + stage ordering +no_cacheknob. Depends on P4 (shares the build plumbing). - Phase 7 — freshness + GC for images.
ScheduleRunnerRefreshkind (weekly base-digest refresh, no CVE feed) + extend the Phase-0 prune job to cover the image store and build cache. Depends on P4 (needs images to refresh/prune) and reuses P0's prune job. - Phase 8 — DR-gate enforcement. Make the doctor gate that refuses BYO-runner runbooks on the DR path enforced, not documentary (execution.md:142). Depends on P4/P5 existing to gate.