Files
MythEclipse 6d1ede8c2b fix: bulk challenge enable path (compose YAML, base images, async toggle, tooling)
- fix_dup_volumes.py: 4 canonical templates had TWO volumes: keys inside one
  service (invalid YAML -> 'mapping key volumes already defined'), which broke
  every enable for anti-alchemy/burvesigner/gemas-notes/kode-viewer.
- fjb: ghcr.io base is not anonymously pullable on this host; swapped to the
  official httpd:2.4 (its httpd.conf only uses stock modules). Added
  onlyBuiltDependencies to package.json (pnpm >=10 blocks esbuild's postinstall).
- xl + kode-viewer: node:20-slim-bookworm is not a real tag; use
  node:20-bookworm-slim. gift-voucher: buster -> bookworm.
- prebuild_images.py: build each challenge's shared services-<name> image once
  in parallel (passes a placeholder PASSWORD build-arg, since several Dockerfiles
  run chpasswd and fail on an empty arg).
- set_enabled.py / sync_all_challenges.py: batch registry flip + runtime apply
  that survives panel restarts and reports per-team results.
- Challenge toggle is now async: PATCH returns a job id, the client polls
  /api/challenges/jobs/<id> so a multi-minute build no longer blocks the panel.
  Added _SYNC_LOCK to serialize concurrent compose rewrites.
2026-09-25 17:01:42 +08:00

89 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Pre-build every challenge's shared image (`services-<name>`) in parallel.
The admin toggle only reuses `image: services-<name>` 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())