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.
This commit is contained in:
1 parent
d4c741926d
commit
6d1ede8c2b
13 files changed
+483
-14
No files matched your search
@@ -0,0 +1,88 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user