#!/usr/bin/env python3 """ compose_gen — render a team's docker-compose.yml from the challenge registry. For every ENABLED challenge, a canonical per-challenge compose template lives at services//docker-compose.yml (see gen_canonical_composes.py). The renderer: - replaces `build:` blocks with `image: services-` for the MAIN service (sidecars keep their images/builds), - rewrites container_name / hostname to the per-team suffix, - rewrites host ports (:, :22) to the team's ports, - replaces PASSWORD_ placeholders with the team's challenge password, - normalizes flag volume to ../receiver/flags/.txt. Multi-container challenges (gemas-notes, gemas-fetcher, kode-viewer, anti-alchemy, tempest-poc) keep their sidecar services. """ import json import re from pathlib import Path BASE = Path("/opt/gemastik18-final") TEAMS_DIR = BASE / "teams" SERVICES_SRC = BASE / "services" # Challenges whose compose has multiple top-level services; the FIRST service # listed is the MAIN challenge service (gets image: reuse + challenge ports), # the rest are sidecars. SIDECAR_NAMES = { "anti-alchemy": ["anti-alchemy-db"], "gemas-fetcher": ["mongodb"], "gemas-notes": ["database", "validation-service"], "kode-viewer": ["redis"], "tempest-poc": ["backend"], } def _load_registry() -> dict: try: return json.loads((TEAMS_DIR / "challenge_registry.json").read_text()) except Exception: return {"sets": {}, "challenges": []} def read_template(name: str) -> str: p = SERVICES_SRC / name / "docker-compose.yml" if not p.exists(): raise FileNotFoundError(f"Tidak ada template compose untuk {name} di {p}") return p.read_text() def _parse_services(text: str): names = [] for line in text.splitlines(): m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) if m and not line.startswith(" "): names.append(m.group(1)) return names def render_team_compose(idx: int, state: dict) -> str: ports = state["ports"] passwords = state["chall_passwords"] blocks = [] for ch in enabled_challenges(): name = ch["name"] text = read_template(name) main = _parse_services(text) main = main[0] if main else name sidecars = set(SIDECAR_NAMES.get(name, [])) org = int(ch.get("org_port", 10000)) tc = ports[name]["chall"] ts = ports[name]["ssh"] # --- rewrite container_name / hostname per team --- out_lines = [] for line in text.splitlines(): s = line.strip() if s.startswith("container_name:"): cname = s.split(":", 1)[1].strip() line = f" container_name: {cname}_team{idx}" elif s.startswith("hostname:"): hname = s.split(":", 1)[1].strip() if hname == name: line = f" hostname: {name}_team{idx}" else: # sidecar hostname also suffixed to keep per-team network unique line = f" hostname: {hname}_team{idx}" out_lines.append(line) text = "\n".join(out_lines) # --- ports: rewrite ONLY the main challenge service's ports --- # The main service is the one whose ports map to org/org+22. # (sidecar "ports_chall" cases: gemas-notes validation-service exposes # 12000:80 — handled by rewriting ANY ":" / ":" occurrence.) text = re.sub(rf'"({org}):', f'"{tc}:', text) text = re.sub(rf'"({org + 22}):', f'"{ts}:', text) # --- build: -> image for MAIN only --- # Replace the main service's build block with image: services-. # NB we process by service names, not generic removal, so sidecar # builds survive. lines = text.splitlines() i = 0 in_main = False cur = None out = [] while i < len(lines): line = lines[i] m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) if m and not line.startswith(" "): cur = m.group(1) in_main = (cur == main) out.append(line) i += 1 continue # inside a service block if in_main and line.strip() == "build:": # skip build block (context/args/dockerfile...) until next key at same indent out.append(f" image: services-{name}") i += 1 while i < len(lines) and (lines[i].startswith(" ") or lines[i].strip() == ""): i += 1 continue out.append(line) i += 1 text = "\n".join(out) # --- PASSWORD placeholder -> team password --- text = re.sub(r"\$PASSWORD_" + str(org) + r"\b", passwords[name], text) text = re.sub(r"PASSWORD_" + str(org) + r"\b", passwords[name], text) # --- flag volume normalization --- # Replace any ./flag.txt / ../receiver/flags/.txt with the per-team flag mount text = re.sub(r"\./flag\.txt(:\w+)?", f"../receiver/flags/{name}.txt", text) blocks.append(text) header = "version: '3.8'\nservices:\n" body = [] for b in blocks: if not b.strip(): continue # strip a leading "services:" header from each block (they are fragments) b = re.sub(r"^services:\n", "", b) body.append(b) return header + "\n".join(body) + "\n" def enabled_challenges() -> list: return [c for c in _load_registry().get("challenges", []) if c.get("enabled")]