#!/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: /.env or legacy /.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())