mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 20:26:33 +00:00
sys-svc-compose already emits a transient build.extra_hosts override for every
service that has a build, driven by INFINITO_CACHE_PACKAGE_FRONTEND_IP and
gated on the frontend CA being present. Its host list held the three apt hosts
and not argos-net.com, so 3af579449b rebuilt the same thing inside the
libretranslate role.
test_no_ansible_env_lookups found it: a role may not pull a default.env key
with lookup('env', ...), because default.env is the dev and CI contract rather
than something deployment-agnostic Ansible should reach into. Adding the host
to the shared list removes the rule violation and the duplicate in one move,
and with it LIBRETRANSLATE_CACHE_FRONTEND_IP, LIBRETRANSLATE_BUILD_HOSTS and
the container_extra_hosts call.
The URL lint probes every URL the repository mentions and a bare GET on
argos-net.com answers 404, measured, while the model paths Nexus prefixes
underneath /v1/ answer 206. The base URL is what Nexus needs, so the line is
marked the way the deb.debian.org redirector two lines above it already is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
288 lines
8.4 KiB
Python
Executable File
288 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 = (
|
|
"archive.ubuntu.com",
|
|
"argos-net.com",
|
|
"deb.debian.org",
|
|
"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())
|