From 88b4cad780b4768270dd1804455dad1629df219f Mon Sep 17 00:00:00 2001 From: funman300 Date: Sat, 22 Aug 2026 01:15:45 +0000 Subject: [PATCH] test(fifa17): migration invariant capture and rehearsal harness fifa17-migration-invariants.py Pre/post invariants across every domain the migration authorization names: coins, ownership (+kind histogram, distinct definitions, chemistry styles, loans, position overrides), squads, managers, staff, consumables, club items, transfer state (market_listings), packs, SBC, match history, plus integrity_check and foreign_key_check. Table names are the REAL schema, not guessed: transfer state lives in market_listings (29 rows in production), the FIFA17 opaque squad blob in game_entity_ext. fifa17-migration-rehearse.py Serves a migrated COPY with the candidate Rust stack on isolated ports and validates the wire surface: club discardValue is table-derived, squad projects, consumable categories populate, and the apply probe is OFF (502 upstream-unavailable rather than a diagnostic ack). Both are read-only against production: the rehearsal operates on a copy under /home/alex/openfut-migration/, and nothing under openfut-promotion/state is opened. Evidence from the 2026-08-22 rehearsal is written up in the Vault runbook "FIFA17 Rust Production Migration (rehearsed)". --- scripts/fifa17-migration-invariants.py | 90 +++++++++++++++++++++ scripts/fifa17-migration-rehearse.py | 104 +++++++++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100755 scripts/fifa17-migration-invariants.py create mode 100755 scripts/fifa17-migration-rehearse.py diff --git a/scripts/fifa17-migration-invariants.py b/scripts/fifa17-migration-invariants.py new file mode 100755 index 0000000..c94a02c --- /dev/null +++ b/scripts/fifa17-migration-invariants.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Pre/post invariants for the FIFA17 Rust production migration rehearsal. + +Covers every domain the authorization names: coins, ownership, squads, managers, +staff, consumables, club items, transfer state, packs/unassigned, SBC state, +match state/history. Read-only; run against a COPY. +""" +import json +import sqlite3 +import sys + +db = sys.argv[1] +out = sys.argv[2] if len(sys.argv) > 2 else None +con = sqlite3.connect("file:%s?mode=ro" % db, uri=True) +tables = {r[0] for r in con.execute( + "SELECT name FROM sqlite_master WHERE type='table'")} + + +def one(sql, default=None): + try: + r = con.execute(sql).fetchone() + return r[0] if r else default + except sqlite3.Error: + return default + + +def count(t): + return one("SELECT COUNT(*) FROM %s" % t) if t in tables else None + + +def group(t, col): + if t not in tables: + return None + try: + return dict(con.execute("SELECT %s, COUNT(*) FROM %s GROUP BY 1" % (col, t))) + except sqlite3.Error: + return None + + +inv = { + "schema_version": one("SELECT MAX(version) FROM _sqlx_migrations"), + "tables": len(tables), + # economy + "coins": one("SELECT coins FROM clubs LIMIT 1"), + "clubs": count("clubs"), + "profiles": count("profiles"), + # ownership + "owned_cards": count("owned_cards"), + "owned_by_kind": group("owned_cards", "content_kind"), + "owned_distinct_cards": one("SELECT COUNT(DISTINCT card_id) FROM owned_cards"), + "owned_loans": one("SELECT COUNT(*) FROM owned_cards WHERE is_loan=1"), + "owned_chem_styles": one( + "SELECT COUNT(*) FROM owned_cards WHERE chemistry_style IS NOT NULL"), + "owned_pos_overrides": one( + "SELECT COUNT(*) FROM owned_cards WHERE position_override IS NOT NULL"), + # squads / managers + "squads": count("squads"), + "squad_managers": count("squad_managers"), + # club items + "club_active_items": count("club_active_items"), + "club_kit_assignments": count("club_kit_assignments"), + # transfer / market / piles + "market_listings": count("market_listings"), + "market_listings_by_status": group("market_listings", "status"), + "market_history": count("market_history"), + "packs": count("packs"), + # FIFA17 opaque squad extension (adapter-owned blob) + "game_entity_ext": count("game_entity_ext"), + "squad_players": count("squad_players"), + "seasons": count("seasons"), + "events": count("events"), + "notifications": count("notifications"), + # sbc + "sbc_submissions": count("sbc_submissions"), + "sbc_challenge_squads": count("sbc_challenge_squads"), + # matches / history + "matches": count("matches"), + "match_completions": count("match_completions"), + "season_history": count("season_history"), + "statistics": count("statistics"), + "consumable_applications": count("consumable_applications"), +} +# integrity +inv["integrity_check"] = one("PRAGMA integrity_check") +inv["foreign_key_violations"] = len(list(con.execute("PRAGMA foreign_key_check"))) +con.close() + +print(json.dumps(inv, indent=2, sort_keys=True)) +if out: + open(out, "w").write(json.dumps(inv, sort_keys=True, indent=2)) diff --git a/scripts/fifa17-migration-rehearse.py b/scripts/fifa17-migration-rehearse.py new file mode 100755 index 0000000..958c5ee --- /dev/null +++ b/scripts/fifa17-migration-rehearse.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Serve the MIGRATED + RECLASSIFIED rehearsal copy with the candidate Rust +stack and validate the wire surface. Isolated ports, copy-only state, no +production or staging resource touched.""" +import json +import os +import signal +import subprocess +import time +import urllib.error +import urllib.request + +R = "/home/alex/openfut-migration/rehearsal-20260822" +REPO = "/home/alex/OpenFUT" +CORE_PORT, HOST_PORT = 18099, 18098 +H = {"X-OpenFUT-Game": "fifa17"} +procs = [] + + +def start(name, binary, env): + e = dict(os.environ) + e.update(env) + log = open("%s/evidence/%s.log" % (R, name), "w") + p = subprocess.Popen([binary], cwd=REPO, env=e, stdout=log, stderr=log, + start_new_session=False) + procs.append(p) + return p + + +def get(port, path): + req = urllib.request.Request("http://127.0.0.1:%d%s" % (port, path), headers=H) + with urllib.request.urlopen(req, timeout=25) as r: + return json.loads(r.read()) + + +try: + start("rehearsal-core", R + "/artifacts/openfut-core", { + "LISTEN_ADDR": "127.0.0.1:%d" % CORE_PORT, + "DATABASE_URL": "sqlite://%s/work/core.db" % R, + "DATA_DIR": "%s/openfut-core/data" % REPO, + "OPENFUT_CONTENT_PACKS": "%s/emit/content/fifa17-production-cards.json" % R, + "RUST_LOG": "warn", + }) + time.sleep(6) + start("rehearsal-host", R + "/artifacts/openfut-utas-host", { + "OPENFUT_UTAS_HOST_ADDR": "127.0.0.1:%d" % HOST_PORT, + "OPENFUT_UTAS_PYTHON_URL": "http://127.0.0.1:19999", # deliberately dead + "OPENFUT_CORE_URL": "http://127.0.0.1:%d" % CORE_PORT, + "OPENFUT_FIFA17_TABLES_DIR": "%s/fifa17-recon/data/tables" % REPO, + "OPENFUT_FIFA17_CATALOG": "%s/emit/content/fifa17-production-catalog.json" % R, + "OPENFUT_IDENTITY_STORE": "%s/work/identity.json" % R, + "OPENFUT_PERSONA_ID": "33068179", + "OPENFUT_MARKET_DB": "%s/work/market.db" % R, + "OPENFUT_PILE_DB": "%s/work/pile.db" % R, + "OPENFUT_FIFA17_DISCARD_TABLE": "1", + "RUST_LOG": "warn", + }) + time.sleep(6) + + checks = {} + club = get(HOST_PORT, "/ut/game/fifa17/club?type=player&start=0&count=5") + checks["club_total"] = club.get("totalResults") + checks["club_first_discard"] = (club.get("itemData") or [{}])[0].get("discardValue") + checks["club_first_rating"] = (club.get("itemData") or [{}])[0].get("rating") + + umi = get(HOST_PORT, "/ut/game/fifa17/userMassInfo") + checks["squad_slots"] = len(((umi.get("userInfo") or {}).get("squad") or + umi.get("squad") or {}).get("players", []) or []) + + for cat in ("contracts", "development", "fitness"): + d = get(HOST_PORT, "/ut/game/fifa17/club/consumables/" + cat) + st = d.get("itemData") or [] + checks["consumables_" + cat] = (len(st), sum(s.get("count", 0) for s in st)) + + sq = get(HOST_PORT, "/ut/game/fifa17/squad/0") + checks["squad_players"] = len((sq.get("squad") or sq).get("players") or []) + + print(json.dumps(checks, indent=2, sort_keys=True)) + + print("\n=== apply probe MUST be off in the candidate ===") + req = urllib.request.Request( + "http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT, + data=b'{"apply":[{"id":100000003}]}', + headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}, + method="POST") + try: + with urllib.request.urlopen(req, timeout=20) as r: + st, body = r.status, r.read().decode() + except urllib.error.HTTPError as e: + st, body = e.code, e.read().decode() + print(" status=%s body=%s" % (st, body)) + print(" probe OFF (must be 502 upstream-unavailable): %s" + % (st == 502 and "upstream" in body)) +finally: + for p in procs: + try: + os.kill(p.pid, signal.SIGTERM) + except ProcessLookupError: + pass + time.sleep(2) + for p in procs: + if p.poll() is None: + os.kill(p.pid, signal.SIGKILL) + print("\nrehearsal processes stopped")