Files
kevinveenbirkenbachandClaude Opus 5 575a5a87ac refactor(layout): sort shipped scripts by language and split the unit suite
Every script a role ships now lives under `files/<language>/`, and the unit
suite is split the same way: `tests/unit/python/` beside new sibling trees for
the other languages. `tests/lint/filesystem/test_role_files_layout.py` keeps it
that way; directories whose shape is dictated from outside carry a `.nocheck`
naming the constraint (Docker build contexts, WordPress mu-plugins, the Joomla
extension, Odoo addons, Playwright suites, the `files/test/test.sh` harness
that `test-e2e-cli` resolves generically).

This cannot be applied in halves. Moving a file and repointing what names it
are the same change: 14 references resolve only at deploy time, so the tree is
green either before or after, never between.

Those 14 were found by static resolution over every reference class, not by the
test suite -- a full `make quality-high` was green while all of them were
broken:

  * 7 `src:` values built from a role variable holding a bare filename
    (svc-bkp-remote-2-local, decidim, espocrm, friendica, joomla, pixelfed x2).
    The variable doubles as the destination name inside the container, so the
    language segment belongs at the call site, not in the variable.
  * `sys-service/tasks/04_base.yml` resolved every consumer's systemd script
    through `first_found(..., errors='strict')` over `files/script.{sh,py}`.
    18 roles ship exactly that and have no template fallback: the play would
    have died on each.
  * `sys-front-inj-javascript` fell back to `<role>/files/javascript.js`; the
    interface now reads `files/javascript/javascript.js`.
  * `sys-front-inj-logout/templates/head_sub.j2` built the cache-busting mtime
    path through `path_absolute(... 'files', ...)`.
  * 4 `ansible.builtin.script:` invocations naming a bare command
    (pull.py, prune.sh, set_postgres_superuser_password.sh, ganesha-serve.sh).
  * `dev-python/files/shell/install.sh` pointed at a sibling role's script.

Depth-dependent paths inside the moved trees were bumped accordingly: 59
`parents[N]` computations, 12 relative climbs, and the per-role recover CLI,
whose path `cli/administration/recover/recoverers.py` still assembled from the
pre-move location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 01:31:09 +02:00

