fix: per-team receivers as independent systemd services

Receivers were child Popen processes of gemastik-panel systemd cgroup;
restarting the panel killed all team receivers (SLA -> 0/6, 401 on
/api/team/N/status proxy). Now each team receiver is a systemd service
(gemastik-receiver-teamN.service) generated by gen_receiver_services.py with
per-team env (ports, containers, COMPOSE_LOCATION, .env). Verified: panel
restart no longer kills receivers; 18/18 SLA stays UP.
This commit is contained in:
root
2026-09-23 17:06:24 +08:00
parent 72382c43d0
commit b66530e7fd
2 changed files with 86 additions and 26 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Generate + (re)start per-team receiver systemd services.
Each team receiver becomes gemastik-receiver-teamN.service, independent of
the panel service. This fixes the bug where restarting gemastik-panel killed
all team receivers (they were child processes of the panel systemd cgroup).
"""
import json
import os
import subprocess
import sys
from pathlib import Path
TEAMS_DIR = Path("/opt/gemastik18-final/teams")
RECEIVER_VENV = "/opt/gemastik18-final/receiver/.venv/bin/python"
UNIT_DIR = Path("/etc/systemd/system")
def write_unit(idx: int, st: dict):
port = st["ports"]["receiver"]
recv_dir = TEAMS_DIR / f"team{idx}" / "receiver"
env = {}
# ports/containers for challenge classes
for ch in st["ports"]:
if ch in ("receiver", "panel"):
continue
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"])
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}"
env["COMPOSE_LOCATION"] = str(TEAMS_DIR / f"team{idx}" / "services" / "docker-compose.yml")
env_lines = "\n".join(f'Environment="{k}={v}"' for k, v in env.items())
unit = f"""[Unit]
Description=Gemastik A/D receiver for team {idx}
After=docker.service
Wants=docker.service
[Service]
Type=simple
WorkingDirectory={recv_dir}
EnvironmentFile={recv_dir}/.env
{env_lines}
ExecStart={RECEIVER_VENV} -m uvicorn main:app --host 0.0.0.0 --port {port}
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
"""
(UNIT_DIR / f"gemastik-receiver-team{idx}.service").write_text(unit)
def main():
action = sys.argv[1] if len(sys.argv) > 1 else "start"
for d in sorted(TEAMS_DIR.glob("team*")):
sf = d / "state.json"
if not sf.exists():
continue
st = json.loads(sf.read_text())
idx = st["index"]
write_unit(idx, st)
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "enable", f"gemastik-receiver-team{idx}.service"], check=False)
if action == "stop":
subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"], check=False)
else:
subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"], check=False)
print(f"gemastik-receiver-team{idx}: {action} (port {st['ports']['receiver']})")
if __name__ == "__main__":
main()
+19 -26
View File
@@ -25,6 +25,7 @@ import re
import secrets import secrets
import shutil import shutil
import subprocess import subprocess
import sys
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -235,40 +236,32 @@ def stop_team(idx: int):
return st return st
def _start_receiver(idx: int): def _start_receiver(idx: int):
"""Launch team's receiver as a detached uvicorn process with team env.""" """Start team's receiver via systemd service (independent of panel cgroup)."""
team_dir = TEAMS_DIR / f"team{idx}" team_dir = TEAMS_DIR / f"team{idx}"
recv_dir = team_dir / "receiver"
st = json.loads((team_dir / "state.json").read_text()) st = json.loads((team_dir / "state.json").read_text())
port = st["ports"]["receiver"] port = st["ports"]["receiver"]
pidfile = team_dir / "receiver.pid" pidfile = team_dir / "receiver.pid"
# kill existing # ensure the per-team systemd unit exists with current env
_stop_receiver(idx) subprocess.run([sys.executable, str(BASE / "panel" / "gen_receiver_services.py"), "start"],
env = dict(os.environ) check=False, capture_output=True)
env["PYTHONPATH"] = str(recv_dir) subprocess.run(["systemctl", "restart", f"gemastik-receiver-team{idx}.service"],
env["COMPOSE_LOCATION"] = str(team_dir / "services" / "docker-compose.yml") check=False, capture_output=True)
# expose team ports/containers so challenge classes bind the right targets # best-effort pid bookkeeping for ps
for ch in st["ports"]: try:
if ch in ("receiver", "panel"): out = subprocess.run(["systemctl", "show", "-p", "MainPID", f"gemastik-receiver-team{idx}.service"],
continue capture_output=True, text=True).stdout
env[f"CHALLENGE_PORT_{ch.upper()}"] = str(st["ports"][ch]["chall"]) pid = out.strip().split("=")[1]
env[f"CHALLENGE_CONTAINER_{ch.upper()}"] = f"{ch}_container_team{idx}" pidfile.write_text(pid)
proc = subprocess.Popen( except Exception:
[str(RECEIVER_SRC.parent / "receiver" / ".venv" / "bin" / "python"), pass
"-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", str(port)],
cwd=str(recv_dir), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True)
pidfile.write_text(str(proc.pid))
def _stop_receiver(idx: int): def _stop_receiver(idx: int):
"""Stop team's receiver via systemd service."""
team_dir = TEAMS_DIR / f"team{idx}" team_dir = TEAMS_DIR / f"team{idx}"
pidfile = team_dir / "receiver.pid" pidfile = team_dir / "receiver.pid"
if pidfile.exists(): subprocess.run(["systemctl", "stop", f"gemastik-receiver-team{idx}.service"],
try: check=False, capture_output=True)
pid = int(pidfile.read_text().strip()) pidfile.unlink(missing_ok=True)
os.kill(pid, 15)
except Exception:
pass
pidfile.unlink(missing_ok=True)
def team_logs(idx: int, service: str = None, tail: int = 100): def team_logs(idx: int, service: str = None, tail: int = 100):
team_dir = TEAMS_DIR / f"team{idx}" team_dir = TEAMS_DIR / f"team{idx}"