Decision 0006 — uv build-time toolchain with an operator escape hatch
A uv build-time Python toolchain plus a doctor-gated operator escape hatch in the data volume.
Status
Accepted · 2026-08-21
Context
ansible-core is Python, so the image carries a Python layer whatever else is true. That
layer needs three properties at once, and they pull against each other:
- Determinism — a restore must produce the same toolchain that produced the backup.
- A version switch for the operator — homelabs run playbooks written against a particular ansible-core, and rebuilding the image to change it is not an acceptable answer.
- No registry on the DR path — an on-demand installer anywhere near the job path reintroduces the network dependency the offline-cache work exists to remove.
Decision
Build the Python layer with uv: uv python install (standalone CPython) plus one keyed
tool install — uv tool install ansible-core==X --with ansible-runner --with <module deps>
— pinned by a committed constraints file, with the resolved set frozen into
MANIFEST.json alongside the tool pins. (uv tool install does not consume a project
uv.lock; the constraints file plus the frozen manifest give the same determinism.)
Module-side dependencies (netaddr, jmespath, pywinrm, …) live inside the tool venv via
--with.
uv ships in the image, and UV_TOOL_DIR / UV_CACHE_DIR point into the data volume.
That single placement turns "uv happens to be present" into a designed operator escape
hatch: a human can uv tool install ansible-core==Y at a shell and the switch
- persists across container restarts,
- rides inside every backup — so a restore comes back with the switched version, no registry on the DR path, and
- is visible:
exocomp doctorreports which binary each shim resolves to and diffs it againstMANIFEST.json.
Boundary rules keep the hatch from becoming a hole:
- The control plane never invokes uv. No
uvx, no on-demand installs on the job path. The hatch is operator-initiated, at a shell, on purpose. - Out-of-range versions are loud. The golden-file parsing corpus is valid only for
tested version ranges;
doctorflags a switched toolchain and jobs refuse to run against it unless the project carries an explicit, acknowledged override. - DR-path runbooks — backup, restore drill, host onboarding — always execute the baked-in image pins, never the volume toolchain: a fresh restore has the image before it has the volume. Enforced by the doctor, asserted by the drill.
Consequences
- Per-project Python module dependencies may likewise be uv-installed into cached venvs
under the same volume toolchain directory — backed up, offline-restorable, drill-covered.
Decision 0011 refines the "control plane never invokes
uv" boundary for exactly this case: exocomp MAY resolve a project's declared deps
(root
requirements.txt/pyproject.toml) via uv, hash-gated and off the DR path, while the ansible-core toolchain switch stays operator-initiated. - The 800 MB image ceiling stands and the working target drops to ~500 MB.
- The escape hatch widens the tested surface: a switched toolchain is a supported state, not a supported configuration, and the doctor is what makes the difference visible.
- Toolchain state now lives in the data volume, so volume loss is toolchain loss. That is acceptable precisely because the DR path is pinned to the image instead.