mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
meta/volumes.yml says where a volume lands inside its container. Wherever a
second file spelled the same path out, the two drifted independently: the mount
moves, the consumer keeps pointing at the old place, and nothing fails until the
process looks for content that is not there. e031b7f61 fixed exactly this for
web-app-moodle; this generalises it and makes the rule hold.
The lookup gains what a caller needs. `targets` maps each mounting service to
its container path, and `target` is the one path an entry mounts at -- present
only when every mount agrees. An entry that lands at different paths per service
omits `target` rather than picking one, and the caller names the service:
"{{ lookup('volume', application_id, 'prometheus_config').target }}"
"{{ lookup('volume', 'web-app-prometheus', 'host_sys').targets['node-exporter'] }}"
66 literals across 48 role files now derive. Replacement only happened where the
path stood alone -- /srv/app inside /srv/app/data is a different path -- and
every rewritten file was re-parsed, Jinja and YAML alike. Three sites the tool
refused rather than guess were done by hand: zammad's path sat in a Jinja list
literal, seaweedfs' inside a quoted string feeding a filter, and openresty's
/var/run/docker.sock is not a mount target at all but the socket on the host,
which only happens to spell the same string. That one carries the single
nocheck.
tests/lint/repository/spot/test_volumes_path_single_spot.py keeps it that way.
roles/*/files/ is out of scope by construction: it is copied verbatim into an
image and executed there, so a Dockerfile or entrypoint has no Ansible to
evaluate a lookup with.
One defect this found, and it was in the tooling rather than the roles: deciding
ambiguity AFTER filtering short paths made web-app-prometheus' host_sys look
single-valued -- it mounts /sys for cadvisor and /host/sys for node-exporter,
and /sys is below the length filter. The template would have rendered an
undefined `.target`. Both the converter and the rule now decide ambiguity over
every target and filter afterwards, and that call reads targets['node-exporter'].
Verified by resolving rather than by reading: all 70 accesses in the diff were
run against the real registry and every one yields a non-empty path equal to the
literal it replaced. make test-lint: 328 passed.
Not verified: no Ansible render, so a call site whose surrounding context cannot
build the applications registry would still fail at deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
"""Lookup ``volume``: read the canonical ``meta/volumes.yml`` registry.
|
|
|
|
Two call forms:
|
|
|
|
``lookup('volume', application_id, name)`` — return the normalised dict
|
|
for a single semantic entry. The returned dict always carries:
|
|
|
|
- ``name`` the EFFECTIVE docker volume name
|
|
(entry's ``name:`` if set, otherwise the YAML key)
|
|
- ``semantic_name`` the YAML key from ``meta/volumes.yml``
|
|
- ``docker_name`` the explicit ``name:`` field (alias for ``name``
|
|
during the dict-of-dicts migration), ``''`` if absent
|
|
- ``path`` the legacy ``path:`` field, ``''`` if absent
|
|
- ``type`` one of ``volume`` | ``bind`` | ``config`` | ``secret``
|
|
| ``tmpfs``, defaults to ``volume``
|
|
- ``source`` for binds / configs / secrets, ``''`` if absent
|
|
- ``nfs`` NFS opt-in (bool or dict), ``None`` if absent
|
|
- ``mounts`` the raw mounts list, ``[]`` if absent
|
|
- ``targets`` ``{service: container path}`` built from ``mounts``
|
|
- ``target`` the one container path this entry mounts at, present
|
|
only when every mount agrees. An entry that lands at
|
|
different paths per service omits the key rather than
|
|
picking one; read ``targets[service]`` there.
|
|
|
|
``target`` is what lets ``meta/volumes.yml`` stay the single source of a
|
|
container path: a role defines its constant from the registry
|
|
|
|
PROMETHEUS_CONFIG_FILE: "{{ lookup('volume', application_id, 'config').target }}"
|
|
|
|
rather than repeating the literal, which nothing keeps in step with the
|
|
mount it describes.
|
|
|
|
``lookup('volume', application_id)`` — return the WHOLE canonical
|
|
registry as a dict keyed by semantic name. Useful for tasks that loop
|
|
over every declared volume (e.g. NFS subdir pre-creation). Returns
|
|
``{}`` for roles with no meta/volumes.yml.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from ansible.errors import AnsibleError
|
|
from ansible.plugins.lookup import LookupBase
|
|
|
|
# nocheck: lookup-cache-import
|
|
from utils.cache.applications import get_application_defaults, get_canonical_volumes
|
|
|
|
|
|
class LookupModule(LookupBase):
|
|
def run(
|
|
self,
|
|
terms: list[Any] | None,
|
|
variables: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
) -> list[dict[str, Any]]:
|
|
if not terms or len(terms) not in (1, 2):
|
|
raise AnsibleError(
|
|
"volume lookup requires 1 (application_id) or 2 "
|
|
"(application_id, name) terms"
|
|
)
|
|
application_id = str(terms[0]).strip()
|
|
if not application_id:
|
|
raise AnsibleError("volume lookup: application_id must be non-empty")
|
|
|
|
canonical = get_canonical_volumes(application_id)
|
|
if not canonical:
|
|
get_application_defaults()
|
|
canonical = get_canonical_volumes(application_id)
|
|
|
|
if len(terms) == 1:
|
|
return [dict(canonical)]
|
|
|
|
name = str(terms[1]).strip()
|
|
if not name:
|
|
raise AnsibleError("volume lookup: name must be non-empty")
|
|
|
|
if not canonical:
|
|
raise AnsibleError(
|
|
f"volume lookup: no canonical meta/volumes.yml entries for "
|
|
f"role {application_id!r}"
|
|
)
|
|
|
|
entry = canonical.get(name)
|
|
if not isinstance(entry, dict):
|
|
raise AnsibleError(
|
|
f"volume lookup: no entry named {name!r} in canonical "
|
|
f"meta/volumes.yml for role {application_id!r}"
|
|
)
|
|
|
|
docker_name = entry.get("name", "")
|
|
mounts = entry.get("mounts", [])
|
|
resolved = {
|
|
"name": entry.get("name") or name,
|
|
"semantic_name": name,
|
|
"docker_name": docker_name,
|
|
"path": entry.get("path", ""),
|
|
"type": entry.get("type", "volume"),
|
|
"source": entry.get("source", ""),
|
|
"nfs": entry.get("nfs", None),
|
|
"mounts": mounts,
|
|
"targets": _targets(mounts),
|
|
}
|
|
distinct = {t for t in resolved["targets"].values() if t}
|
|
if len(distinct) == 1:
|
|
resolved["target"] = next(iter(distinct))
|
|
return [resolved]
|
|
|
|
|
|
def _targets(mounts: Any) -> dict[str, str]:
|
|
"""Map each mounting service to the container path it receives.
|
|
|
|
Args:
|
|
mounts: the entry's raw ``mounts`` list.
|
|
"""
|
|
if not isinstance(mounts, list):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for mount in mounts:
|
|
if not isinstance(mount, dict):
|
|
continue
|
|
service = str(mount.get("service", "") or "").strip()
|
|
target = str(mount.get("target", "") or "").strip()
|
|
if service and target:
|
|
out[service] = target
|
|
return out
|