1041 lines
42 KiB
Python
Executable File
1041 lines
42 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.
|
|
|
|
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
|
|
|
|
# --- fixed facts -------------------------------------------------------------------
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
# Production. Never bind, never connect, never open.
|
|
FORBIDDEN_PORTS = frozenset(
|
|
{8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
|
|
)
|
|
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
|
|
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
|
|
|
|
# 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"
|
|
|
|
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
|
|
|
|
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(RuntimeError):
|
|
"""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 listening_ports() -> set[int]:
|
|
"""Every TCP port in state LISTEN in this network namespace, read straight from
|
|
the kernel socket table.
|
|
|
|
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. This mirrors `ss -ltn` (and host-lifecycle.sh's
|
|
hl_port_listening), which is the question actually being asked."""
|
|
ports: set[int] = set()
|
|
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
|
|
try:
|
|
with open(path) as fh:
|
|
next(fh, None) # header
|
|
for line in fh:
|
|
fields = line.split()
|
|
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
|
|
continue
|
|
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
|
|
except OSError:
|
|
continue
|
|
return ports
|
|
|
|
|
|
def port_free(port: int) -> bool:
|
|
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 ""
|
|
|
|
|
|
def assert_prod_alive(where: str) -> None:
|
|
for pid, what in PROD_PIDS.items():
|
|
if not pid_alive(pid):
|
|
raise Fatal(f"{what} pid {pid} is NOT alive at {where} -- stop and investigate")
|
|
ok(
|
|
f"production untouched at {where}: "
|
|
+ ", ".join(f"{what} pid {pid} alive" for pid, what in PROD_PIDS.items())
|
|
)
|
|
|
|
|
|
# --- 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)")
|
|
|
|
|
|
def assert_seed_cards_resolvable(lay: Layout) -> 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."""
|
|
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"])
|
|
wanted = [c for _, c in SELLER_SQUAD_CARDS] + [DISPOSABLE_CARD]
|
|
missing_pack = sorted(set(wanted) - pack_ids)
|
|
missing_cat = sorted(set(wanted) - catalog_ids)
|
|
if missing_pack or missing_cat:
|
|
raise Fatal(
|
|
"seed card ids are not resolvable -- Core or the host would fail at "
|
|
f"startup.\n absent from content pack: {missing_pack}"
|
|
f"\n absent from identity catalog: {missing_cat}"
|
|
)
|
|
ok(
|
|
f"all {len(wanted)} seed card ids present in BOTH the content pack "
|
|
f"({len(pack_ids)} defs) and the 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 ------------------------------------------------------------------------
|
|
|
|
|
|
def seed_core_db(lay: Layout) -> None:
|
|
"""Two identities by direct SQL, 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.
|
|
"""
|
|
conn = sqlite3.connect(safe_path(lay.core_db), timeout=15)
|
|
try:
|
|
conn.execute("PRAGMA busy_timeout = 15000")
|
|
with conn:
|
|
conn.executemany(
|
|
"INSERT INTO profiles (id, username, level, xp, created_at, "
|
|
"updated_at, game_id) VALUES (?, ?, 1, 0, ?, ?, ?)",
|
|
[
|
|
(SELLER_PROFILE, PERSONA_NAME, TS, TS, GAME),
|
|
(BUYER_PROFILE, "BUYER-B", TS, TS, BUYER_GAME),
|
|
],
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO clubs (id, profile_id, name, coins, level, created_at, "
|
|
"updated_at) VALUES (?, ?, ?, ?, 1, ?, ?)",
|
|
[
|
|
(SELLER_CLUB, SELLER_PROFILE, f"{PERSONA_NAME} FC", SELLER_COINS,
|
|
TS, TS),
|
|
(BUYER_CLUB, BUYER_PROFILE, "Buyer B FC", BUYER_COINS, TS, TS),
|
|
],
|
|
)
|
|
conn.executemany(
|
|
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, "
|
|
"acquired_at) VALUES (?, ?, ?, 0, ?)",
|
|
[(item, SELLER_CLUB, card, TS) for item, card in SELLER_SQUAD_CARDS]
|
|
+ [(DISPOSABLE_ITEM, SELLER_CLUB, DISPOSABLE_CARD, TS)],
|
|
)
|
|
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)
|
|
],
|
|
)
|
|
finally:
|
|
conn.close()
|
|
ok(
|
|
f"seeded Seller A ({PERSONA_NAME}, persona {PERSONA_ID}, {SELLER_COINS} coins, "
|
|
f"{len(SELLER_SQUAD_CARDS)} starters + 1 disposable) and Buyer B "
|
|
f"({BUYER_COINS} coins)"
|
|
)
|
|
|
|
|
|
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]) -> None:
|
|
banner("STAGING STACK IS UP")
|
|
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})")
|
|
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)}")
|
|
assert_prod_alive("preflight")
|
|
refuse_if_up(lay)
|
|
assert_ports_free()
|
|
|
|
banner("MATERIALISE STAGING DIRECTORY")
|
|
materialise(lay)
|
|
reset_throwaway_state(lay)
|
|
assert_seed_cards_resolvable(lay)
|
|
|
|
banner("PATCH THE BLAZE RESPONDER COPY")
|
|
patch_blaze(lay)
|
|
|
|
banner("STAGING CORE")
|
|
if os.path.exists(lay.core_db):
|
|
raise Fatal(f"{lay.core_db} should have been removed by the state reset")
|
|
migrate_core(lay)
|
|
seed_core_db(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,
|
|
"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)
|
|
return 0
|
|
except Fatal 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())
|