#!/usr/bin/env python3 """Pre-build every challenge's shared image (`services-`) in parallel. The admin toggle only reuses `image: services-` when that image already exists locally; otherwise it keeps `build:` and each of the 4 teams rebuilds it separately (team1-x, team2-x, ...), which wastes minutes and gigabytes. Since a challenge's image content is identical for every team (the SSH password is applied at runtime via chpasswd, not baked into the image), one shared build suffices. Runs up to --jobs builds at a time and prints a per-challenge PASS/FAIL summary. """ from __future__ import annotations import argparse import json import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor from pathlib import Path SERVICES = Path("/opt/gemastik18-final/services") REGISTRY = Path("/opt/gemastik18-final/teams/challenge_registry.json") def image_exists(name: str) -> bool: r = subprocess.run(["docker", "image", "inspect", f"services-{name}"], capture_output=True) return r.returncode == 0 def build(name: str, force: bool) -> tuple[str, bool, str]: if not force and image_exists(name): return name, True, "already built" d = SERVICES / name if not (d / "Dockerfile").exists(): return name, False, "no Dockerfile (multi-service challenge)" t0 = time.time() # Several challenge Dockerfiles do `echo root:${PASSWORD} | chpasswd`, so an # empty build-arg makes the RUN step fail. Pass a throwaway value: the real # per-team password is applied at container start via chpasswd, never baked # into the image, so this value never matters. r = subprocess.run( ["docker", "build", "-t", f"services-{name}", "--build-arg", "PASSWORD=prebuild-placeholder", "."], cwd=d, capture_output=True, text=True, ) if r.returncode == 0: return name, True, f"built in {time.time()-t0:.0f}s" tail = [l for l in r.stderr.splitlines() if l.strip()][-1:] or ["unknown error"] return name, False, tail[0][:160] def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("names", nargs="*", help="challenge names (default: all in registry)") ap.add_argument("--jobs", type=int, default=4) ap.add_argument("--force", action="store_true", help="rebuild even if present") args = ap.parse_args() if args.names: names = args.names else: reg = json.loads(REGISTRY.read_text()) names = [c["name"] for c in reg["challenges"]] print(f"building {len(names)} challenge images with {args.jobs} parallel jobs\n", flush=True) t0 = time.time() results: list[tuple[str, bool, str]] = [] with ThreadPoolExecutor(max_workers=args.jobs) as ex: for res in ex.map(lambda n: build(n, args.force), names): results.append(res) mark = "PASS" if res[1] else "FAIL" print(f"[{mark}] {res[0]:16s} {res[2]}", flush=True) ok = [r for r in results if r[1]] bad = [r for r in results if not r[1]] print(f"\n{len(ok)}/{len(results)} images ready in {(time.time()-t0)/60:.1f} min") if bad: print("\nfailures:") for n, _, why in bad: print(f" {n}: {why}") return 1 if bad else 0 if __name__ == "__main__": raise SystemExit(main())