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 proxmoxer is 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]) or requirements.txt; .exocomp/requirements.txt overrides. Resolved by uv into a managed venv at cache/python/<source>/<manifest-hash> under the data-volume toolchain dir; the ansible driver points ansible_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-root requirements.yml) vendored into cache/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==Y on 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.toml is 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].dependencies array and every [dependency-groups] group array, and hands those specifiers to uv. A PEP 735 include-group entry is skipped (it names a group, not a package) and [project.optional-dependencies] extras are excluded (opt-in, not installed by default). A structurally broken pyproject.toml is an error, so it fails the vendoring step rather than silently resolving to fewer dependencies.