Files
computer-playbook/roles/sys-svc-compose/files/python/compose.py
T
kevinveenbirkenbach 230d9a1e5e feat(cache): declare every cached upstream once and generate the rest
The package cache was configured in six places at once: the nginx upstream
map, the consumer compose override, pip.conf, .npmrc, the apt sources and the
twenty ensure_proxy_repo calls that bootstrap Nexus each spelled the same
hostnames, repository names and suites by hand. A new upstream meant editing
five files and hoping none was missed, and the docs told contributors to do
exactly that.

A role now declares what it needs under cache: in its meta/networks.yml, and
everything else is derived from it. svc-cache-package holds the general
upstreams; web-svc-libretranslate holds its own. All nine derived files are
generated and gitignored, so a second spelling of a hostname cannot exist.

utils.cache.hosts reads the declarations without PyYAML, because the .env
generator reaches them before any dependency is installed; it raises rather
than truncate when a comment sits at column 0 inside the block, which would
otherwise drop every declaration below it in silence. utils.cache.records
owns the Nexus record format and the Ubuntu mirror resolution, so the dev
stack and the role provision from one rendering.

A build may only resolve a cached host it can verify: the plain-HTTP hosts
are hijacked for every image, and the TLS hosts only for an image whose
Dockerfile installs the frontend CA. Hijacking all of them would fail every
pip and npm build with CERTIFICATE_VERIFY_FAILED.

svc-cache-package deploys the cache and its TLS frontend by calling the same
shell scripts the dev stack calls, rather than reimplementing them as tasks.
The role is invokable through the new svc-cache category and inert until a
host carries it.

Verified: the twenty Nexus payloads, the fifteen nginx server blocks and all
seven package-manager configs are byte-identical to the hand-written files
they replace, and the new category adds one role to the deploy order while
preserving the relative order of the other 180.

The role has not been deployed yet.
2026-09-27 16:28:48 +02:00

345 lines
11 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_HOSTS_KEY = "INFINITO_CACHE_HOSTS"
_CACHE_HTTP_HOSTS_KEY = "INFINITO_CACHE_HTTP_HOSTS"
_CA_INSTALLER = "package-frontend-ca.sh"
def _cache_http_hostnames() -> list[str]:
"""Return the hosts the frontend serves, as `make dotenv` derived them.
The list lives in the ``cache:`` entries of every role's meta/networks.yml
and reaches this script only through the environment, because the script
is installed as a standalone binary and cannot import the repository.
"""
raw = os.environ.get(_CACHE_HOSTS_KEY) or ""
return [host for host in (part.strip() for part in raw.split(",")) if host]
def _cache_http_only_hostnames() -> list[str]:
"""Return the hosts the frontend serves without TLS."""
raw = os.environ.get(_CACHE_HTTP_HOSTS_KEY) or ""
return [host for host in (part.strip() for part in raw.split(",")) if host]
def _installs_frontend_ca(service: dict) -> bool:
"""Whether this service's image build trusts the frontend's CA.
Hijacking a TLS host for a build that does not install the CA turns every
`pip install` into CERTIFICATE_VERIFY_FAILED, so the plain-HTTP hosts are
the only ones safe by default. A Dockerfile that runs the CA installer
earns the rest.
"""
build = service.get("build")
context = build.get("context", ".") if isinstance(build, dict) else str(build)
name = (
build.get("dockerfile", "Dockerfile")
if isinstance(build, dict)
else "Dockerfile"
)
path = Path(context) / name
try:
return _CA_INSTALLER in path.read_text(encoding="utf-8")
except OSError:
return False
def _buildable_hostnames(service: dict) -> list[str]:
"""Return the hosts this service's build may safely resolve to the cache."""
if _installs_frontend_ca(service):
return _cache_http_hostnames()
return _cache_http_only_hostnames()
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
hostnames = _cache_http_hostnames()
if not hostnames:
raise SystemExit(
f"the package cache is active but {_CACHE_HOSTS_KEY} is empty, so "
"every build would silently fetch from upstream; run `make dotenv` "
"to derive it from the roles' cache: declarations"
)
override_doc = {
"services": {
name: {
"build": {
"extra_hosts": [
f"{host}:{cache_ip}"
for host in _buildable_hostnames(services[name])
]
}
}
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())