de747b79e6
The operator could not field a starting XI because staging has only ever held the 14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays. Worth recording while looking for the club: the LIVE production Core container serves an EMPTY database (0 owned cards, schema predating even the game_id column). The real club is not being served anywhere right now; it exists as state on disk. So restoring it into staging is not a convenience, it is the only way to play it. Two pieces: `scripts/club-snapshot.py` is the ONE place allowed to read production state, and it is read-only by construction: the Core database is opened `mode=ro` and copied with sqlite's online backup API (a plain file copy can tear a database with a hot WAL), every destination is asserted to be outside the production directory before anything is opened for writing, and the sha256 of every source is compared before and after -- a mismatch aborts, because that would mean the snapshot modified production. It then proves the copy is faithful (same counts, coins, squad) and that the identity store maps EVERY owned card to a wire id, since an unmapped card would reappear under a freshly minted id and break the client's cached squad. `sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE Core first starts, so Core migrates the copy forward from schema v19 through match_completions, squad managers and kit assignments. Seller A is then already present -- it IS the imported persona -- so only Buyer B is seeded, the kit fixtures are attached to the real club so the kit work stays exercisable, and the real squad is left alone. The up script still never reads production state: the snapshot lives outside it, which is precisely what makes `--club real` compatible with the `safe_path()` refusal. The resolvability preflight now covers whichever club will actually be served. This is the check that matters most for the real one: Core does not fail on an owned card whose definition is missing, it silently filter_map-drops it, so a gap shows up as an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct card ids resolve in both the content pack and the identity catalog, 0 missing. `sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have overwritten the operator's own lineup with an auto-picked XI -- destructive and not recoverable in place. `--show` still works in every mode; `--force` overrides. Verified end to end against the restored club: /club 29,843,976 coins, /collection 1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated 90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from the snapshot identity store. The fixture path was re-run afterwards and still seeds exactly 14 items, so the SOLD experiment is unaffected.
310 lines
12 KiB
Python
Executable File
310 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Take a READ-ONLY snapshot of the operator's real imported FIFA-17 club.
|
|
|
|
Why this script exists at all
|
|
----------------------------
|
|
The real club -- the 1986-item CAGE import, persona 33068179 -- lives in the
|
|
production state directory `/home/alex/openfut-promotion/state`. That directory is
|
|
listed in FORBIDDEN_PATHS by both staging lifecycle scripts, which refuse to open
|
|
any path underneath it. That guard is deliberate and stays absolute: the staging
|
|
scripts must not be able to reach production state even by accident.
|
|
|
|
So the club cannot enter staging directly. This script is the ONE place allowed to
|
|
read production state, it is read-only by construction, and its only output is a
|
|
snapshot in a directory OUTSIDE the guard. `sold-staging-up.py --club real` then
|
|
installs from the snapshot and never learns where it came from.
|
|
|
|
Read-only by construction
|
|
-------------------------
|
|
* The Core database is opened `mode=ro` and copied with sqlite3's online backup
|
|
API, so the copy is transactionally consistent and the source is never written
|
|
(a plain file copy of a database with a hot WAL can tear).
|
|
* Every destination is asserted to be outside the production state directory
|
|
before anything is opened for writing.
|
|
* The sha256 of every source file is taken before and after the copy and compared.
|
|
A mismatch aborts loudly -- that would mean this script, or something racing it,
|
|
modified production state.
|
|
|
|
The snapshot is a point-in-time artifact, not a live mirror. Re-run it to refresh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from openfut_production import ProductionError, production_state # noqa: E402
|
|
|
|
# --- fixed facts -------------------------------------------------------------------
|
|
|
|
# The production state directory. This script READS these files and writes nothing
|
|
# back. It is the same path both staging scripts refuse outright.
|
|
PROD_STATE = "/home/alex/openfut-promotion/state"
|
|
SRC_CORE_DB = os.path.join(PROD_STATE, "prod-core.db")
|
|
SRC_IDENTITY = os.path.join(PROD_STATE, "prod-identity.json")
|
|
SRC_CLIENTDATA = os.path.join(PROD_STATE, "clientdata.json")
|
|
|
|
DEFAULT_SNAPSHOT_DIR = "/home/alex/openfut-club-snapshot"
|
|
|
|
# Snapshot member names. `sold-staging-up.py` knows these and nothing else.
|
|
SNAP_CORE_DB = "core.db"
|
|
SNAP_IDENTITY = "identity.json"
|
|
SNAP_CLIENTDATA = "clientdata.json"
|
|
SNAP_MANIFEST = "snapshot.json"
|
|
|
|
GAME = "fifa17"
|
|
|
|
|
|
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 the snapshot loudly rather than degrade."""
|
|
|
|
|
|
def sha256(path: str) -> str:
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def assert_outside_production(path: str, what: str) -> str:
|
|
"""Every write target goes through here. The snapshot must never be able to
|
|
land inside the directory this script is reading."""
|
|
resolved = os.path.abspath(path)
|
|
if resolved == PROD_STATE or resolved.startswith(PROD_STATE + os.sep):
|
|
raise Fatal(
|
|
f"REFUSING: {what} {resolved!r} is inside the production state "
|
|
f"directory {PROD_STATE!r} -- this script never writes there"
|
|
)
|
|
return resolved
|
|
|
|
|
|
def read_club_facts(db_path: str) -> dict:
|
|
"""Describe the club in a database WITHOUT modifying it. Used on the source (to
|
|
record what was taken) and on the copy (to prove the copy is faithful)."""
|
|
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
try:
|
|
profiles = conn.execute(
|
|
"SELECT id, username, game_id, import_fingerprint FROM profiles "
|
|
"WHERE game_id = ?", (GAME,)
|
|
).fetchall()
|
|
if len(profiles) != 1:
|
|
raise Fatal(
|
|
f"expected exactly one {GAME} profile in {db_path}, found "
|
|
f"{len(profiles)} -- refusing to snapshot an ambiguous club"
|
|
)
|
|
profile_id, username, game_id, fingerprint = profiles[0]
|
|
club = conn.execute(
|
|
"SELECT id, name, coins FROM clubs WHERE profile_id = ?", (profile_id,)
|
|
).fetchone()
|
|
if club is None:
|
|
raise Fatal(f"{GAME} profile {profile_id} has no club in {db_path}")
|
|
club_id, club_name, coins = club
|
|
owned, distinct = conn.execute(
|
|
"SELECT COUNT(*), COUNT(DISTINCT card_id) FROM owned_cards WHERE club_id = ?",
|
|
(club_id,),
|
|
).fetchone()
|
|
squads = conn.execute(
|
|
"SELECT id, name, formation FROM squads WHERE club_id = ?", (club_id,)
|
|
).fetchall()
|
|
squad_players = conn.execute(
|
|
"SELECT COUNT(*) FROM squad_players sp JOIN squads s ON s.id = sp.squad_id "
|
|
"WHERE s.club_id = ?", (club_id,)
|
|
).fetchone()[0]
|
|
migration = conn.execute(
|
|
"SELECT MAX(version) FROM _sqlx_migrations"
|
|
).fetchone()[0]
|
|
return {
|
|
"profile_id": profile_id,
|
|
"username": username,
|
|
"game_id": game_id,
|
|
"import_fingerprint": fingerprint,
|
|
"club_id": club_id,
|
|
"club_name": club_name,
|
|
"coins": coins,
|
|
"owned_cards": owned,
|
|
"distinct_card_ids": distinct,
|
|
"squads": [
|
|
{"id": s[0], "name": s[1], "formation": s[2]} for s in squads
|
|
],
|
|
"squad_players": squad_players,
|
|
"schema_version": migration,
|
|
}
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def copy_database(src: str, dst: str) -> None:
|
|
"""Online-backup copy. The source is opened read-only, so this cannot write to
|
|
production state even if the backup API wanted to."""
|
|
source = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
|
|
try:
|
|
if os.path.exists(dst):
|
|
os.remove(dst)
|
|
target = sqlite3.connect(dst)
|
|
try:
|
|
source.backup(target)
|
|
finally:
|
|
target.close()
|
|
finally:
|
|
source.close()
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--dir", default=os.environ.get("OPENFUT_CLUB_SNAPSHOT_DIR",
|
|
DEFAULT_SNAPSHOT_DIR),
|
|
help=f"snapshot directory (default: {DEFAULT_SNAPSHOT_DIR})")
|
|
args = ap.parse_args()
|
|
|
|
try:
|
|
banner("SNAPSHOT THE REAL CLUB (read-only on production state)")
|
|
dest = assert_outside_production(args.dir, "snapshot directory")
|
|
step(f"source (read-only): {PROD_STATE}")
|
|
step(f"destination : {dest}")
|
|
|
|
# Production is not involved in a file copy -- its Core runs from a docker
|
|
# volume, not from this directory -- but proving the freeze held across the
|
|
# operation costs nothing and keeps the evidence uniform with the other
|
|
# lifecycle scripts.
|
|
before_state = production_state()
|
|
ok("production before: " + ", ".join(before_state.describe()))
|
|
|
|
missing = [p for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)
|
|
if not os.path.isfile(p)]
|
|
if missing:
|
|
raise Fatal("missing production artifact(s):\n " + "\n ".join(missing))
|
|
|
|
before = {p: sha256(p) for p in (SRC_CORE_DB, SRC_IDENTITY, SRC_CLIENTDATA)}
|
|
for path, digest in before.items():
|
|
step(f"{os.path.basename(path):20s} {os.path.getsize(path):>9,d} B "
|
|
f"sha256 {digest[:16]}")
|
|
|
|
facts = read_club_facts(SRC_CORE_DB)
|
|
ok(
|
|
f"club to snapshot: {facts['username']} ({facts['club_name']}) "
|
|
f"{facts['owned_cards']} items, {facts['distinct_card_ids']} distinct "
|
|
f"card ids, {facts['coins']:,} coins, schema v{facts['schema_version']}"
|
|
)
|
|
|
|
banner("COPY")
|
|
os.makedirs(dest, exist_ok=True)
|
|
snap_db = assert_outside_production(os.path.join(dest, SNAP_CORE_DB), "core db")
|
|
copy_database(SRC_CORE_DB, snap_db)
|
|
ok(f"{SNAP_CORE_DB} written by sqlite online backup (consistent copy)")
|
|
|
|
for src, name in ((SRC_IDENTITY, SNAP_IDENTITY),
|
|
(SRC_CLIENTDATA, SNAP_CLIENTDATA)):
|
|
dst = assert_outside_production(os.path.join(dest, name), name)
|
|
shutil.copy2(src, dst)
|
|
# The source is root-owned; the copy must be writable by the staging user
|
|
# that installs it.
|
|
os.chmod(dst, 0o644)
|
|
ok(f"{SNAP_IDENTITY} and {SNAP_CLIENTDATA} copied")
|
|
|
|
banner("PROVE THE COPY IS FAITHFUL AND THE SOURCE IS UNTOUCHED")
|
|
after = {p: sha256(p) for p in before}
|
|
changed = [os.path.basename(p) for p in before if before[p] != after[p]]
|
|
if changed:
|
|
raise Fatal(
|
|
"PRODUCTION STATE WAS MODIFIED during the snapshot: "
|
|
f"{changed} -- this must never happen"
|
|
)
|
|
ok("every source file byte-identical before and after (sha256)")
|
|
|
|
copy_facts = read_club_facts(snap_db)
|
|
differences = {
|
|
key: (facts[key], copy_facts[key])
|
|
for key in facts
|
|
if facts[key] != copy_facts[key]
|
|
}
|
|
if differences:
|
|
raise Fatal(f"snapshot does not match the source: {differences}")
|
|
ok(
|
|
f"snapshot matches source exactly: {copy_facts['owned_cards']} owned "
|
|
f"cards, {copy_facts['coins']:,} coins, "
|
|
f"{copy_facts['squad_players']} squad players"
|
|
)
|
|
|
|
identity = json.load(open(os.path.join(dest, SNAP_IDENTITY)))
|
|
rows = identity.get("rows", [])
|
|
owned_rows = [r for r in rows if r.get("entity_kind") == "owned-item"]
|
|
conn = sqlite3.connect(f"file:{snap_db}?mode=ro", uri=True)
|
|
try:
|
|
owned_ids = {r[0] for r in conn.execute("SELECT id FROM owned_cards")}
|
|
finally:
|
|
conn.close()
|
|
unmapped = owned_ids - {r["core_id"] for r in owned_rows}
|
|
if unmapped:
|
|
raise Fatal(
|
|
f"{len(unmapped)} owned cards have no wire id in the identity store; "
|
|
"the client would see them appear under freshly minted ids and its "
|
|
f"cached squad would break. Sample: {sorted(unmapped)[:5]}"
|
|
)
|
|
ok(
|
|
f"identity store maps all {len(owned_ids)} owned cards to stable wire "
|
|
f"ids ({len(owned_rows)} rows, watermarks {identity.get('watermarks')})"
|
|
)
|
|
|
|
manifest = {
|
|
"taken_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
"source_dir": PROD_STATE,
|
|
"sources": {
|
|
os.path.basename(p): {"sha256": before[p], "bytes": os.path.getsize(p)}
|
|
for p in before
|
|
},
|
|
"members": {
|
|
"core_db": SNAP_CORE_DB,
|
|
"identity": SNAP_IDENTITY,
|
|
"clientdata": SNAP_CLIENTDATA,
|
|
},
|
|
"club": copy_facts,
|
|
"identity_rows": len(rows),
|
|
"watermarks": identity.get("watermarks"),
|
|
}
|
|
with open(os.path.join(dest, SNAP_MANIFEST), "w") as fh:
|
|
json.dump(manifest, fh, indent=2)
|
|
fh.write("\n")
|
|
ok(f"manifest written: {os.path.join(dest, SNAP_MANIFEST)}")
|
|
|
|
after_state = production_state()
|
|
ok("production after : " + ", ".join(after_state.describe()))
|
|
|
|
banner("SNAPSHOT READY")
|
|
print(f" {dest}")
|
|
print()
|
|
print(" Install it into staging with:")
|
|
print(" python3 scripts/sold-staging-up.py --club real "
|
|
"--variant highest \\")
|
|
print(" --roster-host winter15.gosredirector.ea.com:8081")
|
|
return 0
|
|
except ProductionError as exc:
|
|
print(f"\nFATAL: {exc}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|