From 3442eac6f0e9225be5f4fdb1d7bcfb5a1fd278f4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 04:10:02 +0000 Subject: [PATCH] fix(fifa17): complete kit stats, restore red squad tests, unrot prod gate Four defects found by running the suites and the staging lifecycle end to end after the kit milestone. 1. club-stats kits were half-implemented. The global `kits` counter was real but `kitsHome`/`kitsAway` and every per-team `kits` bucket stayed hardcoded 0, so the same screen reported two owned kits and zero home/away kits. `kits` is a total with a family split, exactly like players/playersGold and staff/staffManager. The split key is `fcc_kitcards.assetid`: 14 is the home family and 15 the away family, verified across all 1482 rows of the kit table (assetid 14 covers exactly the 63xxxxx carddbids, 828 rows; assetid 15 exactly the 64xxxxx ones, 654 rows; no exceptions either way). ClubStatInput now carries `asset_id`, and a kit buckets onto the team that wears it -- including a team the club owns no player from, the normal case for a kit won from a pack. The host reads both from the catalog through new NON-MINTING accessors: `resolve`/`resolve_kit` allocate a wire id, which a read-only stats query must never do as a side effect. 2. host_test.rs had 10 tests red since the squad-manager work (25f4ad1 / d37a9d5); 56bd9dd updated the squad_projection integration test and stopped there. `put_body` hardcoded the captured manager ref 100000427 into EVERY save, including tests with no manager fixture, so each one was refused with `unresolved_wire_ids` -- the tests were reporting a real invariant against a fixture that could not satisfy it. The manager is now an explicit `Option` per test, and FakeCore models Core's manager persistence instead of inheriting the "not implemented" default that 502'd every save. Added the coverage whose absence let this rot: a manager assignment round-trips as a Core owned id, a later save without one CLEARS it, and an unowned manager ref refuses the whole save with nothing committed. 3. `club_route_maps_query_and_shapes_core_items` pinned `offset`/`limit` forwarding to Core, which the kit commit deliberately replaced with host-side pagination. It only ever passed because FakeCore ignored the window -- against a real Core, `start=10` over a one-item club was always an empty page. Retargeted to the real contract (Core gets semantic filters and NO window) plus a new test that the window is applied locally after filtering, which the old fake made vacuous. 4. The staging lifecycle scripts identified production by hardcoded pids, so a correct teardown FATAL'd: production moved into containers and pids 3631953/3374264 died with a container restart days ago. A pinned pid rots into the worst of both worlds -- a kill-refusal gate that no longer names any real production process, and a liveness gate that fails a healthy teardown. New shared `scripts/openfut_production.py` resolves production pids AND published ports from the container runtime at the moment they are needed, refuses to signal anything it cannot see, and proves production is the same processes serving the same ports before and after. Both lifecycle scripts use it, which also closed a real gap: port 8085 is published by openfut-fut-backend but was missing from the up script's forbidden list, so staging could have bound a production port. Also fixes the economy differential, red because `complete_match` unlocks achievements in the same transaction that pays the match reward -- a deliberate Core feature the Python oracle has no counterpart for. `rust WIN +400` asserted that progression did not exist; it now asserts the delta is the 400 match reward plus exactly the achievements the match unlocked, read from Core's own report. --- openfut-adapter-fifa17/src/fut/club_stats.rs | 124 ++++++++++-- openfut-utas-host/src/lib.rs | 28 ++- .../tests/economy_differential.rs | 45 ++++- openfut-utas-host/tests/host_test.rs | 182 +++++++++++++++++- scripts/openfut_production.py | 143 ++++++++++++++ scripts/sold-staging-down.py | 77 +++----- scripts/sold-staging-up.py | 76 ++++---- 7 files changed, 568 insertions(+), 107 deletions(-) create mode 100644 scripts/openfut_production.py diff --git a/openfut-adapter-fifa17/src/fut/club_stats.rs b/openfut-adapter-fifa17/src/fut/club_stats.rs index a9f8cb4..2141882 100644 --- a/openfut-adapter-fifa17/src/fut/club_stats.rs +++ b/openfut-adapter-fifa17/src/fut/club_stats.rs @@ -24,14 +24,18 @@ use serde_json::{json, Value}; use crate::fut::content_taxonomy::{consumable_family, ContentKind}; /// One owned item, already classified from the catalog + entity tables by the -/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/ -/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped). +/// host. `subtype`/`rare`/`asset_id` come from the FIFA catalog; `nation_id`/ +/// `league_id`/`team_id` from the reverse entity resolver for players and from +/// the kit table for kits (None = unresolved, bucket skipped). #[derive(Debug, Clone)] pub struct ClubStatInput { pub kind: ContentKind, pub subtype: i64, pub rating: i64, pub rare: bool, + /// Base FIFA asset id. For a kit this is the `fcc_kitcards.assetid` family + /// discriminator, which is what splits the home/away kit counters. + pub asset_id: i64, pub nation_id: Option, pub league_id: Option, pub team_id: Option, @@ -58,8 +62,17 @@ const S_RARE: i64 = 0x05; const S_STAFF: i64 = 0x0A; const S_CONSUMABLES: i64 = 0x3C; const S_KITS: i64 = 0x28; +const S_KITS_HOME: i64 = 0x29; +const S_KITS_AWAY: i64 = 0x2A; const S_BADGES: i64 = 0x2D; +/// `fcc_kitcards.assetid` splits the kit table into the home and away families. +/// Verified across all 1482 rows of the FIFA 17 kit table: assetid 14 covers +/// exactly the `63xxxxx` carddbids (828 rows) and assetid 15 exactly the +/// `64xxxxx` ones (654 rows), with no exceptions in either direction. +const KIT_ASSET_HOME: i64 = 14; +const KIT_ASSET_AWAY: i64 = 15; + /// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT). fn staff_stat(subtype: i64) -> Option { match subtype { @@ -216,20 +229,22 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { } g.insert(S_CONSUMABLES, cons_total); - // Club items: kits are Core-owned and counted; unimplemented families stay - // honest zeros. + // Club items: kits are Core-owned and counted (total plus the home/away + // family split); unimplemented families stay honest zeros. for sid in [ - 0x14, 0x1E, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x14, 0x1E, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, ] { g.entry(sid).or_insert(0); } - g.insert( - S_KITS, - items - .iter() - .filter(|item| matches!(item.kind, ContentKind::Kit)) - .count() as i64, - ); + let kits: Vec<&ClubStatInput> = items + .iter() + .filter(|item| matches!(item.kind, ContentKind::Kit)) + .collect(); + let kits_with_asset = + |asset: i64| kits.iter().filter(|item| item.asset_id == asset).count() as i64; + g.insert(S_KITS, kits.len() as i64); + g.insert(S_KITS_HOME, kits_with_asset(KIT_ASSET_HOME)); + g.insert(S_KITS_AWAY, kits_with_asset(KIT_ASSET_AWAY)); let mut stat: Vec = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect(); @@ -247,10 +262,30 @@ pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value { by_ctx.entry(id).or_default().push(p); } } + + // A kit belongs to the team that wears it and has no nation/league of its + // own, so it only buckets on the team screen -- and it buckets there even if + // the club owns no player from that team, which is the normal case for a kit + // won from a pack. + let mut kits_by_team: BTreeMap = BTreeMap::new(); + if ctx == ContextField::Team { + for kit in &kits { + if let Some(id) = kit.team_id { + *kits_by_team.entry(id).or_insert(0) += 1; + by_ctx.entry(id).or_default(); + } + } + } + for (cid, sel) in &by_ctx { if ctx == ContextField::Team { stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64)); - stat.push(row(3, *cid, S_KITS, 0)); + stat.push(row( + 3, + *cid, + S_KITS, + kits_by_team.get(cid).copied().unwrap_or(0), + )); stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid } else { let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64; @@ -279,6 +314,7 @@ mod tests { subtype: 0, rating, rare, + asset_id: 158023, nation_id: nation, league_id: None, team_id: None, @@ -290,6 +326,7 @@ mod tests { subtype, rating: 0, rare: false, + asset_id: 0, nation_id: None, league_id: None, team_id: None, @@ -301,22 +338,29 @@ mod tests { subtype, rating: 0, rare: false, + asset_id: 0, nation_id: None, league_id: None, team_id: None, } } - fn kit() -> ClubStatInput { + /// A kit of the given family (`KIT_ASSET_HOME` / `KIT_ASSET_AWAY`) worn by + /// `team`. + fn kit_of(asset_id: i64, team: i64) -> ClubStatInput { ClubStatInput { kind: ContentKind::Kit, subtype: 9, rating: 0, rare: false, + asset_id, nation_id: None, league_id: None, - team_id: Some(21), + team_id: Some(team), } } + fn kit() -> ClubStatInput { + kit_of(KIT_ASSET_HOME, 21) + } fn global(body: &Value) -> std::collections::HashMap { body["stat"] @@ -383,6 +427,56 @@ mod tests { assert_eq!(g["kits"], 2); } + /// `kits` is the total and `kitsHome`/`kitsAway` are its family split, the + /// same total/subset shape as players/playersGold and staff/staffManager. + #[test] + fn kit_counts_split_by_home_and_away_family() { + let items = vec![ + kit_of(KIT_ASSET_HOME, 21), + kit_of(KIT_ASSET_HOME, 38), + kit_of(KIT_ASSET_AWAY, 21), + ]; + let g = global(&club_stats_body(&items, ContextField::Nation)); + assert_eq!(g["kits"], 3); + assert_eq!(g["kitsHome"], 2); + assert_eq!(g["kitsAway"], 1); + } + + /// A kit buckets onto the team that wears it -- including a team the club + /// owns no player from, which is the normal case for a kit won from a pack. + #[test] + fn kits_bucket_onto_their_own_team_on_the_team_screen() { + let mut with_team = player(90, false, None); + with_team.team_id = Some(21); + let items = vec![ + with_team, + kit_of(KIT_ASSET_HOME, 21), + kit_of(KIT_ASSET_AWAY, 21), + kit_of(KIT_ASSET_HOME, 38), + ]; + let body = club_stats_body(&items, ContextField::Team); + let kits_for = |team: i64| { + body["stat"] + .as_array() + .unwrap() + .iter() + .find(|r| r["contextId"] == 3 && r["contextValue"] == team && r["type"] == "kits") + .map(|r| r["typeValue"].as_i64().unwrap()) + }; + assert_eq!(kits_for(21), Some(2)); + // Team 38 has no players, so only the kit creates its bucket. + assert_eq!(kits_for(38), Some(1)); + + // A nation/league screen has no team context, so kits stay out of it. + let nation = club_stats_body(&items, ContextField::Nation); + assert!(nation["stat"] + .as_array() + .unwrap() + .iter() + .filter(|r| r["contextId"] == 3 && r["type"] == "kits") + .all(|r| r["typeValue"] == 0)); + } + #[test] fn nation_buckets_emitted_and_players_excludes_nonplayers() { let items = vec![ diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index 59ab3e2..0e7d8e6 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -1640,6 +1640,26 @@ impl Fifa17IdentityResolver { .unwrap_or(0) } + /// The base FIFA `assetId` for an owned item's definition (0 if unknown), + /// from the catalog. Non-minting: club-stats must not allocate a wire id as a + /// side effect of counting, which `resolve`/`resolve_kit` would do. + pub fn asset_id_of(&self, item: &CoreOwnedItem) -> i64 { + self.catalog + .lookup(&item.card_id) + .map(|c| c.asset_id as i64) + .unwrap_or(0) + } + + /// The FIFA `teamid` carried by a KIT definition, from the catalog. `None` + /// for every other content kind, whose team affiliation is the owning + /// player's club and comes from the entity tables instead. + pub fn kit_team_id_of(&self, item: &CoreOwnedItem) -> Option { + self.catalog + .lookup(&item.card_id) + .filter(|c| c.kind == ContentKind::Kit) + .map(|c| c.team_id) + } + /// Non-minting definition identity from the catalog: `Some((rareflag, kind))` /// if the card resolves, else `None`. Used to build the pack pool over ALL /// content definitions WITHOUT allocating a wire id per definition (that would @@ -4047,9 +4067,15 @@ impl Server { subtype: self.resolver.subtype_of(it), rating: it.rating as i64, rare: self.resolver.rareflag_of(it) != 0, + asset_id: self.resolver.asset_id_of(it), nation_id: self.entities.nation_id(&it.nation).map(|n| n as i64), league_id: self.entities.league_id(&it.league).map(|n| n as i64), - team_id: self.entities.team_id(&it.club).map(|n| n as i64), + // A kit carries its own team in the kit table; every other kind + // inherits the owning player's club from the entity tables. + team_id: self + .resolver + .kit_team_id_of(it) + .or_else(|| self.entities.team_id(&it.club).map(|n| n as i64)), }) .collect(); let players = items diff --git a/openfut-utas-host/tests/economy_differential.rs b/openfut-utas-host/tests/economy_differential.rs index 705b724..141f8c1 100644 --- a/openfut-utas-host/tests/economy_differential.rs +++ b/openfut-utas-host/tests/economy_differential.rs @@ -432,6 +432,36 @@ fn json_shape(value: &Value) -> Value { } } +/// Coins Core has granted through ACHIEVEMENT unlocks so far, summed from Core's +/// own report. +/// +/// Core's progression system (objectives + achievements) has no counterpart in +/// the Python oracle, and `complete_match` unlocks achievements in the SAME +/// transaction that pays the match reward. So a raw balance delta around a match +/// is `match reward + newly unlocked achievements`, and the differential has to +/// account for the second term instead of pretending it does not exist. +/// +/// NOTE: `GET /achievements` is unlock-on-read (it calls `check_and_unlock`), so +/// this snapshot must be taken BEFORE the pre-op balance is captured; that flushes +/// any already-satisfied unlock and leaves the measured window attributable to the +/// op under test. +fn achievement_coins_granted(http: &reqwest::blocking::Client, core_base: &str) -> i64 { + let body: Value = http + .get(format!("{core_base}/achievements")) + .header("X-OpenFUT-Game", "fifa17") + .send() + .expect("GET /achievements") + .json() + .expect("achievements json"); + body["achievements"] + .as_array() + .expect("achievements array") + .iter() + .filter(|a| a["unlocked"].as_bool().unwrap_or(false)) + .map(|a| a["reward_coins"].as_i64().unwrap_or(0)) + .sum() +} + /// Every op runs on a plain OS thread with NO ambient Tokio runtime (the blocking /// Core client + `reqwest::blocking` require this), exactly like the /// thread-per-connection server — the bridge takes its direct `block_on` path. @@ -832,6 +862,11 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { matrix.push(("move-items PUT /item", "PARITY")); // ── OP 10: match reward (WIN = +400) ─────────────────────────────────── + // The oracle pays the match reward and nothing else. Core pays the same match + // reward and, in the same transaction, any achievement the match unlocks — a + // deliberate Rust-authority feature the oracle never had. Flush pending + // unlocks first so the measured window belongs to this match. + let r_ach_before = achievement_coins_granted(&http, core_base); let o_mbal = oracle.coins(); let (o_mms, o_mm) = oracle.req( "POST", @@ -889,7 +924,15 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { ); } assert_eq!(oracle.coins() - o_mbal, 400, "oracle WIN +400"); - assert_eq!(client.balance().unwrap() - r_mbal, 400, "rust WIN +400"); + // Same match reward on both sides; Core additionally credits exactly the + // achievements this match unlocked, and nothing unexplained. + let r_ach_awarded = achievement_coins_granted(&http, core_base) - r_ach_before; + assert_eq!( + client.balance().unwrap() - r_mbal, + 400 + r_ach_awarded, + "rust WIN credits +400 plus exactly the achievements it unlocked \ + (achievement coins = {r_ach_awarded})" + ); matrix.push(("match reward (WIN)", "PARITY")); // ── OP 11: market list (POST /auctionhouse) ──────────────────────────── diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index fb87b5b..506bd64 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -47,6 +47,9 @@ struct FakeCore { /// The stored squad + extension returned by `read_squad_ext`. A successful /// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write. squad: Mutex>, + /// The ownership-backed manager assignment, exactly as Core persists it: a + /// full squad replacement writes it (or clears it with `None`). + manager: Mutex>, replaced: Mutex>, panic_if_called: bool, return_err: bool, @@ -92,6 +95,9 @@ impl FakeCore { fn last(&self) -> Vec<(String, String)> { self.last_params.lock().clone() } + fn manager(&self) -> Option { + self.manager.lock().clone() + } } impl CoreAccess for FakeCore { @@ -184,6 +190,21 @@ impl CoreAccess for FakeCore { slots_written: req.slots.len(), }) } + + fn get_squad_manager(&self) -> Result, CoreError> { + if self.return_err { + return Err(CoreError::Status(500)); + } + Ok(self.manager.lock().clone()) + } + + fn set_squad_manager(&self, owned_card_id: Option<&str>) -> Result<(), CoreError> { + if self.return_err { + return Err(CoreError::Status(500)); + } + *self.manager.lock() = owned_card_id.map(str::to_string); + Ok(()) + } } fn entities() -> Fifa17Entities { @@ -472,7 +493,7 @@ fn club_route_maps_query_and_shapes_core_items() { let resp = server.handle( "GET", - "/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10", + "/ut/game/fifa17/club?year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=0", &[], b"", ); @@ -502,8 +523,14 @@ fn club_route_maps_query_and_shapes_core_items() { p.contains(&("club".into(), "Chelsea".into())), "team id 5 -> club name {p:?}" ); - assert!(p.contains(&("offset".into(), "10".into()))); - assert!(p.contains(&("limit".into(), "11".into()))); + // The host must NOT ask Core for a window. Kind and transfer-pile membership + // are host-side concepts Core cannot express, so the visible set only exists + // after local filtering and shaping; a Core-side window would paginate the + // unfiltered set and hand the client short pages. + assert!( + !p.iter().any(|(k, _)| k == "offset" || k == "limit"), + "pagination must not be delegated to Core {p:?}" + ); // No raw FIFA id reached Core. for (_, v) in &p { if v == "13" || v == "5" || v == "52" { @@ -512,6 +539,55 @@ fn club_route_maps_query_and_shapes_core_items() { } } +/// The client's `start`/`count` window is applied by the host, over the set that +/// survives kind + transfer-pile filtering. A window past the end is an empty +/// page, not the first item. +#[test] +fn club_window_is_applied_locally_after_filtering() { + let core = Arc::new(FakeCore::new( + vec![item( + "oc1", + "card_ch_1", + 86, + "CDM", + "Argentina", + "Premier League", + "Chelsea", + )], + 1, + )); + let server = build_server( + core.clone(), + "http://127.0.0.1:1", + Some(HashMap::from([("card_ch_1".to_string(), 20801u32)])), + ); + + let in_window = server.handle( + "GET", + "/ut/game/fifa17/club?type=player&start=0&count=11", + &[], + b"", + ); + let body: Value = serde_json::from_slice(&in_window.body).unwrap(); + assert_eq!(body["itemData"].as_array().unwrap().len(), 1); + + let past_end = server.handle( + "GET", + "/ut/game/fifa17/club?type=player&start=10&count=11", + &[], + b"", + ); + assert_eq!( + past_end.status, 200, + "a window past the end is still a page" + ); + let body: Value = serde_json::from_slice(&past_end.body).unwrap(); + assert!( + body["itemData"].as_array().unwrap().is_empty(), + "offset past the visible set yields an empty page, not the first item" + ); +} + #[test] fn club_route_with_empty_asset_map_drops_items_not_fakes_them() { // The current production reality: no card→asset mapping → empty itemData. @@ -888,11 +964,26 @@ fn resolver_with_wires( } /// A FIFA squad-save body. `players` = (index, wire id, kit number). -fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str) -> Vec { +/// +/// `manager` is explicit because a squad save carries an ownership-backed +/// manager assignment: a ref the resolver cannot map to an owned instance is an +/// unresolved wire id and the whole save is refused (you cannot manage with a +/// card you do not own). Tests that are not about the manager pass `None`. +fn put_body( + formation: &str, + captain: i64, + players: &[(i64, i64, i64)], + custom: &str, + manager: Option, +) -> Vec { let ps: Vec = players .iter() .map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k})) .collect(); + let mgr: Vec = manager + .into_iter() + .map(|id| json!({"id": id, "dream": false})) + .collect(); json!({ "id": 0, "formation": formation, @@ -903,7 +994,7 @@ fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: "starRating": 90, "captain": captain, "custom": custom, - "manager": [{"id": 100000427, "dream": false}], + "manager": mgr, "players": ps, "kicktakers": [{"index": 0, "id": captain, "dream": false}], }) @@ -1032,6 +1123,7 @@ fn numbered_squad_get_returns_current_core_squad_without_python() { wires["oc-a"], &[(0, wires["oc-a"], 1), (1, wires["oc-b"], 9)], "[1,2,3]", + None, ), ); assert_eq!(put.status, 200); @@ -1061,6 +1153,7 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", + None, ); let (resp, log) = handle_put_squad(&body, &deps); @@ -1083,6 +1176,75 @@ fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() { assert_eq!(r.chemistry, Some(52), "client-reported shadow carried"); } +/// The squad's manager is an ownership-backed assignment, not an opaque wire +/// echo: a save assigns the owned instance behind the ref, and a later save +/// without a manager CLEARS it (a full replacement replaces the manager too). +#[test] +fn put_assigns_the_owned_manager_and_a_later_save_clears_it() { + let items = vec![gk(), st()]; + let (resolver, w) = resolver_with_wires(&items, ASSETS); + let core = FakeCore::new(items.clone(), 2); + let ent = entities(); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + + let with_manager = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1)], + "[]", + Some(w["oc-b"]), + ); + let (resp, log) = handle_put_squad(&with_manager, &deps); + assert_eq!(resp.status, 200, "{log:?}"); + assert_eq!( + core.manager().as_deref(), + Some("oc-b"), + "manager persisted as the Core owned id, never the wire id" + ); + + let without_manager = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None); + let (resp, log) = handle_put_squad(&without_manager, &deps); + assert_eq!(resp.status, 200, "{log:?}"); + assert_eq!( + core.manager(), + None, + "a full replacement without a manager clears the assignment" + ); +} + +/// A manager ref the resolver cannot map is an unresolved wire id: the WHOLE +/// save is refused and nothing is committed. You cannot manage with a card you +/// do not own, and a save must never silently drop the assignment instead. +#[test] +fn put_refuses_a_manager_ref_that_is_not_an_owned_instance() { + let items = vec![gk(), st()]; + let (resolver, w) = resolver_with_wires(&items, ASSETS); + let core = FakeCore::new(items.clone(), 2); + let ent = entities(); + let deps = SquadDeps { + core: &core, + resolver: &resolver, + entities: &ent, + }; + + let body = put_body( + "f442", + w["oc-a"], + &[(0, w["oc-a"], 1)], + "[]", + Some(100_000_427), + ); + let (resp, log) = handle_put_squad(&body, &deps); + assert_eq!(resp.status, 400); + assert_eq!(log.outcome, "unresolved_wire_ids"); + assert_eq!(core.replace_calls(), 0, "nothing committed"); + assert_eq!(core.manager(), None, "no manager assigned"); +} + #[test] fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() { // oc-b resolves (identity) but is NOT in the active club's owned set. @@ -1100,6 +1262,7 @@ fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]", + None, ); let (resp, log) = handle_put_squad(&body, &deps); @@ -1126,6 +1289,7 @@ fn put_rejects_unknown_wire_id() { w["oc-a"], &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], "[]", + None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); @@ -1149,6 +1313,7 @@ fn put_rejects_duplicate_owned_item() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-a"], 9)], "[]", + None, ); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 400); @@ -1168,7 +1333,7 @@ fn put_core_failure_returns_error_never_python() { resolver: &resolver, entities: &ent, }; - let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]"); + let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]", None); let (resp, log) = handle_put_squad(&body, &deps); assert_eq!(resp.status, 502); assert_eq!(log.outcome, "core_error"); @@ -1190,6 +1355,7 @@ fn repeated_identical_put_is_idempotent() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", + None, ); let (r1, _) = handle_put_squad(&body, &deps); let (r2, _) = handle_put_squad(&body, &deps); @@ -1225,6 +1391,7 @@ fn coupled_read_after_write_list_and_usermassinfo_agree() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", + None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); @@ -1330,6 +1497,7 @@ fn squad_active_serves_core_backed_object_with_configured_persona() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]", + None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); @@ -1375,6 +1543,7 @@ fn read_path_is_bounded_no_per_slot_lookup() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]", + None, ); handle_put_squad(&body, &deps); let reads_before = core.read_calls(); @@ -1544,6 +1713,7 @@ fn duplicate_definition_instances_stay_distinct_through_host() { w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-c"], 7)], "[]", + None, ); let (put, _) = handle_put_squad(&body, &deps); assert_eq!(put.status, 200); diff --git a/scripts/openfut_production.py b/scripts/openfut_production.py new file mode 100644 index 0000000..dafef8e --- /dev/null +++ b/scripts/openfut_production.py @@ -0,0 +1,143 @@ +"""Runtime identity of the PRODUCTION FIFA-17 stack, shared by the staging +lifecycle scripts. + +This exists because both `sold-staging-up.py` and `sold-staging-down.py` need the +same answer to the same safety question -- "what is production right now, and is +it still healthy?" -- and two hand-maintained copies of a safety gate is two +chances to rot. + +Production runs in containers, so its pids are NOT stable facts: every pid changes +when a container is restarted. A hardcoded pid list decays into the worst of both +worlds -- a kill-refusal gate that guards nothing (the real production pids are no +longer in it) and a liveness gate that fails a perfectly good teardown (the pids it +does list are long dead). So pids and published ports are both resolved from the +container runtime at the moment they are needed. +""" + +from __future__ import annotations + +import subprocess + +# Production containers. Anything running inside one of these is production. +PROD_CONTAINERS = ("openfut-fut-backend", "openfut-bridge-1", "openfut-core-1") + +# Reserved ports: staging may never bind one of these, whether or not it is +# currently published. 8199 (Python oracle) and 18080 (Core) belonged to the +# retired host-process deployment and are kept so an old port map cannot be +# silently reused by staging. +PROD_PORTS = frozenset( + {8080, 8081, 8085, 8094, 8099, 8199, 8443, 4216, 18080, 42127, 42130, 42131} +) + + +class ProductionError(RuntimeError): + """Production could not be observed, or is not healthy.""" + + +def _inspect(container: str, template: str) -> str: + """One `docker inspect -f` field. + + Any failure is fatal by design: a script that cannot see production must + refuse to signal anything rather than assume the best. + """ + try: + result = subprocess.run( + ["docker", "inspect", "-f", template, container], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ProductionError( + f"cannot inspect production container {container!r}: {exc}" + ) from exc + if result.returncode != 0: + detail = result.stderr.strip() or f"docker exited {result.returncode}" + raise ProductionError( + f"cannot inspect production container {container!r}: {detail}" + ) + return result.stdout.strip() + + +def listening_ports() -> set[int]: + """Ports in state LISTEN, from the kernel socket table. + + A trial bind() would report EADDRINUSE for a stopped server's TIME_WAIT + sockets and so wrongly claim a port is still served. + """ + 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 + + +class ProductionState: + """A snapshot of production as the container runtime reports it.""" + + __slots__ = ("pids", "published") + + def __init__(self, pids: dict[int, str], published: dict[int, str]) -> None: + self.pids = pids + self.published = published + + def describe(self) -> list[str]: + return [f"{what} pid {pid}" for pid, what in sorted(self.pids.items())] + + def assert_serving(self) -> None: + """Every port production publishes must actually be listening.""" + listening = listening_ports() + silent = sorted(port for port in self.published if port not in listening) + if silent: + raise ProductionError( + "production port(s) no longer listening: " + + ", ".join(f"{port} ({self.published[port]})" for port in silent) + ) + + def assert_unchanged(self, before: "ProductionState") -> None: + """Production must be the same processes serving the same ports.""" + if self.pids != before.pids: + raise ProductionError( + f"production pids CHANGED: before={before.pids}, after={self.pids}" + ) + if set(self.published) != set(before.published): + raise ProductionError( + "production published ports CHANGED: " + f"before={sorted(before.published)}, after={sorted(self.published)}" + ) + + +def production_state() -> ProductionState: + """Resolve production's current pids and published ports, proving every + production container is running.""" + pids: dict[int, str] = {} + published: dict[int, str] = {} + for container in PROD_CONTAINERS: + status = _inspect(container, "{{.State.Status}}") + if status != "running": + raise ProductionError( + f"production container {container} is {status!r}, not running" + ) + pid = int(_inspect(container, "{{.State.Pid}}") or 0) + if pid <= 0: + raise ProductionError( + f"production container {container} is running but reports no pid" + ) + pids[pid] = f"prod {container}" + ports = _inspect( + container, + "{{range $port, $bindings := .NetworkSettings.Ports}}" + "{{range $bindings}}{{.HostPort}} {{end}}{{end}}", + ) + for field in ports.split(): + published[int(field)] = container + return ProductionState(pids, published) diff --git a/scripts/sold-staging-down.py b/scripts/sold-staging-down.py index 8a6d9ad..aa5e855 100755 --- a/scripts/sold-staging-down.py +++ b/scripts/sold-staging-down.py @@ -10,10 +10,12 @@ ones. So there is no pattern matching here at all: * before any signal, /proc//cmdline is read and MUST contain the staging directory -- production's cmdline never can, because staging runs binaries copied into that directory; - * the known production pids are refused explicitly, as a second gate; + * production's CURRENT pids, resolved from the container runtime, are refused + explicitly as a second gate; * only the process GROUP the up script created (pgid == pid, via start_new_session) is signalled, so a responder thread/child cannot be orphaned; - * afterwards every staging port is proven free and production is proven alive. + * afterwards every staging port is proven free, and production is proven to be + the same running containers serving the same ports as before. python3 scripts/sold-staging-down.py python3 scripts/sold-staging-down.py --purge # also delete the staging dir @@ -29,20 +31,20 @@ import signal import sys import time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from openfut_production import ( # noqa: E402 + PROD_PORTS, + ProductionError, + listening_ports, + production_state, +) + DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging" FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",) -# Production processes that MUST be alive before and after this script runs. These -# two are the ones the batch contract names, and they live in the host pid view. -PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"} -# Reported but not gated: container pids change when the operator restarts the -# container, and a stale entry here would turn a successful teardown into a FATAL. -PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle", - 2090888: "prod pow"} -PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094) -class Fatal(RuntimeError): +class Fatal(ProductionError): pass @@ -87,37 +89,17 @@ def cmdline_of(pid: int) -> str: return "" -def listening_ports() -> set[int]: - """Ports in state LISTEN, from the kernel socket table. A trial bind() would - report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the - teardown failed.""" - 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 stop_one(rec: dict, staging_dir: str) -> str: +def stop_one(rec: dict, staging_dir: str, prod_pids: dict[int, str]) -> str: """Stop exactly one recorded process. Returns a human-readable outcome.""" name, pid = rec["name"], int(rec["pid"]) - known_prod = {**PROD_PIDS, **PROD_PIDS_INFO} - if pid in known_prod: + if pid in prod_pids: raise Fatal( - f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). " + f"manifest entry {name} names PRODUCTION pid {pid} ({prod_pids[pid]}). " "REFUSING to signal anything from this manifest." ) if not pid_alive(pid): @@ -192,8 +174,14 @@ def main() -> int: ) step(f"variant : {manifest.get('variant')}") + # Resolved BEFORE anything is signalled: the refusal gate below is only + # meaningful if it knows production's pids as they are right now. + before = production_state() + for line in before.describe(): + step(f"production : {line}") + for rec in manifest.get("processes", []): - ok(stop_one(rec, staging_dir)) + ok(stop_one(rec, staging_dir, before.pids)) banner("PROVE STAGING IS GONE") ports = manifest.get("ports", {}) @@ -214,16 +202,13 @@ def main() -> int: ok("no recorded staging process is alive") banner("PROVE PRODUCTION IS STILL UP") - dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items() - if not pid_alive(pid)] - for pid, what in PROD_PIDS.items(): - if pid_alive(pid): - ok(f"{what} pid {pid} alive") - if dead: - raise Fatal("production process(es) NOT alive: " + ", ".join(dead)) - for pid, what in PROD_PIDS_INFO.items(): - state = "alive" if pid_alive(pid) else "not found (informational only)" - step(f"{what} pid {pid} {state}") + after = production_state() + for line in after.describe(): + ok(f"{line} alive") + after.assert_unchanged(before) + after.assert_serving() + ok(f"all {len(after.published)} published production ports still listening: " + + ", ".join(str(port) for port in sorted(after.published))) if args.purge: shutil.rmtree(safe_path(staging_dir), ignore_errors=True) @@ -243,7 +228,7 @@ def main() -> int: print() print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.") return 0 - except Fatal as exc: + except ProductionError as exc: print(f"\nFATAL: {exc}", file=sys.stderr) return 1 diff --git a/scripts/sold-staging-up.py b/scripts/sold-staging-up.py index cf1818b..b6983b0 100755 --- a/scripts/sold-staging-up.py +++ b/scripts/sold-staging-up.py @@ -60,16 +60,24 @@ 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. -FORBIDDEN_PORTS = frozenset( - {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094} -) +# 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",) -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 @@ -178,7 +186,7 @@ def ok(msg: str) -> None: print(f" [ OK ] {msg}") -class Fatal(RuntimeError): +class Fatal(ProductionError): """Anything that must abort bring-up loudly rather than degrade.""" @@ -200,31 +208,12 @@ def check_port_allowed(port: int, what: str) -> None: 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: + """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() @@ -271,14 +260,25 @@ def cmdline_of(pid: int) -> str: return "" +_PROD_BASELINE: ProductionState | None = None + + 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()) - ) + """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 --------------------------------------------------------------------------- @@ -1091,7 +1091,7 @@ def main() -> int: print_summary(lay, args.variant, args.coins_processed, args.count_mode, args.roster_host, records) return 0 - except Fatal as exc: + except ProductionError as exc: print(f"\nFATAL: {exc}", file=sys.stderr) if started: print(" rolling back the partial bring-up...", file=sys.stderr)