3442eac6f0
Four defects found by running the suites and the staging lifecycle end to end after the kit milestone. 1. club-stats kits were half-implemented. The global `kits` counter was real but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded 0, so the same screen reported two owned kits and zero home/away kits. `kits` is a total with a family split, exactly like players/playersGold and staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home family and 15 the away family, verified across all 1482 rows of the kit table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15 exactly the 64xxxxx ones, 654 rows; no exceptions either way). ClubStatInput now carries `asset_id`, and a kit buckets onto the team that wears it -- including a team the club owns no player from, the normal case for a kit won from a pack. The host reads both from the catalog through new NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a read-only stats query must never do as a side effect. 2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1/d37a9d5);56bd9ddupdated the squad_projection integration test and stopped there. `put_body` hardcoded the captured manager ref 100000427 into EVERY save, including tests with no manager fixture, so each one was refused with `unresolved_wire_ids` -- the tests were reporting a real invariant against a fixture that could not satisfy it. The manager is now an explicit `Option<i64>` per test, and FakeCore models Core's manager persistence instead of inheriting the "not implemented" default that 502'd every save. Added the coverage whose absence let this rot: a manager assignment round-trips as a Core owned id, a later save without one CLEARS it, and an unowned manager ref refuses the whole save with nothing committed. 3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit` forwarding to Core, which the kit commit deliberately replaced with host-side pagination. It only ever passed because FakeCore ignored the window -- against a real Core, `start=10` over a one-item club was always an empty page. Retargeted to the real contract (Core gets semantic filters and NO window) plus a new test that the window is applied locally after filtering, which the old fake made vacuous. 4. The staging lifecycle scripts identified production by hardcoded pids, so a correct teardown FATAL'd: production moved into containers and pids 3631953/3374264 died with a container restart days ago. A pinned pid rots into the worst of both worlds -- a kill-refusal gate that no longer names any real production process, and a liveness gate that fails a healthy teardown. New shared `scripts/openfut_production.py` resolves production pids AND published ports from the container runtime at the moment they are needed, refuses to signal anything it cannot see, and proves production is the same processes serving the same ports before and after. Both lifecycle scripts use it, which also closed a real gap: port 8085 is published by openfut-fut-backend but was missing from the up script's forbidden list, so staging could have bound a production port. Also fixes the economy differential, red because `complete_match` unlocks achievements in the same transaction that pays the match reward -- a deliberate Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted that progression did not exist; it now asserts the delta is the 400 match reward plus exactly the achievements the match unlocked, read from Core's own report.
238 lines
8.3 KiB
Python
Executable File
238 lines
8.3 KiB
Python
Executable File
#!/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/<pid>/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())
|