mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
287 lines
8.4 KiB
Python
Executable File
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())
|