106cb83988
FIFA refuses to kick off with "your squad must have at least 11 players and 7 subs … currently below the minimum number of players (18)". The imported club's squad carries ONLY its starting XI, so a freshly installed real club is unplayable until someone fills the bench by hand in the hub. `fill_bench_to_minimum` tops the squad up with the club's best spare players, writing EMPTY bench slots from index 11 upward. The 23 slots are 0..10 pitch and 11..22 bench/reserves, derived from the index alone, 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 is nonsense the client would drop anyway. Selection is best-rating-first with a stable id tiebreak, so the same bench comes back on a re-run rather than shuffling. Scoped to the REAL club on purpose. The 14-item fixture is a market test bed with a single spare player; demanding 18 there would abort a bring-up that never needed to kick off. Fixture mode therefore does not call this at all. Verified against a copy of the club snapshot (the live staging database was left alone, since it currently holds a squad the operator saved by hand): 11 -> 18 players, slots 0..17, no duplicate instance in two slots, every filled pick a real player definition, and a second run filling nothing. This is NOT a diagnosis of the failure the operator just hit — that squad had already been filled to 23 valid players before the match was created, and the host log shows the client issued no request at all after `match-create` beyond account-sync, so that refusal is decided entirely client-side. It removes the variable: after a clean bring-up the club is now playable without hand-editing.
1415 lines
57 KiB
Python
Executable File
1415 lines
57 KiB
Python
Executable File
#!/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 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 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
|
|
# 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.
|
|
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,
|
|
"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,
|
|
}
|
|
|
|
mgr = STAGING_MANAGER
|
|
if mgr["card_id"] not in existing:
|
|
definitions.append({
|
|
"id": mgr["card_id"],
|
|
"name": mgr["label"],
|
|
"overall": 0,
|
|
"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,
|
|
"rareflag": 0,
|
|
"kind": "staff",
|
|
"subtype": MANAGER_SUBTYPE,
|
|
"team_id": mgr["team_id"],
|
|
"nation": mgr["nation"],
|
|
"league_id": mgr["league_id"],
|
|
}
|
|
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 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"]
|
|
owned = [
|
|
(owned_id, seller_club, card_id, TS)
|
|
for _slot, owned_id, card_id, _resource_id in STAGING_KITS
|
|
]
|
|
owned.append(
|
|
(STAGING_MANAGER["owned_id"], seller_club, STAGING_MANAGER["card_id"], TS)
|
|
)
|
|
if real_club is None:
|
|
owned = (
|
|
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
|
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)]
|
|
+ owned
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
|
"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)
|
|
],
|
|
)
|
|
|
|
conn.executemany(
|
|
"INSERT INTO club_kit_assignments (club_id, slot, owned_card_id, updated_at) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
[
|
|
(seller_club, slot, owned_id, TS)
|
|
for slot, owned_id, _card_id, _resource_id in STAGING_KITS
|
|
],
|
|
)
|
|
|
|
# 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
|
|
)
|
|
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"
|
|
)
|
|
|
|
|
|
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 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) -> 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}")
|
|
|
|
# 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)
|
|
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)
|
|
|
|
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())
|