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)