#!/usr/bin/env python3 """Bring up a COMPLETE, ISOLATED FIFA-17 staging stack for the seller-facing SOLD A/B experiment, so a real FIFA 17 client can be pointed at it without production being touched in any way. ONE entry point. It starts, in order: 1. staging Core 127.0.0.1:18081 throwaway sqlite under the staging dir 2. staging utas-host 0.0.0.0:8299 own market/pile/identity/clientdata files 3. staging Blaze 0.0.0.0:42327 (redirector) / 42330 (main) / 42331 (nucleus) and then prints the three `openfut.cfg` lines the operator must put on the client. Tear the whole thing down with `scripts/sold-staging-down.py`. WHY the client only needs Blaze ports: FIFA 17 learns both the UTAS base URL and the roster URL from Blaze. This script copies the responder into the staging dir, rewrites the hardcoded UTAS port, and passes the independently configurable roster host through `OPENFUT_ROSTER_HOST`. ISOLATION, enforced not assumed: * production ports 8099 8199 18080 8443 42127 42130 42131 4216 8080 8081 8094 are a hard deny-list: never bound, never connected to, and every chosen staging port is checked against it AND checked free before anything is launched; * nothing under /home/alex/openfut-promotion/state/ is ever opened -- every path this script touches goes through `safe_path()`, which refuses that prefix; * the Core/utas-host binaries are COPIED into the staging dir and run from there, so (a) a later `cargo build` cannot change what staging is running and (b) every staging process's /proc cmdline provably contains the staging directory, which is what the down script requires before it will signal anything; * `OPENFUT_UTAS_PYTHON_URL` points at an unused loopback port, so any Python fallback fails closed and loudly instead of silently serving production data; * nothing on the FIFA client machine (10.10.0.105) is modified -- the operator edits `openfut.cfg` by hand, using the block this script prints. Read-only reuse of production: staging Blaze advertises the production roster service through a certificate dNSName (`winter15.gosredirector.ea.com:8081` by default) and advertises the production POW content/API hosts (`10.10.0.120:8085` / `:8094`) verbatim. These services hold NO economy state. The client must resolve the roster hostname to this server without changing the advertised URL. WHICH CLUB the seller plays with is chosen by `--club`: * `fixture` (default) seeds a 14-item synthetic club -- 11 starters, one disposable item to sell, two kits -- which is all the SOLD experiment needs; * `real` installs the operator's own imported club from the snapshot produced by `scripts/club-snapshot.py`, including its coins, its squad and the identity store that keeps every item's wire id stable. This script still never reads production state: the snapshot lives outside it, which is what makes the `safe_path()` refusal above compatible with playing the real club. python3 scripts/sold-staging-up.py --variant highest python3 scripts/sold-staging-up.py --variant buyNow --coins-processed 1 \ --count-mode active_plus_sold python3 scripts/sold-staging-up.py --variant off # sold projection disabled """ from __future__ import annotations import argparse import http.client import json import os import re import shutil import signal import socket import sqlite3 import subprocess import sys import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from openfut_production import ( # noqa: E402 PROD_PORTS, ProductionError, ProductionState, listening_ports, production_state, ) # --- fixed facts ------------------------------------------------------------------- REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Production. Never bind, never connect, never open. Production's pids are NOT # listed here: they live in containers and change on every restart, so they are # resolved from the container runtime by openfut_production.production_state(). FORBIDDEN_PORTS = PROD_PORTS FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",) # The staging port block. One obvious place; every one of these is asserted free. # 42227 (the first choice for the redirector) is permanently occupied by # openfut-redirector-host, so the redirector sits at 42327. CORE_PORT = 18081 HOST_PORT = 8299 BLAZE_REDIR_PORT = 42327 BLAZE_MAIN_PORT = 42330 BLAZE_NUCLEUS_PORT = 42331 # Deliberately dead: the utas-host requires a Python upstream, and this one must # never resolve to the production oracle on 8199. DEAD_PYTHON_PORT = 8399 STAGING_PORTS = { "staging Core": CORE_PORT, "staging utas-host": HOST_PORT, "staging blaze redirector": BLAZE_REDIR_PORT, "staging blaze main": BLAZE_MAIN_PORT, "staging blaze nucleus": BLAZE_NUCLEUS_PORT, } DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging" # What staging Blaze tells the client about itself and about the economy-free # auxiliary services. Same advertise IP and same POW hosts production Blaze uses. ADVERTISE = "10.10.0.120" BIND = "0.0.0.0" POW_CONTENT_HOST = "10.10.0.120:8085" POW_HOST = "10.10.0.120:8094" DEFAULT_ROSTER_HOST = "winter15.gosredirector.ea.com:8081" BLAZE_SRC = os.path.join(REPO, "fifa17-recon", "tools", "blaze_responder_v3b.py") BLAZE_ASSETS = ("redir_cert.pem", "redir_key.pem") # Emitted FIFA17 content, from a build tree OUTSIDE the forbidden production state # directory. `cards` is Core's content pack (a bare JSON array of CardDefinition); # `catalog` is the adapter's identity catalog (card id -> asset id/version/rareflag). 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 # directory precisely so this script never has to read production state: # FORBIDDEN_PATHS stays absolute and safe_path() still refuses the live directory. CLUB_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot" SNAP_CORE_DB = "core.db" SNAP_IDENTITY = "identity.json" SNAP_CLIENTDATA = "clientdata.json" SNAP_MANIFEST = "snapshot.json" GAME = "fifa17" PERSONA_ID = "33068179" PERSONA_NAME = "CAGE" TS = "2026-01-01T00:00:00Z" # Seller A is the real FIFA persona, so the retail client logs into THIS profile # (Core resolves the active profile by game_id, and X-OpenFUT-Game is `fifa17`). SELLER_PROFILE = "prof-seller-a-cage" SELLER_CLUB = "club-seller-a-cage" SELLER_COINS = 1_000 SELLER_SQUAD = "squad-seller-a" # Buyer B is a synthetic second club. It is parked on its OWN game_id so it can # never become the active `fifa17` profile -- Core is single-profile-per-game. BUYER_PROFILE = "prof-buyer-b" BUYER_CLUB = "club-buyer-b" BUYER_GAME = "fifa17-buyer-b" BUYER_COINS = 20_000 # 11 starters + 1 disposable item for the seller. Every card_id here exists in BOTH # the content pack (so Core's content preflight passes) and the identity catalog (so # the host can resolve a resourceId); both memberships are asserted before launch. SELLER_SQUAD_CARDS = [ ("owned-a-gk", "fifa17_84053575"), # Manuel Neuer GK 97 ("owned-a-lb", "fifa17_151192389"), # David Alaba LB 91 ("owned-a-cb1", "fifa17_134381968"), # Thiago Silva CB 92 ("owned-a-cb2", "fifa17_151177437"), # Diego Godin CB 92 ("owned-a-rb", "fifa17_100785235"), # Philipp Lahm RB 90 ("owned-a-cm1", "fifa17_151171947"), # Luka Modric CM 93 ("owned-a-cm2", "fifa17_84054731"), # Ivan Rakitic CM 92 ("owned-a-cm3", "fifa17_134400249"), # Toni Kroos CM 91 ("owned-a-lw", "fifa17_83906881"), # Cristiano Ronaldo LW 99 ("owned-a-st", "fifa17_117617092"), # Luis Suarez ST 95 ("owned-a-rw", "fifa17_84044103"), # Lionel Messi RW 98 ] # THE disposable item: what the operator lists and sells during the experiment. DISPOSABLE_ITEM = "owned-a-disposable" DISPOSABLE_CARD = "fifa17_232273" # Nelson Atiagli LB 51, rareflag 1 # Two authoritative modern kit-card definitions for one real source team. These # are staging fixtures derived from fcc_kitcards, not synthetic FIFA identities. KIT_TEAM_ID = 21 STAGING_KITS = [ ("home", "owned-a-kit-home", "fifa17_6300006", 6_300_006), ("away", "owned-a-kit-away", "fifa17_6400003", 6_400_003), ] # The remaining club-item families, so the rig exercises EVERY ownable class # rather than only the two the real club happens to hold. Every value is read # out of the client's own tables (fifa17-recon/data/tables), never invented: # fcc_badgecards 6000005 teamid 21 -> the same team as the kits above # fcc_balls 8120194 cardassetid 37 # fcc_stadium 6200000 cardassetid 36 (capacity 76200) # fcc_leaguelogos 8010015 leagueid 53 -> the same league as the manager below # Each family ships a CONSTANT cardassetid (kit 35, stadium 36, ball 37, # badge 39, logo 40), which is what the importer gates on. A league logo has no # equipped slot, so it is owned as generic `misc` content. STAGING_CLUB_ITEMS = [ # (slot, owned_id, card_id, resource_id, kind, subtype, card_asset_id, team_id) ("badge", "owned-a-badge", "fifa17_6000005", 6_000_005, "badge", 11, 39, 21), ("ball", "owned-a-ball", "fifa17_8120194", 8_120_194, "ball", 30, 37, None), ("stadium", "owned-a-stadium", "fifa17_6200000", 6_200_000, "stadium", 10, 36, None), (None, "owned-a-leaguelogo", "fifa17_8010015", 8_010_015, "misc", 31, 40, None), ] # The club manager. FIFA refuses to start a match without one ("your player or # managers contracts have expired"), and NEITHER club owns a manager: the real # import has 1992 players and exactly 3 staff items, all coaches (2 fitness, 1 GK), # which the client's own club/stats confirms with staffManager:0. So a manager is # minted here rather than restored. # # Every value below is read out of the client's OWN tables, never invented: # managercards.carddbid 1000509 (assetid == carddbid on all 417 rows) # managercards.nation 45 -> the flag and the nation half of chemistry # managercards.value 88 -> the rating the client re-rates this card to # managercards.rare 1 -> selects its discard price column # manager[509].surname "Luis Enrique", teamid 241 # leagueteamlinks[241].leagueid 53 -> the badge and the league half of chemistry # League 53 is also the dominant league in the restored squad (12 of 23 players), # so this manager is the chemistry-correct choice for it, not an arbitrary one. # # value/rare are LIVE-CONFIRMED: read back out of the running client's own record # (tools/coach_probe.py grades it HIT, so +0xb4 == value and +0x58 == rare), and # the discard value it computes for itself at +0x3c is 282 = 88 * 320 / 100. MANAGER_SUBTYPE = 4 STAGING_MANAGER = { "owned_id": "owned-a-manager", "card_id": "fifa17_1000509", "resource_id": 1_000_509, "nation": 45, "league_id": 53, "team_id": 241, "rating": 88, "rare": 1, "label": "Luis Enrique", } READY_TIMEOUT_S = 60.0 # --- output ------------------------------------------------------------------------ def banner(title: str) -> None: print() print("=" * 78) print(f"== {title}") print("=" * 78) def step(msg: str) -> None: print(f" {msg}") def ok(msg: str) -> None: print(f" [ OK ] {msg}") class Fatal(ProductionError): """Anything that must abort bring-up loudly rather than degrade.""" # --- isolation guards -------------------------------------------------------------- def safe_path(path: str) -> str: """Every filesystem path in this script goes through here. Refuses the live production state directory outright -- a typo cannot reach prod-core.db.""" 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 check_port_allowed(port: int, what: str) -> None: if port in FORBIDDEN_PORTS: raise Fatal(f"REFUSING: {what} port {port} is a PRODUCTION port") def port_free(port: int) -> bool: """A trial bind() is the wrong test: after a server exits, its accepted sockets sit in TIME_WAIT holding the same local port, so bind() reports EADDRINUSE for a minute even though nothing is serving -- and every server here sets SO_REUSEADDR and would bind fine. listening_ports() mirrors `ss -ltn` (and host-lifecycle.sh's hl_port_listening), which is the question actually asked.""" return port not in listening_ports() def assert_ports_free() -> None: busy = [] for what, port in STAGING_PORTS.items(): check_port_allowed(port, what) if not port_free(port): busy.append(f"{what} {port}") check_port_allowed(DEAD_PYTHON_PORT, "dead python upstream") if not port_free(DEAD_PYTHON_PORT): busy.append( f"dead python upstream {DEAD_PYTHON_PORT} (it MUST stay unbound so the " "Python fallback fails closed)" ) if busy: raise Fatal( "REFUSING to start -- these staging ports are not free:\n " + "\n ".join(busy) + "\n Nothing was launched. Free them, or edit the port block at the " "top of this script." ) ok( "staging ports free and none is a production port: " + ", ".join(str(p) for p in STAGING_PORTS.values()) ) def pid_alive(pid: int) -> bool: try: os.kill(pid, 0) except ProcessLookupError: return False except PermissionError: return True # exists, owned by root (the prod stack runs under sudo) 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 "" _PROD_BASELINE: ProductionState | None = None def assert_prod_alive(where: str) -> None: """Prove production is untouched. The first call records the baseline; every later call must observe the SAME container pids publishing the SAME ports, and every published port must still be listening. Pids are read from the container runtime each time because a restarted container gets a new one. """ global _PROD_BASELINE state = production_state() state.assert_serving() if _PROD_BASELINE is None: _PROD_BASELINE = state else: state.assert_unchanged(_PROD_BASELINE) ok(f"production untouched at {where}: " + ", ".join(state.describe())) # --- HTTP --------------------------------------------------------------------------- def http_get(port: int, path: str, timeout: float = 5.0): check_port_allowed(port, "HTTP request") conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) try: conn.request("GET", path, headers={"X-OpenFUT-Game": GAME}) resp = conn.getresponse() raw = resp.read() return resp.status, raw.decode("utf-8", "replace") finally: conn.close() def wait_http(port: int, path: str, proc, log_path: str, label: str) -> None: deadline = time.monotonic() + READY_TIMEOUT_S last = "no attempt" while time.monotonic() < deadline: if proc.poll() is not None: raise Fatal(f"{label} exited {proc.returncode} during startup\n{tail(log_path)}") try: status, _ = http_get(port, path) if status < 500: return last = f"HTTP {status}" except OSError as exc: last = f"{type(exc).__name__}: {exc}" time.sleep(0.15) raise Fatal( f"{label} never answered {path} on 127.0.0.1:{port} within " f"{READY_TIMEOUT_S:.0f}s (last: {last})\n{tail(log_path)}" ) def wait_tcp(ports: list[int], proc, log_path: str, label: str) -> None: deadline = time.monotonic() + READY_TIMEOUT_S pending = list(ports) while time.monotonic() < deadline and pending: if proc.poll() is not None: raise Fatal(f"{label} exited {proc.returncode} during startup\n{tail(log_path)}") still = [] for port in pending: check_port_allowed(port, f"{label} readiness probe") s = socket.socket() s.settimeout(1.0) try: s.connect(("127.0.0.1", port)) except OSError: still.append(port) finally: s.close() pending = still if pending: time.sleep(0.15) if pending: raise Fatal(f"{label} never listened on {pending}\n{tail(log_path)}") def tail(log_path: str, lines: int = 30) -> str: try: with open(log_path, errors="replace") as fh: body = fh.read().splitlines() except OSError: return f"(no log at {log_path})" return f"--- {os.path.basename(log_path)} tail ---\n" + "\n".join(body[-lines:]) # --- staging directory -------------------------------------------------------------- class Layout: def __init__(self, root: str) -> None: self.root = safe_path(os.path.abspath(root)) self.bin = os.path.join(self.root, "bin") self.content = os.path.join(self.root, "content") self.blaze = os.path.join(self.root, "blaze") self.logs = os.path.join(self.root, "logs") self.core_db = os.path.join(self.root, "staging-core.db") self.market_db = os.path.join(self.root, "staging-market.db") self.pile_db = os.path.join(self.root, "staging-pile.db") self.identity = os.path.join(self.root, "staging-identity.json") self.clientdata = os.path.join(self.root, "staging-clientdata.json") self.cards = os.path.join(self.content, CARDS_NAME) self.catalog = os.path.join(self.content, CATALOG_NAME) self.blaze_script = os.path.join(self.blaze, "blaze_responder_staging.py") self.blaze_rx = os.path.join(self.blaze, "rx") self.manifest = os.path.join(self.root, "manifest.json") self.core_bin = os.path.join(self.bin, "openfut-core") self.host_bin = os.path.join(self.bin, "openfut-utas-host") self.core_log = os.path.join(self.logs, "core.log") self.host_log = os.path.join(self.logs, "utas-host.log") self.blaze_log = os.path.join(self.logs, "blaze.log") # The responder's OWN log(), kept separate from its stdout/stderr file so # two writers never interleave in one file. self.blaze_responder_log = os.path.join(self.logs, "blaze-responder.log") for p in vars(self).values(): safe_path(p) def make_dirs(self) -> None: for d in (self.root, self.bin, self.content, self.blaze, self.logs, self.blaze_rx): os.makedirs(safe_path(d), exist_ok=True) def refuse_if_up(lay: Layout) -> None: """A previous stack still running must be torn down by the down script, never stepped on: two stacks would fight over the same sqlite files.""" if not os.path.exists(lay.manifest): return try: with open(safe_path(lay.manifest)) as fh: manifest = json.load(fh) except (OSError, ValueError): return live = [] for proc in manifest.get("processes", []): pid = int(proc["pid"]) if pid_alive(pid) and lay.root in cmdline_of(pid): live.append(f"{proc['name']} pid {pid}") if live: raise Fatal( "a previous staging stack is STILL UP:\n " + "\n ".join(live) + "\n Run: python3 scripts/sold-staging-down.py" + "\n Nothing was launched and nothing was signalled." ) def reset_throwaway_state(lay: Layout) -> None: """Every staging database is throwaway by definition, so a re-`up` after a clean `down` starts from nothing rather than refusing. Only files this script created are removed, and only after `refuse_if_up` proved no stack is running.""" removed = [] for path in (lay.core_db, lay.market_db, lay.pile_db, lay.identity, lay.clientdata, lay.manifest, lay.manifest + ".stopped"): for candidate in (path, path + "-wal", path + "-shm"): if os.path.exists(safe_path(candidate)): os.remove(safe_path(candidate)) removed.append(os.path.basename(candidate)) if removed: ok("removed stale throwaway state from a previous run: " + ", ".join(removed)) def materialise(lay: Layout) -> None: lay.make_dirs() for src, dst in ( (os.path.join(REPO, "target", "release", "openfut-core"), lay.core_bin), (os.path.join(REPO, "target", "release", "openfut-utas-host"), lay.host_bin), ): if not os.path.isfile(src): raise Fatal( f"not built: {src}\n cargo build --release -p openfut-core " "-p openfut-utas-host" ) shutil.copy2(safe_path(src), safe_path(dst)) os.chmod(dst, 0o755) ok(f"binaries copied into {lay.bin} (a later rebuild cannot change staging)") for name, dst in ((CARDS_NAME, lay.cards), (CATALOG_NAME, lay.catalog)): src = os.path.join(CONTENT_SRC, name) if not os.path.isfile(src): raise Fatal(f"missing FIFA17 content source {src}") shutil.copy2(safe_path(src), safe_path(dst)) ok(f"FIFA17 content copied from {CONTENT_SRC} (outside production state)") with open(safe_path(lay.cards)) as fh: definitions = json.load(fh) with open(safe_path(lay.catalog)) as fh: catalog = json.load(fh) existing = {definition["id"] for definition in definitions} for _slot, _owned_id, card_id, resource_id in STAGING_KITS: if card_id not in existing: definitions.append({ "id": card_id, "name": "Kit", "overall": 0, "position": "", "nation": "", "league": "", "club": "", "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0, "rarity": "bronze", "image_path": None, }) catalog["cards"][card_id] = { "asset_id": resource_id, "version": 0, "rareflag": 0, "kind": "kit", "subtype": 9, "card_asset_id": 35, "team_id": KIT_TEAM_ID, } for _slot, _owned, card_id, resource_id, kind, subtype, art, team in STAGING_CLUB_ITEMS: if card_id not in existing: definitions.append({ "id": card_id, "name": kind.title(), "overall": 0, "position": "", "nation": "", "league": "", "club": "", "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0, "rarity": "bronze", "image_path": None, }) entry = { "asset_id": resource_id, "version": 0, "rareflag": 0, "kind": kind, "subtype": subtype, "card_asset_id": art, } if team is not None: entry["team_id"] = team catalog["cards"][card_id] = entry mgr = STAGING_MANAGER if mgr["card_id"] not in existing: definitions.append({ "id": mgr["card_id"], "name": mgr["label"], # `overall` STAYS 0. It feeds pricing and squad projection, and a # staff card is not a player; the authoritative number lives in # `source_rating` below, which is what the contract-tier rules read. "overall": 0, # managercards.value, the same number the catalog carries as # `rating`. Core owns it so the manager-contract tier is resolved # from Core-owned state rather than from adapter projection. "source_rating": mgr["rating"], "position": "", "nation": "", "league": "", "club": "", "pace": 0, "shooting": 0, "passing": 0, "dribbling": 0, "defending": 0, "physical": 0, "rarity": "bronze", "image_path": None, }) # `version` MUST stay 0 and `asset_id` MUST be the raw carddbid: the client's # managercards merge reads the wire resourceId as a u32 WITHOUT masking off a # version byte (players are the only family that is masked), and the manager # branch has no else-arm, so a wrong key fails silently with a blank card. catalog["cards"][mgr["card_id"]] = { "asset_id": mgr["resource_id"], "version": 0, # managercards.rare, NOT cosmetic: it selects the discard price column, # and rare 1 is why this manager is worth 282 rather than 97. "rareflag": mgr["rare"], "kind": "staff", "subtype": MANAGER_SUBTYPE, "team_id": mgr["team_id"], "nation": mgr["nation"], "league_id": mgr["league_id"], # managercards.value. The client re-rates staff from its own DB, so # without this the server cannot price a quick sell to the number the # client displays. "rating": mgr["rating"], } with open(safe_path(lay.cards), "w") as fh: json.dump(definitions, fh, indent=2) fh.write("\n") with open(safe_path(lay.catalog), "w") as fh: json.dump(catalog, fh, indent=2) fh.write("\n") ok( "added ownership-backed home/away kit fixtures from fcc_kitcards, and the " f"manager {STAGING_MANAGER['label']} (carddbid " f"{STAGING_MANAGER['resource_id']}, nation {STAGING_MANAGER['nation']}, " f"league {STAGING_MANAGER['league_id']}) from managercards" ) def install_real_club(lay: Layout) -> dict: """Install the operator's real club from the snapshot in place of the fixture. The snapshot database is a schema generation behind (it predates match_completions, squad managers and kit assignments), so it is installed BEFORE migrate_core and Core brings the COPY forward. Production's own files are never opened here: scripts/club-snapshot.py already took them out, which is why this script can keep refusing the production state directory outright. """ manifest_path = os.path.join(CLUB_SNAPSHOT_DIR, SNAP_MANIFEST) if not os.path.isfile(manifest_path): raise Fatal( f"no club snapshot at {CLUB_SNAPSHOT_DIR}.\n" " Take one first (it is read-only on production state):\n" " python3 scripts/club-snapshot.py" ) with open(manifest_path) as fh: manifest = json.load(fh) for name, dst in ((SNAP_CORE_DB, lay.core_db), (SNAP_IDENTITY, lay.identity), (SNAP_CLIENTDATA, lay.clientdata)): src = os.path.join(CLUB_SNAPSHOT_DIR, name) if not os.path.isfile(src): raise Fatal(f"club snapshot is incomplete: missing {src}") shutil.copy2(src, safe_path(dst)) os.chmod(safe_path(dst), 0o644) club = manifest["club"] ok( f"installed the real club from {CLUB_SNAPSHOT_DIR} (taken " f"{manifest['taken_at']}): {club['username']}, {club['owned_cards']} items, " f"{club['coins']:,} coins, schema v{club['schema_version']}" ) ok( "wire ids come from the snapshot identity store, so item ids the client " f"already cached still resolve ({manifest['identity_rows']} rows, " f"watermarks {manifest['watermarks']})" ) return club def assert_seed_cards_resolvable(lay: Layout, real_club: dict | None) -> None: """Core's content preflight rejects any owned card whose card_id is not a loaded CardDefinition, and the host refuses to shape a /club item with no catalog identity. Prove BOTH memberships now, not via a startup crash later. For the real club this is the check that matters most: a card_id missing from the pack is not an error at read time, it is silently filter_map-dropped, so the symptom is an EMPTY /collection with all the rows still sitting in the database. """ with open(safe_path(lay.cards)) as fh: pack_ids = {c["id"] for c in json.load(fh)} with open(safe_path(lay.catalog)) as fh: catalog_ids = set(json.load(fh)["cards"]) if real_club is None: wanted = set( [card for _, card in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD] + [card for _, _, card, _ in STAGING_KITS] + [STAGING_MANAGER["card_id"]] ) what = f"all {len(wanted)} fixture seed card ids" else: conn = sqlite3.connect(f"file:{safe_path(lay.core_db)}?mode=ro", uri=True) try: wanted = {row[0] for row in conn.execute("SELECT DISTINCT card_id FROM owned_cards")} finally: conn.close() wanted |= {card for _, _, card, _ in STAGING_KITS} wanted.add(STAGING_MANAGER["card_id"]) what = (f"all {len(wanted)} distinct card ids owned by the real club " "(plus the kit and manager fixtures)") missing_pack = sorted(wanted - pack_ids) missing_cat = sorted(wanted - catalog_ids) if missing_pack or missing_cat: raise Fatal( "card ids are not resolvable. Core drops an owned card whose definition " "is missing instead of failing, so this would surface as an EMPTY club " "with every row still in the database.\n" f" absent from content pack ({len(missing_pack)}): {missing_pack[:10]}\n" f" absent from identity catalog ({len(missing_cat)}): {missing_cat[:10]}" ) ok( f"{what} present in BOTH the content pack ({len(pack_ids)} defs) and the " f"identity catalog ({len(catalog_ids)} entries)" ) # --- blaze patching ----------------------------------------------------------------- # Every substitution is anchored to the whole assignment line and must apply exactly # once. A silent no-op here would leave staging Blaze advertising PRODUCTION UTAS. def blaze_patches(lay: Layout) -> list[tuple[str, str, str]]: return [ ("REDIR_PORT", r"^REDIR_PORT = 42127$", f"REDIR_PORT = {BLAZE_REDIR_PORT}"), ("BLAZE_PORT", r"^BLAZE_PORT = 42130$", f"BLAZE_PORT = {BLAZE_MAIN_PORT}"), ("NUCLEUS_PORT", r"^NUCLEUS_PORT = 42131$", f"NUCLEUS_PORT = {BLAZE_NUCLEUS_PORT}"), ("UTAS_BASE", r'^UTAS_BASE = "http://%s:8099/" % _ADVERTISE$', f'UTAS_BASE = "http://%s:{HOST_PORT}/" % _ADVERTISE'), # Not protocol values, but the responder's two hardcoded /tmp paths: left # alone, a staging run would write its frames and log into the shared # host /tmp and make a capture ambiguous about which stack produced it. ("LOG", r'^LOG = "/tmp/blaze_responder\.log"$', f'LOG = "{lay.blaze_responder_log}"'), ("RXDIR", r'^RXDIR = "/tmp/blaze_rx"$', f'RXDIR = "{lay.blaze_rx}"'), ] def patch_blaze(lay: Layout) -> None: if not os.path.isfile(BLAZE_SRC): raise Fatal(f"missing blaze responder {BLAZE_SRC}") with open(safe_path(BLAZE_SRC)) as fh: text = fh.read() for name, pattern, replacement in blaze_patches(lay): text, n = re.subn(pattern, replacement.replace("\\", "\\\\"), text, flags=re.MULTILINE) if n != 1: raise Fatal( f"blaze patch {name} applied {n} times, expected exactly 1 " f"(pattern {pattern!r}). The responder changed shape -- REFUSING to " "run a half-patched copy that could point at production." ) step(f"patched {name:<12} -> {replacement.split(' = ', 1)[1]}") with open(safe_path(lay.blaze_script), "w") as fh: fh.write(text) for asset in BLAZE_ASSETS: src = os.path.join(os.path.dirname(BLAZE_SRC), asset) if not os.path.isfile(src): raise Fatal(f"missing blaze TLS asset {src}") # The responder resolves CERT/KEY relative to its own directory, so the # assets are copied rather than patched. shutil.copy2(safe_path(src), safe_path(os.path.join(lay.blaze, asset))) verify_blaze_patch(lay) def verify_blaze_patch(lay: Layout) -> None: """Re-read the file from disk and prove the patched copy cannot reach production Blaze ports or production UTAS.""" with open(safe_path(lay.blaze_script)) as fh: lines = fh.read().splitlines() def assignment(name: str) -> str: hits = [ln for ln in lines if re.match(rf"^{name} = ", ln)] if len(hits) != 1: raise Fatal(f"patched blaze copy has {len(hits)} `{name} =` lines") return hits[0] expected = { "REDIR_PORT": f"REDIR_PORT = {BLAZE_REDIR_PORT}", "BLAZE_PORT": f"BLAZE_PORT = {BLAZE_MAIN_PORT}", "NUCLEUS_PORT": f"NUCLEUS_PORT = {BLAZE_NUCLEUS_PORT}", "UTAS_BASE": f'UTAS_BASE = "http://%s:{HOST_PORT}/" % _ADVERTISE', "LOG": f'LOG = "{lay.blaze_responder_log}"', "RXDIR": f'RXDIR = "{lay.blaze_rx}"', } for name, want in expected.items(): got = assignment(name) if got != want: raise Fatal(f"patched blaze {name} is {got!r}, expected {want!r}") utas = assignment("UTAS_BASE") if ":8099" in utas: raise Fatal(f"patched blaze STILL advertises production UTAS: {utas!r}") ok(f"patched blaze UTAS_BASE = {utas.split(' = ', 1)[1]} (no :8099)") ok( "patched blaze ports: redirector " f"{BLAZE_REDIR_PORT} / main {BLAZE_MAIN_PORT} / nucleus " f"{BLAZE_NUCLEUS_PORT} (no 42127/42130/42131)" ) # --- seeding ------------------------------------------------------------------------ # FIFA refuses to kick off unless the squad has 11 starters AND 7 substitutes: # "your squad must have at least 11 players and 7 subs … currently below the # minimum number of players (18)". The imported club's squad has only its 11 # starters, so a freshly installed real club is UNPLAYABLE until the bench is # filled. The 23 slots are 0..10 pitch, 11..22 bench/reserves, derived from the # index alone. CLIENT_MIN_SQUAD = 18 SQUAD_SLOTS = 23 def _squad_fingerprint(squad_id: str, formation: str, slots) -> str: """Recompute Core's canonical squad fingerprint. Mirrors `openfut-core/src/services/squad.rs::squad_fingerprint` exactly: `v1|||` plus SORTED `slot:owned:captain:bench` items joined by `;`, hashed with FNV-1a-64 and printed as 16 lowercase hex digits. Core stamps this whenever the canonical squad changes, and the FIFA17 opaque extension is only applied when its stored fingerprint still matches. Writing squad rows directly therefore INVALIDATES the extension, and the host then projects `stale_integrity` — no players and no manager at all. Re-stamping is what a squad replace through Core would have done. """ items = sorted( f"{slot}:{owned}:{int(captain)}:{int(bench)}" for slot, owned, captain, bench in slots ) canon = f"v1|{squad_id}|{formation}|" + ";".join(items) h = 0xCBF29CE484222325 for b in canon.encode(): h = ((h ^ b) * 0x00000100000001B3) & 0xFFFFFFFFFFFFFFFF return f"{h:016x}" def restamp_squad_extension(conn, squad_id: str) -> bool: """Bring the stored FIFA17 extension back in step with the canonical squad. Returns False when the squad has no extension row (nothing to re-stamp), which is the fixture club's normal state. """ row = conn.execute( "SELECT formation FROM squads WHERE id = ?", (squad_id,) ).fetchone() if row is None: return False slots = conn.execute( "SELECT position_index, owned_card_id, is_captain, is_on_bench " "FROM squad_players WHERE squad_id = ?", (squad_id,) ).fetchall() fingerprint = _squad_fingerprint(squad_id, row[0], slots) changed = conn.execute( "UPDATE game_entity_ext SET canonical_fingerprint = ? " "WHERE entity_kind = 'squad' AND entity_id = ?", (fingerprint, squad_id), ).rowcount return changed > 0 def fill_bench_to_minimum(conn, club_id: str, squad_id: str, lay: Layout) -> int: """Top the squad up to the client's minimum with the club's best spare players, filling empty bench slots from index 11 upward. Only EMPTY slots are written, so the starting XI — and any bench the operator has already chosen — is never touched, and a re-run is a no-op. Only real PLAYER definitions are eligible: a kit, a manager or a consumable in a squad slot would be nonsense, and the client would drop it anyway. """ taken = { row[0] for row in conn.execute( "SELECT owned_card_id FROM squad_players WHERE squad_id = ?", (squad_id,) ) } used_slots = { row[0] for row in conn.execute( "SELECT position_index FROM squad_players WHERE squad_id = ?", (squad_id,) ) } if len(taken) >= CLIENT_MIN_SQUAD: return 0 with open(safe_path(lay.catalog)) as fh: catalog = json.load(fh)["cards"] with open(safe_path(lay.cards)) as fh: overall = {c["id"]: c.get("overall", 0) for c in json.load(fh)} # Best first, then a stable id tiebreak so a re-run picks the same bench. candidates = [ (overall.get(card_id, 0), owned_id, card_id) for owned_id, card_id in conn.execute( "SELECT id, card_id FROM owned_cards WHERE club_id = ? AND is_loan = 0", (club_id,), ) if owned_id not in taken and catalog.get(card_id, {}).get("kind", "player") == "player" ] candidates.sort(key=lambda c: (-c[0], c[1])) free_slots = [i for i in range(11, SQUAD_SLOTS) if i not in used_slots] need = CLIENT_MIN_SQUAD - len(taken) if need > len(free_slots) or need > len(candidates): raise Fatal( f"cannot reach the client's {CLIENT_MIN_SQUAD}-player minimum: need " f"{need} more, but {len(free_slots)} free bench slots and " f"{len(candidates)} eligible spare players" ) rows = [ (f"sp-bench-{slot}", squad_id, owned_id, slot, 0, 1) for slot, (_ovr, owned_id, _card) in zip(free_slots, candidates[:need]) ] conn.executemany( "INSERT INTO squad_players (id, squad_id, owned_card_id, position_index, " "is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, ?)", rows, ) return len(rows) def seed_core_db(lay: Layout, real_club: dict | None) -> None: """Seed the identities the experiment needs, against the schema Core just migrated. Column sets are from openfut-core/migrations/0001_initial.sql plus 0016's profiles.game_id. Seller A carries game_id `fifa17` so the retail client (which sends X-OpenFUT-Game: fifa17) resolves to it; Buyer B is parked on its own game_id so it can never shadow the seller as the active fifa17 profile. With the real club installed, Seller A already exists -- it IS the imported persona, with its own club id, coins, items and squad -- so only Buyer B is added, and the kit fixtures are attached to the real club so the kit work stays exercisable. The real squad is never touched: it is the operator's own. """ conn = sqlite3.connect(safe_path(lay.core_db), timeout=15) try: conn.execute("PRAGMA busy_timeout = 15000") with conn: profiles = [(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME)] clubs = [(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS)] if real_club is None: profiles.insert(0, (SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME)) clubs.insert(0, (SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC", SELLER_COINS, TS, TS)) conn.executemany( "INSERT INTO profiles (id, username, level, xp, created_at, " "updated_at, game_id) VALUES (?, ?, 1, 0, ?, ?, ?)", profiles, ) conn.executemany( "INSERT INTO clubs (id, profile_id, name, coins, level, created_at, " "updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)", clubs, ) seller_club = SELLER_CLUB if real_club is None else real_club["club_id"] # Core classifies ownership generically (migration 0025), so every # seeded row states what it IS. Getting this wrong would leave Core # calling a kit a player, and Core is the ownership authority. owned = [ (owned_id, seller_club, card_id, "kit", TS) for _slot, owned_id, card_id, _resource_id in STAGING_KITS ] owned.append( ( STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], "manager", TS, ) ) owned.extend( (owned_id, seller_club, card_id, kind, TS) for _slot, owned_id, card_id, _rid, kind, _st, _art, _team in STAGING_CLUB_ITEMS ) if real_club is None: owned = ( [(item, SELLER_CLUB, card, "player", TS) for item, card in SELLER_SQUAD_CARDS] + [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, "player", TS)] + owned ) conn.executemany( "INSERT INTO owned_cards (id, club_id, card_id, is_loan, " "content_kind, acquired_at) VALUES (?, ?, ?, 0, ?, ?)", owned, ) if real_club is None: conn.execute( "INSERT INTO squads (id, club_id, name, formation, created_at, " "updated_at) VALUES (?, ?, ?, ?, ?, ?)", (SELLER_SQUAD, SELLER_CLUB, "Staging XI", "4-3-3", TS, TS), ) conn.executemany( "INSERT INTO squad_players (id, squad_id, owned_card_id, " "position_index, is_captain, is_on_bench) VALUES (?, ?, ?, ?, ?, 0)", [ (f"sp-{idx}", SELLER_SQUAD, item, idx, 1 if idx == 0 else 0) for idx, (item, _) in enumerate(SELLER_SQUAD_CARDS) ], ) # Core generalised the two-slot kit table into slot-keyed active club # designations (migration 0026), so the fixture writes 'home_kit' / # 'away_kit' rather than 'home' / 'away'. conn.executemany( "INSERT INTO club_active_items (club_id, slot, owned_card_id, updated_at) " "VALUES (?, ?, ?, ?)", [ (seller_club, f"{slot}_kit", owned_id, TS) for slot, owned_id, _card_id, _resource_id in STAGING_KITS ] # badge / ball / stadium are slot-keyed exactly like the kits. # A league logo has no slot, so it stays owned-but-unequipped — # which is itself worth exercising. + [ (seller_club, slot, owned_id, TS) for slot, owned_id, _c, _r, _k, _s, _a, _t in STAGING_CLUB_ITEMS if slot is not None ], ) # Assign the manager to whichever squad the club actually has: the # fixture's own, or the real club's imported squad. Migration 0023 # keys squad_managers by squad_id, so the assignment must name a real # squad row rather than the club. squad_row = conn.execute( "SELECT id FROM squads WHERE club_id = ? ORDER BY updated_at DESC " "LIMIT 1", (seller_club,) ).fetchone() if squad_row is None: raise Fatal( f"club {seller_club} has no squad, so the manager cannot be " "assigned; FIFA refuses to start a match without one" ) conn.execute( "INSERT OR REPLACE INTO squad_managers (squad_id, owned_card_id, " "updated_at) VALUES (?, ?, ?)", (squad_row[0], STAGING_MANAGER["owned_id"], TS), ) # Only the REAL club is meant to be played. The 14-item fixture is a # market test bed with a single spare player, so demanding 18 there # would abort a bring-up that never needed to kick off. filled = ( fill_bench_to_minimum(conn, seller_club, squad_row[0], lay) if real_club is not None else 0 ) # Squad rows were just written behind Core's back (bench fill, and # the manager designation above), so the opaque FIFA17 extension is # now stale and the host would drop it — projecting an EMPTY squad. restamped = restamp_squad_extension(conn, squad_row[0]) finally: conn.close() if real_club is None: ok( f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} " f"coins, {len(SELLER_SQUAD_CARDS)} starters + 1 disposable + " f"{len(STAGING_KITS)} active kits) and Buyer B ({BUYER_COINS} coins)" ) else: ok( f"kept the real club untouched ({real_club['owned_cards']} items, " f"{real_club['coins']:,} coins, squad {real_club['squads'][0]['name']!r} " f"with {real_club['squad_players']} players); added " f"{len(STAGING_KITS)} active kits, the manager " f"{STAGING_MANAGER['label']} and Buyer B ({BUYER_COINS} coins)" ) if filled: ok( f"filled {filled} empty bench slot(s) with the club's best spare " f"players: FIFA refuses to kick off below {CLIENT_MIN_SQUAD} " "(11 starters + 7 subs), and the imported squad carries only its XI" ) if restamped: ok( "re-stamped the squad's FIFA17 extension fingerprint so Core still " "considers it fresh after the squad changed (a stale extension " "projects as an empty squad with no manager)" ) def db_summary(lay: Layout) -> str: conn = sqlite3.connect(safe_path(lay.core_db), timeout=15) try: clubs = conn.execute("SELECT id, coins FROM clubs ORDER BY id").fetchall() owned = conn.execute( "SELECT club_id, COUNT(*) FROM owned_cards GROUP BY club_id" ).fetchall() finally: conn.close() return f"clubs={dict(clubs)} owned={dict(owned)}" # --- launching ---------------------------------------------------------------------- class Launched: def __init__(self, name: str, proc, log: str, port_note: str) -> None: self.name = name self.proc = proc self.log = log self.port_note = port_note def record(self) -> dict: return { "name": self.name, "pid": self.proc.pid, "pgid": os.getpgid(self.proc.pid), "cmdline": cmdline_of(self.proc.pid), "log": self.log, "ports": self.port_note, } def spawn(argv: list[str], env: dict, cwd: str, log_path: str, append=False): """Start detached (own session) so the stack survives this script exiting, and so the down script can signal exactly this process group and nothing else.""" log = open(safe_path(log_path), "a" if append else "w", buffering=1) try: log.write(f"\n---- launch {' '.join(argv)} @ {time.strftime('%FT%TZ')} ----\n") return subprocess.Popen( argv, cwd=safe_path(cwd), env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, ) finally: log.close() # the child holds its own dup of the fd def core_env(lay: Layout) -> dict: return dict( os.environ, LISTEN_ADDR=f"127.0.0.1:{CORE_PORT}", DATABASE_URL=f"sqlite://{lay.core_db}", DATA_DIR=os.path.join(REPO, "openfut-core", "data"), OPENFUT_CONTENT_PACKS=lay.cards, RUST_LOG="openfut_core=info", ) def migrate_core(lay: Layout) -> None: """Core owns its schema and has no migrate-only subcommand, so the fixture cannot be written into an empty file: start it once, let it migrate, stop it, seed, then start the long-lived instance.""" proc = spawn([lay.core_bin], core_env(lay), lay.root, lay.core_log) try: wait_http(CORE_PORT, "/health", proc, lay.core_log, "staging Core (migrate)") ok(f"staging Core migrated {os.path.basename(lay.core_db)}") finally: proc.terminate() try: proc.wait(timeout=20) except subprocess.TimeoutExpired: proc.kill() 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 `" ) # 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") ok(f"staging Core ready on 127.0.0.1:{CORE_PORT} (pid {proc.pid})") return Launched("staging-core", proc, lay.core_log, f"127.0.0.1:{CORE_PORT}") def start_host(lay: Layout, variant: str, coins_processed: str, count_mode: str) -> Launched: env = dict( os.environ, OPENFUT_UTAS_HOST_ADDR=f"{BIND}:{HOST_PORT}", OPENFUT_CORE_URL=f"http://127.0.0.1:{CORE_PORT}", # NOT the production oracle on 8199. Nothing listens here, so any Python # fallback fails closed and shows up in the log. OPENFUT_UTAS_PYTHON_URL=f"http://127.0.0.1:{DEAD_PYTHON_PORT}", OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon", "data", "tables"), OPENFUT_FIFA17_CATALOG=lay.catalog, OPENFUT_IDENTITY_STORE=lay.identity, OPENFUT_CLIENTDATA_DB=lay.clientdata, OPENFUT_PERSONA_ID=PERSONA_ID, OPENFUT_MARKET_DB=lay.market_db, OPENFUT_PILE_DB=lay.pile_db, RUST_LOG="info", ) # OFF must mean "unset", not "set to something unrecognised": the host treats an # unrecognised token as OFF, but leaving the variable behind invites confusion. for key in ("OPENFUT_FIFA17_SOLD_EXPERIMENT", "OPENFUT_FIFA17_SOLD_COINS_PROCESSED", "OPENFUT_FIFA17_SOLD_COUNT_MODE"): env.pop(key, None) if variant != "off": env["OPENFUT_FIFA17_SOLD_EXPERIMENT"] = variant env["OPENFUT_FIFA17_SOLD_COINS_PROCESSED"] = coins_processed env["OPENFUT_FIFA17_SOLD_COUNT_MODE"] = count_mode proc = spawn([lay.host_bin], env, lay.root, lay.host_log) wait_http(HOST_PORT, f"/ut/game/{GAME}/tradePile/counts", proc, lay.host_log, "staging utas-host") ok(f"staging utas-host ready on {BIND}:{HOST_PORT} (pid {proc.pid})") return Launched("staging-utas-host", proc, lay.host_log, f"{BIND}:{HOST_PORT}") def start_blaze(lay: Layout, roster_host: str) -> Launched: env = dict( os.environ, OPENFUT_ADVERTISE=ADVERTISE, OPENFUT_BIND=BIND, OPENFUT_ROSTER_HOST=roster_host, # Read-only reuse of the production auxiliary services: static content, no # economy state, and staging never connects to them -- it only advertises # the same strings production Blaze advertises. POW_CONTENT_HOST=POW_CONTENT_HOST, POW_HOST=POW_HOST, # The responder does `sys.path.insert(0, dirname(__file__))` to reach its # pure sibling modules (`heat2` TDF codec, `fut_account` identity). The copy # lives elsewhere, so the originals are made importable read-only instead of # duplicated -- identity MUST stay byte-identical to what LSX and Blaze # already assert for this persona. PYTHONPATH=os.path.dirname(BLAZE_SRC), ) env.pop("FUT_POW", None) env.pop("FUT_SBC", None) proc = spawn(["python3", "-u", lay.blaze_script], env, lay.blaze, lay.blaze_log) wait_tcp([BLAZE_REDIR_PORT, BLAZE_MAIN_PORT, BLAZE_NUCLEUS_PORT], proc, lay.blaze_log, "staging blaze") want = ( f"RESPONDER v3 START (redir {BLAZE_REDIR_PORT} / blaze {BLAZE_MAIN_PORT} " f"/ nucleus {BLAZE_NUCLEUS_PORT})" ) responder_log = "" if os.path.exists(lay.blaze_responder_log): with open(safe_path(lay.blaze_responder_log), errors="replace") as fh: responder_log = fh.read() if want not in responder_log: raise Fatal( f"staging blaze did not log {want!r} -- it is not the patched copy.\n" f"{tail(lay.blaze_responder_log)}\n{tail(lay.blaze_log)}" ) ok( f"staging blaze ready: redirector {BLAZE_REDIR_PORT}, main {BLAZE_MAIN_PORT}, " f"nucleus {BLAZE_NUCLEUS_PORT} (pid {proc.pid}); roster {roster_host}; " f"logged {want!r}" ) return Launched( "staging-blaze", proc, lay.blaze_log, f"{BIND}:{BLAZE_REDIR_PORT},{BLAZE_MAIN_PORT},{BLAZE_NUCLEUS_PORT}", ) def stop_launched(items: list[Launched]) -> None: """Roll back a partial bring-up: signal only the process groups we created.""" for item in reversed(items): if item.proc.poll() is not None: continue try: os.killpg(os.getpgid(item.proc.pid), signal.SIGTERM) except OSError: pass try: item.proc.wait(timeout=10) except subprocess.TimeoutExpired: try: os.killpg(os.getpgid(item.proc.pid), signal.SIGKILL) except OSError: pass # --- verification ------------------------------------------------------------------- def host_banner_line(lay: Layout) -> str: with open(safe_path(lay.host_log), errors="replace") as fh: hits = [ln.strip() for ln in fh if "sold-experiment=" in ln] if not hits: raise Fatal(f"staging utas-host printed no sold-experiment banner\n{tail(lay.host_log)}") return hits[-1] def verify(lay: Layout, variant: str, real_club_expected: bool) -> None: line = host_banner_line(lay) ok(f"host banner: {line}") if variant == "off": if "sold-experiment=OFF" not in line: raise Fatal(f"expected an OFF banner, got {line!r}") else: want = f"sold-experiment=ON bidState={variant}" if want not in line: raise Fatal(f"banner does not say {want!r}: {line!r}") status, body = http_get(HOST_PORT, f"/ut/game/{GAME}/tradePile/counts") ok(f"GET 127.0.0.1:{HOST_PORT}/ut/game/{GAME}/tradePile/counts -> HTTP {status} {body}") if status != 200: raise Fatal("staging /tradePile/counts did not answer 200") status, body = http_get(CORE_PORT, "/health") ok(f"GET 127.0.0.1:{CORE_PORT}/health -> HTTP {status} {body}") # A stale opaque extension does not fail anything loudly — the host just # drops it and the client sees an empty squad with no manager. Prove the # real club actually projects before handing the rig over. if real_club_expected: status, body = http_get(HOST_PORT, f"/ut/game/{GAME}/squad/0") squad = json.loads(body) if status == 200 else {} occupied = sum(1 for p in squad.get("players", []) if p.get("itemData", {}).get("id")) manager = squad.get("manager") or [] ok( f"GET /ut/game/{GAME}/squad/0 -> HTTP {status} " f"{occupied} occupied slot(s), {len(manager)} manager" ) if occupied < CLIENT_MIN_SQUAD or not manager: raise Fatal( f"the squad projected {occupied} player(s) and {len(manager)} " f"manager(s); expected at least {CLIENT_MIN_SQUAD} and one " "manager. A 'stale_integrity' line in the host log means the " "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"): try: target = os.readlink(f"/proc/{os.getpid()}/fd/{fd}") except OSError: continue if any(target.startswith(bad) for bad in FORBIDDEN_PATHS): leaked.append(target) if leaked: raise Fatal(f"this script has open handles on production state: {leaked}") ok(f"no open handle under {FORBIDDEN_PATHS[0]}/") assert_prod_alive("end of bring-up") # --- summary ------------------------------------------------------------------------ def cfg_block() -> list[str]: return [ f"host={ADVERTISE}", f"blaze_redirector_port={BLAZE_REDIR_PORT}", f"blaze_main_port={BLAZE_MAIN_PORT}", ] def print_summary(lay: Layout, variant: str, coins_processed: str, count_mode: str, roster_host: str, records: list[dict], real_club: dict | None) -> None: banner("STAGING STACK IS UP") if real_club is None: print(" club: the 14-item synthetic fixture " "(--club real installs the operator's own)") else: squad = real_club["squads"][0] if real_club["squads"] else None print(f" club: THE REAL ONE -- {real_club['username']} " f"({real_club['club_name']}), {real_club['owned_cards']} items, " f"{real_club['coins']:,} coins") if squad is not None: print(f" squad {squad['name']!r} ({squad['formation']}) with " f"{real_club['squad_players']} players") print(" DO NOT run sold-staging-seed-squad.py: it would overwrite " "this squad with the fixture XI") print() rows = [ ("staging Core", f"127.0.0.1:{CORE_PORT}", "loopback only; client never talks to it"), ("staging utas-host", f"{BIND}:{HOST_PORT}", "UTAS the client reaches"), ("staging blaze redirector", f"{BIND}:{BLAZE_REDIR_PORT}", "TLS; EA :10041 / :42230"), ("staging blaze main", f"{BIND}:{BLAZE_MAIN_PORT}", "EA :42127"), ("staging blaze nucleus", f"{BIND}:{BLAZE_NUCLEUS_PORT}", "OAuth stub"), ("dead python upstream", f"127.0.0.1:{DEAD_PYTHON_PORT}", "UNBOUND on purpose: fallback fails closed"), ] print(f" {'SERVICE':<26} {'BIND':<24} NOTE") for name, bind, note in rows: print(f" {name:<26} {bind:<24} {note}") print() print(f" {'STATE FILE':<26} PATH") for name, path in ( ("Core sqlite", lay.core_db), ("market sqlite", lay.market_db), ("pile sqlite", lay.pile_db), ("identity store", lay.identity), ("clientdata blobs", lay.clientdata), ("content pack", lay.cards), ("identity catalog", lay.catalog), ("patched blaze", lay.blaze_script), ("manifest", lay.manifest), ): print(f" {name:<26} {path}") print() print(f" {'PROCESS':<26} {'PID':<8} {'PGID':<8} LOG") for rec in records: print(f" {rec['name']:<26} {rec['pid']:<8} {rec['pgid']:<8} {rec['log']}") print() print(f" experiment variant : {variant}") print(f" coinsProcessed : {coins_processed}") print(f" count mode : {count_mode}") print(f" roster host : {roster_host}") print(f" banner : {host_banner_line(lay)}") print(f" seeded state : {db_summary(lay)}") print(f" disposable item to sell : {DISPOSABLE_ITEM} ({DISPOSABLE_CARD})") banner("OPERATOR: EDIT openfut.cfg ON THE FIFA CLIENT (10.10.0.105)") print(' File: "/mnt/games/FIFA 17/openfut.cfg" (back it up first:') print(' cp openfut.cfg openfut.cfg.prod)') print() print(" Replace these three lines with EXACTLY:") print() for line in cfg_block(): print(f" {line}") print() print(" Leave https_port=8443 UNCHANGED (Bridge; no economy state).") print(" Then RELAUNCH the FIFA 17 client -- a running client caches its UTAS") print(" session and will not re-auth against a different stack.") print() print(" REVERT to production (production values, unchanged on this host):") print() print(" host=10.10.0.120") print(" blaze_redirector_port=42127") print(" blaze_main_port=42130") print() print(" Full detail: docs/SOLD_STAGING_RUNBOOK.md") print() print(" Tear down: python3 scripts/sold-staging-down.py") # --- main --------------------------------------------------------------------------- def main() -> int: ap = argparse.ArgumentParser( description=__doc__.splitlines()[0], formatter_class=argparse.RawDescriptionHelpFormatter, ) ap.add_argument("--variant", choices=["highest", "buyNow", "off"], default="highest", help="bidState token emitted on a sold seller row (default: highest)") ap.add_argument("--coins-processed", choices=["0", "1"], default="0", help="coinsProcessed on the sold row (default: 0)") ap.add_argument("--count-mode", choices=["active", "active_plus_sold"], default="active", help="what /tradePile/counts.count reports (default: active)") ap.add_argument( "--roster-host", default=os.environ.get("OPENFUT_ROSTER_HOST", DEFAULT_ROSTER_HOST), help=f"host:port advertised for roster HTTPS (default: {DEFAULT_ROSTER_HOST})", ) 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( "--club", choices=["fixture", "real"], default="fixture", help="which club the seller plays with: the 14-item synthetic fixture " "(default) or the operator's real imported club, installed from the " "snapshot taken by scripts/club-snapshot.py", ) args = ap.parse_args() lay = Layout(args.dir) started: list[Launched] = [] try: banner("PREFLIGHT (nothing is launched until every check passes)") step(f"staging dir : {lay.root}") step(f"repo : {REPO}") step(f"forbidden ports : {sorted(FORBIDDEN_PORTS)}") step(f"forbidden paths : {list(FORBIDDEN_PATHS)}") step(f"club : {args.club}") assert_prod_alive("preflight") refuse_if_up(lay) assert_ports_free() banner("MATERIALISE STAGING DIRECTORY") materialise(lay) reset_throwaway_state(lay) # The real club is installed BEFORE Core first runs, so Core migrates the # snapshot forward (it is a schema generation behind) rather than being # handed a database it has already opened. real_club = install_real_club(lay) if args.club == "real" else None assert_seed_cards_resolvable(lay, real_club) banner("PATCH THE BLAZE RESPONDER COPY") patch_blaze(lay) banner("STAGING CORE") if real_club is None: if os.path.exists(lay.core_db): raise Fatal( f"{lay.core_db} should have been removed by the state reset" ) elif not os.path.exists(lay.core_db): 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") started.append(start_host(lay, args.variant, args.coins_processed, args.count_mode)) banner("STAGING BLAZE") started.append(start_blaze(lay, args.roster_host)) records = [item.record() for item in started] for rec in records: if lay.root not in rec["cmdline"]: raise Fatal( f"{rec['name']} pid {rec['pid']} cmdline does not contain the " f"staging dir -- the down script would refuse to stop it: " f"{rec['cmdline']!r}" ) with open(safe_path(lay.manifest), "w") as fh: json.dump( { "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "staging_dir": lay.root, "variant": args.variant, "club": args.club, "real_club": real_club, "coins_processed": args.coins_processed, "count_mode": args.count_mode, "roster_host": args.roster_host, "ports": { "core": CORE_PORT, "utas_host": HOST_PORT, "blaze_redirector": BLAZE_REDIR_PORT, "blaze_main": BLAZE_MAIN_PORT, "blaze_nucleus": BLAZE_NUCLEUS_PORT, "dead_python": DEAD_PYTHON_PORT, }, "state_files": { "core_db": lay.core_db, "market_db": lay.market_db, "pile_db": lay.pile_db, "identity": lay.identity, "clientdata": lay.clientdata, }, "client_cfg": cfg_block(), "processes": records, }, fh, indent=2, ) ok(f"manifest written: {lay.manifest}") banner("VERIFY ISOLATION") verify(lay, args.variant, real_club is not None) print_summary(lay, args.variant, args.coins_processed, args.count_mode, args.roster_host, records, real_club) return 0 except ProductionError as exc: print(f"\nFATAL: {exc}", file=sys.stderr) if started: print(" rolling back the partial bring-up...", file=sys.stderr) stop_launched(started) print(" rolled back (only this script's own process groups were " "signalled)", file=sys.stderr) return 1 if __name__ == "__main__": sys.exit(main())