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)".
This commit is contained in:
Executable
+90
@@ -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))
|
||||
Executable
+104
@@ -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")
|
||||
Reference in New Issue
Block a user