mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
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.
345 lines
11 KiB
Python
Executable File
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())
|