fix(staging): reclassify the restored club, and assert Core's ownership truth

The snapshot predates the content taxonomy, so every fresh bring-up restored a
club whose coaches, kits and consumables were recorded as players. The wire
still looked right (the adapter classifies from its own catalog), which is
exactly the kind of divergence that hides until something keys off ownership.

Bring-up now runs Core's reclassify and asserts the club really owns something
of each kind. A Core binary predating the subcommand ignores it and boots the
server instead, so the step is bounded and says so rather than hanging.
This commit is contained in:
funman300
2026-08-21 20:13:35 +00:00
parent 09db5413cd
commit 23f120f889
+67
View File
@@ -127,6 +127,9 @@ BLAZE_ASSETS = ("redir_cert.pem", "redir_key.pem")
CONTENT_SRC = "/home/alex/openfut-post-p1/staging/emit/content"
CARDS_NAME = "fifa17-production-cards.json"
CATALOG_NAME = "fifa17-production-catalog.json"
# The FIFA17 content_kind mapping Core needs to correct a club imported before
# the taxonomy existed. Emitted alongside the catalog by openfut-import-fifa17.
RECLASSIFY_NAME = "fifa17-reclassify.json"
# The operator's REAL club (the 1986-item CAGE import), as staged by
# scripts/club-snapshot.py. That snapshot lives OUTSIDE the production state
@@ -1100,6 +1103,49 @@ def migrate_core(lay: Layout) -> None:
proc.wait(timeout=20)
def reclassify_core(lay: Layout) -> None:
"""Correct `content_kind` on the club restored from the snapshot.
The snapshot was imported before the content taxonomy existed, so its
coaches, kits and consumables are all recorded as players — and a profile
import is once-only, so re-importing cannot fix it. Core takes the FIFA17
mapping and applies it in one idempotent transaction. Without this, every
fresh bring-up would silently restore the wrong ownership truth.
"""
request = os.path.join(CONTENT_SRC, RECLASSIFY_NAME)
if not os.path.exists(request):
raise Fatal(
f"missing {request}; regenerate it with "
"`openfut-import-fifa17 --emit-content <dir>`"
)
# A binary predating the subcommand ignores it and boots the SERVER instead,
# which would block the bring-up forever rather than fail.
try:
out = subprocess.run(
[lay.core_bin, "reclassify", request],
env=core_env(lay), cwd=lay.root, capture_output=True, text=True,
timeout=120,
)
except subprocess.TimeoutExpired:
raise Fatal(
f"{lay.core_bin} did not answer `reclassify` — a binary predating the "
"subcommand ignores it and starts the server instead. Rebuild Core."
) from None
if out.returncode != 0:
raise Fatal(f"core reclassify failed: {out.stderr.strip()[-400:]}")
result = json.loads(out.stdout)
if result["unmatched_definitions"]:
raise Fatal(
"reclassify named definitions this club owns no copy of: "
f"{result['unmatched_definitions'][:5]}"
)
ok(
f"reclassified owned content: {result['updated']} row(s) corrected, "
f"{result['unchanged']} already right (Core is the ownership authority, "
"so a coach must not be recorded as a player)"
)
def start_core(lay: Layout) -> Launched:
proc = spawn([lay.core_bin], core_env(lay), lay.root, lay.core_log, append=True)
wait_http(CORE_PORT, "/health", proc, lay.core_log, "staging Core")
@@ -1256,6 +1302,25 @@ def verify(lay: Layout, variant: str, real_club_expected: bool) -> None:
"squad changed without re-stamping its extension fingerprint"
)
# Core is the ownership authority. If a snapshot restore silently reverted
# every non-player row to `player`, the wire would still look plausible
# (the adapter classifies from its catalog) while Core's truth was wrong.
if real_club_expected:
kinds = {}
for kind in ("player", "manager", "staff", "consumable", "kit"):
status, body = http_get(
CORE_PORT, f"/collection?content_kind={kind}&limit=1"
)
kinds[kind] = json.loads(body)["total"] if status == 200 else -1
ok(f"CORE ownership truth by content_kind: {kinds}")
missing = [k for k, v in kinds.items() if v <= 0]
if missing:
raise Fatal(
f"Core owns nothing of kind {missing}; the real club holds "
"players, a manager, coaches, consumables and kits. A zero here "
"means the reclassify step did not run or did not stick"
)
# Nothing this process ever opened may live under the production state dir.
leaked = []
for fd in os.listdir(f"/proc/{os.getpid()}/fd"):
@@ -1432,6 +1497,8 @@ def main() -> int:
raise Fatal(f"{lay.core_db} should have been installed from the snapshot")
migrate_core(lay)
seed_core_db(lay, real_club)
if real_club is not None:
reclassify_core(lay)
started.append(start_core(lay))
banner("STAGING UTAS-HOST")