Bill of materials

The pinned tool and dependency set the image and the runtime depend on, with versions and provenance.

Indicative pins as of 2026-08. The frozen pins are committed at packaging/MANIFEST.json — the explicit source of the toolchain versions, not a constant hidden in the binary. The image build copies that file to /opt/exocomp/MANIFEST.json, filling in only git (resolved from Fedora's signed repo), and sets EXOCOMP_TOOLCHAIN_MANIFEST so the daemon reads the shipped pin — a per-source venv's default ansible-core comes from it, with the compiled default as the fallback for a run outside the image. A packaging test fails if the committed pins drift from the Containerfile's build args. Exact versions are enforced by exocomp doctor, which refuses to run a job whose tool version is outside a tested range rather than failing later with a parse error.

The license boundary

GPL components — ansible-core, git — are subprocesses, never linked, so exocompd's own license stays unencumbered. This is a structural rule, not a preference: it is the same reason Dagu (GPL-3.0, and otherwise exactly the right shape at 16 MB with no database) was rejected, because embedding it would have forced the whole binary's license.

Anything that would have to be linked must be permissively licensed. Anything GPL crosses a process boundary or does not ship.

Layer 1 — compiled into exocompd (one static Go binary)

Component Pin License Role
Go toolchain 1.26+ BSD-3 CGO_ENABLED=0 static builds, amd64 + arm64
modernc.org/sqlite 1.57.x BSD-3 embedded DB, pure Go, no CGO; exocomp.db, WAL
filippo.io/age 1.3.x BSD-3 envelope crypto as a library (recipients → DEK)
Go stdlib crypto (GCM, crypto/hkdf) 1.26 stdlib BSD-3 secret encryption and subkey derivation — zero novel crypto
robfig/cron/v3 3.0.x MIT schedules, rebuilt from the DB on boot
google/cel-go 0.31.x Apache-2.0 sandboxed, non-Turing-complete event rules
charm.land/bubbletea/v2 + bubbles/v2 + lipgloss/v2 v2.0.x / v2.2.x / v2.0.x MIT TUI — the priority-1 interface. Not the charmbracelet/* v1 imports — Charm's tooling moved to the charm.land module path for the v2 line this project is on
charm.land/wish/v2 2.0.x MIT TUI over SSH, exocomp ssh-serve (cmd/exocomp/ssh.go). Not charmbracelet/wish v1.x — that major still pairs with Bubble Tea v1; charm.land/wish/v2's bubbletea middleware is the release that targets Bubble Tea v2, which this project is on
a-h/templ 0.3.x MIT server-rendered web UI via go:embed, no Node build
spf13/cobra 1.x Apache-2.0 CLI surface
(deferred) Wails v2 MIT desktop wrapper of the web UI

Binary target ~20 MB; idle RSS ≤ 60 MB, with CI failing above 80 MB.

Layer 2 — pinned subprocess tools shipped in the image

Tool Pin License Role Interface contract
uv (+ uvx) 0.9.7 Apache-2.0/MIT build: uv tool install of the pinned Ansible toolchain, constraints-pinned and frozen into MANIFEST.json. Runtime: exocompd invokes uv to resolve project-declared Python deps (root requirements.txt/pyproject.toml) into hash-gated caches (Decision 0011); exocomp installs a pinned uv itself when none is present, and manages the fallback ansible toolchain with uv tool install (Decision 0016). The ansible-core toolchain switch remains an operator-only, doctor-gated escape hatch (Decision 0006) build stage + runtime (project-dep vendoring, fallback toolchain, self-bootstrap) + operator shell
ansible-core (+ Python via uv) 2.21.3 / CPython 3.14.0 GPL-3.0+ / PSF playbook execution direct ansible-playbook subprocess, stdout line-scanned into events; exit codes gate success
ansible-runner 2.4.3 (in the image, unused by the driver) Apache-2.0 not used. The driver is a direct ansible-playbook subprocess — exit-code correctness, line-scanned output — rather than ansible-runner's per-task JSON events and artifact capture. A JSON task-tree callback plugin over the direct driver is backlogged v1.1 v1.1 backlog
vendored Galaxy collections (community.sops, …) requirements.yml lock GPL-3.0 (mostly) playbook deps, offline-restorable data volume cache, in every backup
OpenTofu 1.11.5 (sha256-pinned) MPL-2.0 plan/apply/drift; native state encryption -json UI stream; HTTP backend → exocompd
restic 0.19.1 (sha256-pinned) BSD-2 encrypted backup engine, all three legs --json; exit codes only for correctness
sops 3.13.3 (sha256-pinned) MPL-2.0 repo-file secret ops, identical behaviour to the operator CLI shelled out
age (CLI) 1.3.1 (sha256-pinned) BSD-3 repo-file ops and the printed-card manual recovery path shelled out
git 2.4x GPL-2.0 project sources; bundle and push for the GitHub backup leg plumbing only
nono 0.74.0, floor ≥ 0.55.0 (sha256-pinned) Apache-2.0 kernel sandbox around job subprocesses, the enforcing tier and Linux default (Decision 0005) nono run with per-kind grants; doctor probes the Landlock ABI and asserts the denials
Litestream (optional sidecar) 0.5.x Apache-2.0 continuous WAL replication, seconds-level RPO; fast-RPO complement to the authoritative restic chain (Decision 0012). Does not encrypt — LAN-local or already-encrypted target only own process (never embedded); exocompd generates its config and observes replica freshness, does not run it

Image target ≤ 800 MB measured in CI, with a working target of ~500 MB. The base is a slim glibc image — the Python requirement rules out distroless-static.

The image is also a distribution artifact (Decision 0016): ghcr.io/abn/exocomp, multi-arch, published as a manifest list and pinned by digest. uv and uvx are lifted in with the documented COPY --from=<uv image> /uv /uvx /bin/, with the uv image pinned by digest like every other build input.

Layer 3 — deployment substrate (host-provided)

Component License Role
Podman + Quadlet (systemd) Apache-2.0 primary deployment: rootless, MemoryMax=1.5G, journald
docker-compose file — fallback for Docker-only hosts, supported at a loudly degraded tier
systemd-creds + TPM2 (optional) LGPL/GPL (host) TPM-sealed host age identity
age-plugin-yubikey (optional) MIT/Apache-2.0 hardware recipient in the keyring
recovery.sh project separately versioned DR restore — never depends on exocompd

Layer 4 — operator-supplied services (composed, not shipped)

Service License Role Required?
ntfy (self-hosted or token-authed) Apache-2.0/GPL-2.0 approval action buttons, notifications yes — the approvals UX
healthchecks (self-hosted or hosted) BSD-3 dead-man's switch; ping URL on the recovery kit yes — backup honesty
Prometheus + Alertmanager (existing) Apache-2.0 event source in; /metrics and seeded rules out recommended
NAS share (SMB/NFS) — restic leg 1 ≥ 1 of the
S3-compatible store (Garage/SeaweedFS/B2/R2 — MinIO CE archived 2026-04, see the MinIO assessment) varies restic leg 2; optional Litestream target three legs
private GitHub/Gitea repo — leg 3: age-encrypted orphan-commit archives required
Git forge for ops repos — playbook and OpenTofu sources, bundled into every backup yes
Tailscale/WireGuard (optional) BSD-3 the specified remote-access path for approvals; Caddy (Apache-2.0) is the fallback recommended

Layer 5 — budget rollup

A number in a test is a constraint; a number in a README is a wish. Every row below is enforced by something.

Budget Value Enforced by
Image size ≤ 800 MB make budgets (592 MB measured)
exocompd idle RSS ≤ 60 MB, fail above 80 MB make budgets (17 MB measured)
Container memory MemoryMax=1.5G the Quadlet unit
Concurrent jobs 2 by default, memory-derived queue config
Target hardware 4 GB N100 / Raspberry Pi 4, amd64 + arm64 reference benchmark in CI

The concurrency default is derived rather than chosen: Ansible forks and OpenTofu providers are the real memory consumers, not the daemon. CI additionally measures peak RSS of a reference Ansible-plus-OpenTofu job, so the budget covers the whole container rather than just the binary.