#!/usr/bin/env python3 """Tear down the isolated FIFA-17 SOLD staging stack brought up by `scripts/sold-staging-up.py` -- and NOTHING else. Kill safety is the whole point of this file. `openfut-utas-host` and `openfut-core` each name TWO live processes on this machine: the staging ones and the PRODUCTION ones. So there is no pattern matching here at all: * every pid comes from the manifest the up script wrote; * before any signal, /proc//cmdline is read and MUST contain the staging directory -- production's cmdline never can, because staging runs binaries copied into that directory; * production's CURRENT pids, resolved from the container runtime, are refused explicitly as a second gate; * only the process GROUP the up script created (pgid == pid, via start_new_session) is signalled, so a responder thread/child cannot be orphaned; * afterwards every staging port is proven free, and production is proven to be the same running containers serving the same ports as before. python3 scripts/sold-staging-down.py python3 scripts/sold-staging-down.py --purge # also delete the staging dir """ from __future__ import annotations import argparse import json import os import shutil import signal import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from openfut_production import ( # noqa: E402 PROD_PORTS, ProductionError, listening_ports, production_state, ) DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging" FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",) class Fatal(ProductionError): pass def banner(title: str) -> None: print() print("=" * 78) print(f"== {title}") print("=" * 78) def ok(msg: str) -> None: print(f" [ OK ] {msg}") def step(msg: str) -> None: print(f" {msg}") def safe_path(path: str) -> str: real = os.path.realpath(path) for bad in FORBIDDEN_PATHS: if real == bad or real.startswith(bad + os.sep): raise Fatal(f"REFUSING to touch production state: {path} -> {real}") return path def pid_alive(pid: int) -> bool: try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True return True def cmdline_of(pid: int) -> str: try: with open(f"/proc/{pid}/cmdline", "rb") as fh: return fh.read().replace(b"\0", b" ").decode(errors="replace").strip() except OSError: return "" def port_free(port: int) -> bool: return port not in listening_ports() def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str: """Stop exactly one recorded process. Returns a human-readable outcome.""" name, pid = rec["name"], int(rec["pid"]) if pid in prod_pids: raise Fatal( f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). " "REFUSING to signal anything from this manifest." ) if not pid_alive(pid): return f"{name} pid {pid}: already gone" live = cmdline_of(pid) if staging_dir not in live: raise Fatal( f"{name} pid {pid} is alive but its cmdline does NOT contain " f"{staging_dir!r} -- pid reuse, or the wrong manifest. REFUSING to " f"signal it.\n cmdline: {live!r}" ) try: pgid = os.getpgid(pid) except OSError: pgid = pid recorded_pgid = int(rec.get("pgid", pid)) if pgid != recorded_pgid: raise Fatal( f"{name} pid {pid} is in process group {pgid} but the manifest recorded " f"{recorded_pgid} -- REFUSING to signal a group we did not create." ) if pgid != pid: raise Fatal( f"{name} pid {pid} is not its own group leader (pgid {pgid}) -- the up " "script always starts a new session, so this is not our process." ) os.killpg(pgid, signal.SIGTERM) deadline = time.monotonic() + 15.0 while time.monotonic() < deadline and pid_alive(pid): time.sleep(0.1) if pid_alive(pid): os.killpg(pgid, signal.SIGKILL) deadline = time.monotonic() + 10.0 while time.monotonic() < deadline and pid_alive(pid): time.sleep(0.1) if pid_alive(pid): raise Fatal(f"{name} pid {pid} survived SIGKILL") return f"{name} pid {pid} (pgid {pgid}): stopped and verified gone" def main() -> int: ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR", DEFAULT_STAGING_DIR), help=f"staging directory (default: {DEFAULT_STAGING_DIR})") ap.add_argument("--purge", action="store_true", help="delete the staging directory after stopping (default: keep " "the databases and logs as evidence)") args = ap.parse_args() staging_dir = safe_path(os.path.abspath(args.dir)) manifest_path = safe_path(os.path.join(staging_dir, "manifest.json")) try: banner("STOPPING THE STAGING STACK (recorded pids only)") step(f"staging dir : {staging_dir}") if not os.path.exists(manifest_path): print(f" no manifest at {manifest_path} -- nothing was recorded, so " "nothing will be signalled.") print(" If a staging process is somehow still running, find it with " "its cmdline (it contains the staging dir) and stop it by pid.") return 0 with open(manifest_path) as fh: manifest = json.load(fh) if manifest.get("staging_dir") != staging_dir: raise Fatal( f"manifest staging_dir {manifest.get('staging_dir')!r} != " f"{staging_dir!r} -- REFUSING to act on a foreign manifest." ) step(f"variant : {manifest.get('variant')}") # Resolved BEFORE anything is signalled: the refusal gate below is only # meaningful if it knows production's pids as they are right now. before = production_state() for line in before.describe(): step(f"production : {line}") for rec in manifest.get("processes", []): ok(stop_one(rec, staging_dir, before.pids)) banner("PROVE STAGING IS GONE") ports = manifest.get("ports", {}) for name, port in sorted(ports.items(), key=lambda kv: kv[1]): if port in PROD_PORTS: raise Fatal(f"manifest port {name}={port} is a PRODUCTION port") if not port_free(port): raise Fatal(f"staging port {name}={port} is STILL listening") ok(f"staging port {name} {port} free") leftovers = [] for rec in manifest.get("processes", []): pid = int(rec["pid"]) if pid_alive(pid) and staging_dir in cmdline_of(pid): leftovers.append(f"{rec['name']} pid {pid}") if leftovers: raise Fatal("staging processes still alive: " + ", ".join(leftovers)) ok("no recorded staging process is alive") banner("PROVE PRODUCTION IS STILL UP") after = production_state() for line in after.describe(): ok(f"{line} alive") after.assert_unchanged(before) after.assert_serving() ok(f"all {len(after.published)} published production ports still listening: " + ", ".join(str(port) for port in sorted(after.published))) if args.purge: shutil.rmtree(safe_path(staging_dir), ignore_errors=True) ok(f"purged {staging_dir}") else: os.replace(manifest_path, safe_path(manifest_path + ".stopped")) ok(f"kept {staging_dir} (manifest renamed to manifest.json.stopped so a " "fresh `up` is allowed)") banner("REMINDER: REVERT THE CLIENT") print(' On 10.10.0.105, restore "/mnt/games/FIFA 17/openfut.cfg" to:') print() print(" host=10.10.0.120") print(" https_port=8443") print(" blaze_redirector_port=42127") print(" blaze_main_port=42130") print() print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.") return 0 except ProductionError as exc: print(f"\nFATAL: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())