Decision 0011 — Project dependency resolution: uv at runtime for declared manifests
Project dependencies resolve from root requirements.txt or pyproject.toml into hash-gated caches, with uv shipped at runtime.
Superseded by 0015 for the collections decision; the venv, tofu-provider, tofu-modules, and system-package decisions stand.
Status
Accepted · 2026-08-22 — refines the boundary in Decision 0006.
Context
A real playbook rarely runs on ansible-core alone. Modules pull Python libraries the
controller must have — proxmoxer for Proxmox, netaddr/dnspython for networking,
boto3 for AWS-shaped APIs, jmespath for json_query, provider SDKs for dynamic
inventories. Collections (galaxy) and tofu providers/modules are the other two dependency
planes. Two constraints pull against each other:
- Deps must be MET — a run cannot fail because
proxmoxeris absent. - Deps must NOT be re-fetched every run — pulling the world on each job is slow and reintroduces the network dependency the offline-cache work removes.
Operators declare Python dependencies the ordinary way: a requirements.txt or a
pyproject.toml (PEP 621 [project.dependencies], [dependency-groups]) at the project
root — the files their editor, CI, and uv already understand. exocomp should honour
those, not demand a bespoke manifest.
This bumps a boundary in Decision 0006: "the control plane never invokes uv … no on-demand installs on the job path." That rule exists to keep the ansible-core toolchain switch an operator-initiated act with no registry on the DR path. It was not written to forbid exocomp from resolving a project's declared dependencies — Decision 0006 itself (Consequences) anticipates per-project venvs. This record draws the line precisely.
Decision
exocomp resolves a source's declared dependencies into hash-gated, offline-restorable caches, and MAY invoke uv to do so — for project dependencies only, never for the core toolchain.
Manifest discovery (repo-side default; .exocomp/ is the explicit override, per the
source model):
- Python — project-root
pyproject.toml(PEP 621 deps /[dependency-groups]) orrequirements.txt;.exocomp/requirements.txtoverrides. Resolved by uv into a managed venv atcache/python/<source>/<manifest-hash>under the data-volume toolchain dir; the ansible driver pointsansible_python_interpreter(and PATH) at it.
Execution model — every ansible run executes inside a uv-managed venv scoped to its
source (project/workspace). The run is never launched against the bare shared
interpreter; the driver always activates a uv venv keyed to the source + manifest hash.
That venv carries ansible-core (project-pinned; default = the image toolchain pin from
Decision 0006) + ansible-runner + the source's declared Python deps. Per-source isolation is
cheap because uv seeds each venv from its shared package cache by hardlink (no re-download,
offline once cached), and the manifest hash means an unchanged project reuses its venv as a
no-op. A source that declares NO extra deps resolves to the shared base toolchain venv (also
uv-managed — there is nothing project-specific to isolate); the moment it declares one, it
gets its own. This gives the homelab/hybrid case what it wants — a playbook can pin its own
ansible-core and libraries without disturbing another project — while the DR-path runbooks
(backup, restore drill, host onboarding) still execute the image-pinned toolchain, never a
project venv (Decision 0006: a fresh restore has the image before it has the volume).
- Ansible collections —
.exocomp/requirements.yml(or a repo-rootrequirements.yml) vendored intocache/collections(already built;ANSIBLE_COLLECTIONS_PATH). - Tofu providers — the existing plugin cache +
filesystem_mirror(already built). - Tofu modules — a persistent per-workspace module cache so registry/git modules are
not re-fetched every
init.
Hash-gated: a manifest's content hash decides whether to re-resolve. Unchanged manifest → reuse the cache → resolve offline. This is what makes "met, but never fresh every run" true and keeps the network off the steady-state job path.
uv ships in the distribution. It is already in the runtime image (Containerfile runtime
stage + MANIFEST.json pin uv==0.9.7); this record makes that load-bearing, not incidental.
For a host (non-container) install, uv joins the pinned runtime tool set alongside
ansible-core/tofu/restic/age/sops, and exocomp doctor probes it (today it probes
ansible/tofu/restic/age/sops but not uv/git — a later pass adds them).
Boundary refinements to Decision 0006 (what stays true, what changes):
- UNCHANGED — the toolchain switch stays operator-initiated. exocomp never runs
uv tool install ansible-core==Yon the job path; swapping ansible-core is still a human act at a shell (Decision 0006). - UNCHANGED — the DR path is pinned to the image. Backup, restore-drill, and host- onboarding runbooks execute the baked-in image toolchain and image-pinned deps, never a volume-resolved project venv: a fresh restore has the image before it has the volume.
- CHANGED — the control plane MAY invoke uv for project-dependency vendoring, hash-gated and offline-preferring, as a run-lifecycle step. This is distinct from the forbidden on-demand toolchain install: it resolves a project's declared deps into a cache, not the interpreter/ansible-core itself, and it does not touch the network when the hash is stable.
- System packages remain out of runtime scope — the host provides them or the operator bakes a container layer; bring-your-own-runner is the post-v1 seam for arbitrary baked-in system deps.
Consequences
- A vendoring step (
[pre-run] resolve deps if manifest hash changed) becomes part of the run lifecycle, expressible as a step-ledger step; on a stable hash it is a fast no-op. - Project venvs live in the data-volume toolchain dir → backed up, offline-restorable, drill-covered — the same guarantees Decision 0006 gives the core toolchain.
- uv is now a declared runtime dependency, not a build-only tool; doctor gains uv (and git) probes and the bill of materials lists uv as runtime.
- The
<manifest-hash>venv path means switching a dependency set is a new cache entry, not a mutation — an old venv is reusable on rollback, matching the upgrade/rollback model. - A malformed or unresolvable manifest fails the vendoring step loudly (fail-closed), so a run never proceeds against half-met dependencies.
pyproject.tomlis parsed with a real TOML parser (github.com/BurntSushi/toml, a zero-dependency, widely-used library), not a line scanner: the control plane collects the[project].dependenciesarray and every[dependency-groups]group array, and hands those specifiers to uv. A PEP 735include-groupentry is skipped (it names a group, not a package) and[project.optional-dependencies]extras are excluded (opt-in, not installed by default). A structurally brokenpyproject.tomlis an error, so it fails the vendoring step rather than silently resolving to fewer dependencies.