Decision 0015 — Per-source ansible collection isolation
Ansible collections go per-source in a content-hash-keyed cache resolved at claim time and garbage-collected with the source lifecycle.
Status
Accepted · 2026-09-06 — supersedes Decision 0011's collections decision; the venv decision in Decision 0011 stands.
Context
The dependency model has three planes with three scopes:
| Plane | Cache | Scope | Versions per item | Lifecycle GC |
|---|---|---|---|---|
| Python deps (uv venvs, Decision 0011) | cache/python/<source>/<hash> |
per-source | per-source, hash-gated, concurrent | PruneVenvs (keep-N + since) |
| Ansible collections | cache/collections/ (flat) |
shared | one per collection, global | none |
| Tofu providers | cache/tofu/plugin-cache (+ per-workspace modules) |
shared providers / per-workspace modules | one per provider | none |
The venv plane is the model we already built and want everywhere: a per-source
namespace keyed by <source>/<content-hash>, ready-marker-gated (unchanged →
offline reuse), retention-pruned, and resolved at claim time in the daemon's
ansible driver (cmd/exocomp/ansible.go) so every face (CLI, web, API, MCP)
gets it uniformly.
Collections are the one non-isolated plane. A single flat dir is appended to
every run's ANSIBLE_COLLECTIONS_PATH; ansible-galaxy installs by name, so a
second version overwrites the first — two sources cannot pin different versions
of one collection. Vendoring is manual-only (playbook vendor, which requires
an explicit --requirements flag); nothing hooks source add/sync/run; nothing
is pruned. A requirements-file discoverer already exists
(internal/deps/galaxy.go: .exocomp/requirements.yml override else root
requirements.yml, content-hashed, sharing FindProjectRoot with the Python
manifest) but today has no production caller — playbook vendor never
invokes it.
The operator's requirement: unify the isolation model — make collections behave the way venvs already do.
Decision
Apply the venv model to collections: a per-source, content-hash-keyed
collections dir, resolved at claim time, with lifecycle GC. This supersedes
Decision 0011's shared cache/collections vendoring decision.
Layout
cache/collections/<source>/<requirements-hash>/
ansible_collections/<ns>/<name>/...
cache/collections/<source>/<requirements-hash>/.exocomp-galaxy-ready
<source> is deps.SourceKey(manifest.Root) — the same slug+hash namespace a
venv uses — so a source's venv and its collections live under one coherent key.
The shared cache/collections dir survives only as a fallback for
collections no source declares; per-source wins by path order. Once playbook vendor becomes source-keyed, nothing writes the shared dir anymore — it is a
legacy fallback for upgrades and operator-placed collections, kept readable so
the offline guarantee never regresses for an existing deployment.
Discovery
.exocomp/requirements.yml overrides a repo-root requirements.yml, matching
the Python manifest precedence (already implemented in internal/deps/galaxy.go;
this record adds the Root/namespace plumbing and the first production callers).
Vendoring = resolve at claim time
Mirroring the venv resolver, the ansible driver resolves the source's
collections before the play runs: on a matching ready marker it is a no-op with
no Galaxy dial; on a new hash it runs ansible-galaxy collection install -r <req> -p cache/collections/<source>/<hash> as a subprocess of the claimed job
(fail-closed — an unresolvable requirements file fails the run). The daemonless
inline drain path resolves the same way before enqueue, matching the venv's two
call sites. The explicit playbook vendor command stays as the manual
queued-job path, now source-keyed. This is the "vendoring step, part of the run
lifecycle" Decision 0011 anticipated and the same pattern offline-caches.md already
documents for the venv resolver: it is a subprocess of a queued job, not a new
execution path, and an inline dial is bounded by the playbook job's own timeout.
Under the sandbox tier the ansible-galaxy invocation runs wrapped in the same
resolver-sandbox profile uv uses (denied the data dir, keys, and database;
granted write on the per-source collections dir, read on the requirements and
tool tree, HTTPS egress) — ansible-galaxy and its galaxy-server interaction
are as untrusted as a manifest's build backend. Like uv's download cache, the
galaxy download/temp cache needs an explicit daemon-pre-created, granted write
path (Landlock grants attach only to existing paths). The cache lives at
<data dir>/cache/galaxy, a sibling of the collections cache root, and is
split so the resolver's HOME — <data dir>/cache/galaxy/home — is
never granted: nono keeps its own protected state root under $HOME and
refuses to launch when a grant would let the sandboxed process reach it, so a
grant covering the home dir (the earlier cache/collections/.galaxy-cache
layout) failed every sandboxed resolve. Only the directories galaxy actually
writes — ANSIBLE_HOME (~/.ansible, which carries GALAXY_CACHE_DIR and
DEFAULT_LOCAL_TMP) and TMPDIR — are granted, so a fresh provision under
the enforcing tier writes its download/temp tree exactly where the kernel
allows.
Resolution
The driver appends the per-source collections dir ahead of the shared
fallback so a source's pin wins. The per-job sandbox read-grant must split
the CollectionsPath value on os.PathListSeparator and grant each component
— the current single-entry grant (internal/executor/sandbox_drivers.go) would
name one nonexistent joined path. This split restores grant correctness; it
does not give per-source read isolation: the base job grant
(cmd/exocomp/sandbox.go) already reads the cache/collections parent for
every sandboxed ansible job, so any job can still read any source's collections
dir. The base grant stays (it is what keeps the legacy fallback readable); the
per-run grant ensures the per-source path is readable at all.
Lifecycle GC
- Source delete — after
DeleteSource, remove that source'scache/python/<source>andcache/collections/<source>— but only when no other registered source resolves to the same root (a shared root means a sharedSourceKey; purging would break a sibling). - Source edit (requirements hash changes) — a new dir is provisioned; old
hashes for that source are pruned with the same keep-N + since policy as
PruneVenvs. - The shared fallback dir is never purged by a single source's lifecycle.
Scope exclusions
Galaxy roles are out of scope: they install through a different binary,
resolve through roles_path, have no cache today, and the ecosystem moves roles
into collections. A collection that contains roles is fully in scope. Roles get
their own record if a need appears. Keeping a shared fallback for undeclared
collections is part of the decision (see Layout).
System packages
Unchanged from Decision 0011: out of runtime scope — the host or a container layer provides them.
Consequences
- Two sources can pin different versions of one collection; each resolves offline from its own hash-keyed dir.
- A source's collections travel with its venv under one
<source>key — one GC story, one backup story. - Galaxy is dialed only on a hash change (or an explicit vendor), preserving the offline-run guarantee (invariant 7).
- The per-run sandbox grant splits
CollectionsPathand grants each component; the base job grant keepscache/collectionsreadable (correctness, not per-source read isolation). DeleteSourcegains a cache side effect — must be reviewable as part of the source lifecycle, and must be reference-aware (a source whose root is shared with another registered source keeps the caches).- Decision 0011's collections section is superseded; its venv, tofu-provider, tofu-modules, and system-package decisions are untouched.
Addendum — 2026-10-06: local-directory artifacts are read-granted
A requirements file may name a collection on the local disk with type: dir
(or type: file), pointing at a path outside the project root — typically a
sibling checkout (… - name: …/ansible-unifi-network /
type: dir). The resolver sandbox granted only the project root, so
ansible-galaxy opening the artifact's galaxy.yml was refused with EACCES
and the deps step failed closed:
PermissionError: [Errno 13] Permission denied: b'/…/ansible-unifi-network/galaxy.yml'
deps.DiscoverGalaxy now reports the existing local artifact paths the
requirements file names, and the sandboxed resolve adds exactly those to the
profile's read grants on top of the project root. A galaxy-server, git, or url
entry is dialed rather than read and adds no grant; a path that does not exist
adds none, because a Landlock grant attaches only to a real path. The profile
guard is unchanged and still refuses any grant that would reach the data dir or
its keys. This is a read-only widening drawn from source content — the same
shape as the run sandbox granting the inventory and extra-vars paths an argv
names — and is recorded as a residual in
the threat model.