287 lines
8.4 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import os
import shlex
import sys
from pathlib import Path
import yaml
def detect_env_file(project_dir: Path) -> Path | None:
"""Detect compose env file: <dir>/.env or legacy <dir>/.env/env."""
c1 = project_dir / ".env"
if c1.is_file():
return c1
c2 = project_dir / ".env" / "env"
if c2.is_file():
return c2
return None
def _yaml_services(path: Path) -> set[str]:
"""Return the top-level `services:` keys declared in a compose YAML file."""
try:
with path.open() as f:
doc = yaml.safe_load(f) # nocheck: direct-yaml
except (OSError, yaml.YAMLError):
return set()
if not isinstance(doc, dict):
return set()
services = doc.get("services")
if not isinstance(services, dict):
return set()
return set(services.keys())
def ca_override_is_stale(base_compose: Path, ca_override: Path) -> bool:
base_services = _yaml_services(base_compose)
if not base_services:
return False
override_services = _yaml_services(ca_override)
if not override_services:
return False
return bool(override_services - base_services)
def detect_compose_files(project_dir: Path) -> list[Path]:
"""Detect Compose file stack: compose.yml + optional overrides."""
base = project_dir / "compose.yml"
if not base.is_file():
raise FileNotFoundError(f"Missing compose.yml in: {project_dir}")
files = [base]
override = project_dir / "compose.override.yml"
if override.is_file():
files.append(override)
ca_override = project_dir / "compose.ca.override.yml"
if ca_override.is_file():
if ca_override_is_stale(base, ca_override):
print(
"[compose] skipping stale compose.ca.override.yml "
"(declares services not in compose.yml; awaiting regeneration)",
file=sys.stderr,
)
else:
files.append(ca_override)
cache_override = generate_cache_override(project_dir, base)
if cache_override is not None:
files.append(cache_override)
return files
_CACHE_HTTP_HOSTNAMES = (
"deb.debian.org",
"archive.ubuntu.com",
"security.ubuntu.com",
)
def _cache_frontend_ca_present() -> bool:
"""True when the package-cache frontend CA exists non-empty, mirroring the ca.sh `-s` gate for an active cache profile."""
ca_file = (os.environ.get("INFINITO_CACHE_PACKAGE_FRONTEND_CA_FILE") or "").strip()
if not ca_file:
return False
ca_path = Path(ca_file)
return ca_path.is_file() and ca_path.stat().st_size > 0
def generate_cache_override(project_dir: Path, base_compose: Path) -> Path | None:
"""Emit transient build.extra_hosts override only when the package-cache frontend is provisioned (CA present)."""
cache_ip = (os.environ.get("INFINITO_CACHE_PACKAGE_FRONTEND_IP") or "").strip()
if not cache_ip or not base_compose.is_file():
return None
if not _cache_frontend_ca_present():
return None
with Path(base_compose).open() as f:
doc = yaml.safe_load(f) # nocheck: direct-yaml
services = (doc or {}).get("services") or {}
services_with_build = sorted(
name
for name, svc in services.items()
if isinstance(svc, dict) and svc.get("build")
)
if not services_with_build:
return None
extra_hosts = [f"{host}:{cache_ip}" for host in _CACHE_HTTP_HOSTNAMES]
override_doc = {
"services": {
name: {"build": {"extra_hosts": list(extra_hosts)}}
for name in services_with_build
}
}
out = project_dir / "compose.cache.override.yml"
with Path(out).open("w") as f:
yaml.safe_dump(override_doc, f, sort_keys=True) # nocheck: direct-yaml
return out
def resolve_files(project_dir: Path, files: list[str]) -> list[Path]:
"""Resolve -f/--file paths against project_dir."""
out: list[Path] = []
for f in files:
p = Path(f)
if not p.is_absolute():
p = project_dir / p
out.append(p.resolve())
return out
_ENV_PARSING_SUBCOMMANDS = {"build", "config"}
def _subcommand(passthrough: list[str]) -> str:
return passthrough[0] if passthrough else ""
def strip_env_files(project_dir: Path, files: list[Path]) -> list[Path]:
"""Return env_file-free copies of the compose files for build/config."""
out: list[Path] = []
for f in files:
with Path(f).open() as fh:
doc = yaml.safe_load(fh) # nocheck: direct-yaml
for svc in ((doc or {}).get("services") or {}).values():
if isinstance(svc, dict):
svc.pop("env_file", None)
dst = project_dir / f"{Path(f).stem}.noenv.yml"
with Path(dst).open("w") as fh:
yaml.safe_dump(doc, fh, sort_keys=False) # nocheck: direct-yaml
out.append(dst)
return out
def _declared_services(files: list[Path]) -> set[str] | None:
"""Top-level `services:` keys across *files*; None if any file fails to parse."""
services: set[str] = set()
for f in files:
try:
with Path(f).open() as fh:
doc = yaml.safe_load(fh) # nocheck: direct-yaml
except (OSError, yaml.YAMLError):
return None
if isinstance(doc, dict) and isinstance(doc.get("services"), dict):
services.update(doc["services"].keys())
return services
def build_cmd(
project: str,
project_dir: Path,
passthrough: list[str],
extra_files: list[str] | None = None,
) -> list[str] | None:
"""Compose files (extra_files last); None for `up` with no services."""
files = detect_compose_files(project_dir)
if extra_files:
files.extend(resolve_files(project_dir, extra_files))
if _subcommand(passthrough) == "up":
services = _declared_services(files)
if services is not None and not services:
return None
cmd: list[str] = ["docker", "compose", "-p", project]
if _subcommand(passthrough) in _ENV_PARSING_SUBCOMMANDS:
for f in strip_env_files(project_dir, files):
cmd += ["-f", str(f)]
else:
for f in files:
cmd += ["-f", str(f)]
env_file = detect_env_file(project_dir)
if env_file:
cmd += ["--env-file", str(env_file)]
cmd += passthrough
return cmd
def main() -> int:
ap = argparse.ArgumentParser(
description="Infinito.Nexus docker compose wrapper (auto env + overrides)"
)
ap.add_argument(
"--chdir",
help="Compose project directory (default: current working directory)",
)
ap.add_argument(
"-f",
"--file",
action="append",
dest="files",
help="Additional compose file(s) appended after auto-detected files",
)
ap.add_argument(
"--project",
help="Compose project name (-p) (default: basename of chdir)",
)
ap.add_argument(
"--debug",
action="store_true",
help="Print resolved command to stderr before execution",
)
ap.add_argument(
"args",
nargs=argparse.REMAINDER,
help="Arguments passed to `docker compose` (e.g. up -d, restart, down -v, ps -a)",
)
args = ap.parse_args()
project_dir = Path(args.chdir) if args.chdir else Path.cwd()
project_dir = project_dir.resolve()
if not project_dir.is_dir():
print(
f"[compose] --chdir is not a directory: {project_dir}",
file=sys.stderr,
)
return 2
project = args.project or project_dir.name
passthrough = args.args
if passthrough and passthrough[0] == "--":
passthrough = passthrough[1:]
if not passthrough:
print("[compose] No docker compose args provided.", file=sys.stderr)
return 2
try:
cmd = build_cmd(
project=project,
project_dir=project_dir,
passthrough=passthrough,
extra_files=args.files,
)
except Exception as exc:
print(f"[compose] {exc}", file=sys.stderr)
return 2
if cmd is None:
print(
"[compose] project defines no services; skipping 'up'.",
file=sys.stderr,
)
return 0
if args.debug:
print(">>> " + " ".join(shlex.quote(x) for x in cmd), file=sys.stderr)
os.execvp(cmd[0], cmd) # noqa: S606
return 0
if __name__ == "__main__":
raise SystemExit(main())