257 lines
9.6 KiB
Rust
257 lines
9.6 KiB
Rust
//! Environment → [`HostConfig`]. Client-visible bind and the Python upstream are
|
|
//! REQUIRED with no default (host-family discipline: a defaulted port could
|
|
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
|
|
|
use std::env;
|
|
const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss";
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
pub enum SbcPostCommitFault {
|
|
#[default]
|
|
Off,
|
|
Drop,
|
|
Malformed,
|
|
Delay {
|
|
millis: u64,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HostConfig {
|
|
/// Where this host listens (the address FIFA reaches for UTAS). Required.
|
|
pub listen_addr: String,
|
|
/// Base URL of the Python UTAS oracle for fallback, e.g.
|
|
/// `http://127.0.0.1:8199`. Required — must NOT be this host's own address.
|
|
pub python_upstream: String,
|
|
/// OpenFUT Core base URL. Default `http://127.0.0.1:8080` (Bridge convention).
|
|
pub core_url: String,
|
|
/// Directory holding `leagues.json`/`nations.json`/`teams.json`.
|
|
pub tables_dir: String,
|
|
/// FIFA 17 card-definition **identity catalog** (card id → FIFA asset id).
|
|
/// Required production identity source: a `/club` item's `resourceId` comes
|
|
/// from here. Startup fails if it cannot be loaded — never a placeholder.
|
|
pub catalog_path: String,
|
|
/// Persistent external-identity **store** file (owned-instance → stable wire
|
|
/// id). Required: the wire `id` of every owned item is allocated/resolved
|
|
/// here so it survives restart and reverses exactly.
|
|
pub identity_store_path: String,
|
|
/// The launcher-selected FIFA persona id, injected via `OPENFUT_PERSONA_ID`.
|
|
/// Required, non-zero: it stamps `personaId` on the Core-backed
|
|
/// `GET /squad/active`, and must match the persona LSX/Blaze/POW/UTAS use.
|
|
pub persona_id: i64,
|
|
/// Durable FIFA17 transfer-market listing DB (host-owned SQLite). Required
|
|
/// for the economy cutover; must survive host restart (a real path, not a
|
|
/// temp file). Env `OPENFUT_MARKET_DB`.
|
|
pub market_db_path: String,
|
|
/// Durable FIFA17 item-pile metadata DB (host-owned SQLite). Required; must
|
|
/// survive host restart. Env `OPENFUT_PILE_DB`.
|
|
pub pile_db_path: String,
|
|
/// Durable client-data blob store (`clientdata`/`userHubData`), host-owned
|
|
/// JSON file. NOT required: defaults to env `OPENFUT_CLIENTDATA_DB`, else the
|
|
/// identity store's parent directory + `clientdata.json`. The blobs are
|
|
/// non-authoritative client UI state, so a default path is safe.
|
|
pub clientdata_path: String,
|
|
/// Shared FIFA17 account JSON (`fut_account.py` shape), used for club-name
|
|
/// reads and the Rust-owned rename mutation. Defaults to
|
|
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
|
/// identity store's parent directory + `active_account.json`.
|
|
pub account_path: String,
|
|
/// Disabled by default. Non-off values require three explicit staging guards;
|
|
/// see [`parse_sbc_post_commit_fault`].
|
|
pub sbc_post_commit_fault: SbcPostCommitFault,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ConfigError(pub String);
|
|
|
|
impl std::fmt::Display for ConfigError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
impl std::error::Error for ConfigError {}
|
|
|
|
fn required(key: &str) -> Result<String, ConfigError> {
|
|
match env::var(key) {
|
|
Ok(v) if !v.is_empty() => Ok(v),
|
|
_ => Err(ConfigError(format!("{key} is required (no default)"))),
|
|
}
|
|
}
|
|
|
|
/// Parse a required, non-zero i64 env var. A zero identity id is invalid (the
|
|
/// client rejects a zero persona), so unset/empty/non-integer/zero is a hard error.
|
|
fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
|
let raw = required(key)?;
|
|
let val: i64 = raw
|
|
.parse()
|
|
.map_err(|_| ConfigError(format!("{key} must be an integer, got {raw:?}")))?;
|
|
if val == 0 {
|
|
return Err(ConfigError(format!("{key} must be non-zero")));
|
|
}
|
|
Ok(val)
|
|
}
|
|
|
|
fn parse_sbc_post_commit_fault(
|
|
raw: Option<&str>,
|
|
environment: Option<&str>,
|
|
ack: Option<&str>,
|
|
listen_addr: &str,
|
|
) -> Result<SbcPostCommitFault, ConfigError> {
|
|
let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") {
|
|
"off" => return Ok(SbcPostCommitFault::Off),
|
|
"drop" => SbcPostCommitFault::Drop,
|
|
"malformed" => SbcPostCommitFault::Malformed,
|
|
value if value.starts_with("delay:") => {
|
|
let millis = value["delay:".len()..].parse::<u64>().map_err(|_| {
|
|
ConfigError(
|
|
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:<milliseconds>"
|
|
.into(),
|
|
)
|
|
})?;
|
|
if !(1..=30_000).contains(&millis) {
|
|
return Err(ConfigError(
|
|
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(),
|
|
));
|
|
}
|
|
SbcPostCommitFault::Delay { millis }
|
|
}
|
|
value => {
|
|
return Err(ConfigError(format!(
|
|
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:<milliseconds>; got {value:?}"
|
|
)));
|
|
}
|
|
};
|
|
if environment != Some("staging") {
|
|
return Err(ConfigError(
|
|
"SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(),
|
|
));
|
|
}
|
|
if ack != Some(SBC_FAULT_ACK) {
|
|
return Err(ConfigError(format!(
|
|
"SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}"
|
|
)));
|
|
}
|
|
if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") {
|
|
return Err(ConfigError(
|
|
"SBC post-commit faults refuse the production UTAS port 8099".into(),
|
|
));
|
|
}
|
|
Ok(mode)
|
|
}
|
|
|
|
impl HostConfig {
|
|
pub fn from_env() -> Result<Self, ConfigError> {
|
|
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
|
|
let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?;
|
|
let sbc_post_commit_fault = parse_sbc_post_commit_fault(
|
|
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT")
|
|
.ok()
|
|
.as_deref(),
|
|
env::var("OPENFUT_ENVIRONMENT").ok().as_deref(),
|
|
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK")
|
|
.ok()
|
|
.as_deref(),
|
|
&listen_addr,
|
|
)?;
|
|
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.unwrap_or_else(|| default_clientdata_path(&identity_store_path));
|
|
let account_path = env::var("OPENFUT_ACCOUNT_PATH")
|
|
.ok()
|
|
.filter(|v| !v.is_empty())
|
|
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
|
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
|
Ok(HostConfig {
|
|
listen_addr,
|
|
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
|
core_url: env::var("OPENFUT_CORE_URL")
|
|
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
|
tables_dir: env::var("OPENFUT_FIFA17_TABLES_DIR")
|
|
.unwrap_or_else(|_| "fifa17-recon/data/tables".into()),
|
|
catalog_path: required("OPENFUT_FIFA17_CATALOG")?,
|
|
persona_id: required_i64_nonzero("OPENFUT_PERSONA_ID")?,
|
|
market_db_path: required("OPENFUT_MARKET_DB")?,
|
|
pile_db_path: required("OPENFUT_PILE_DB")?,
|
|
identity_store_path,
|
|
clientdata_path,
|
|
account_path,
|
|
sbc_post_commit_fault,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Default client-data blob path: the identity store's parent directory +
|
|
/// `clientdata.json` (co-located with the other host-owned durable state).
|
|
fn default_clientdata_path(identity_store_path: &str) -> String {
|
|
std::path::Path::new(identity_store_path)
|
|
.parent()
|
|
.map(|p| p.join("clientdata.json"))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("clientdata.json"))
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
fn default_account_path(identity_store_path: &str) -> String {
|
|
std::path::Path::new(identity_store_path)
|
|
.parent()
|
|
.map(|p| p.join("active_account.json"))
|
|
.unwrap_or_else(|| std::path::PathBuf::from("active_account.json"))
|
|
.to_string_lossy()
|
|
.into_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sbc_post_commit_faults_require_all_staging_guards() {
|
|
assert_eq!(
|
|
parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(),
|
|
SbcPostCommitFault::Off
|
|
);
|
|
assert!(parse_sbc_post_commit_fault(
|
|
Some("drop"),
|
|
None,
|
|
Some(SBC_FAULT_ACK),
|
|
"127.0.0.1:18199"
|
|
)
|
|
.is_err());
|
|
assert!(parse_sbc_post_commit_fault(
|
|
Some("drop"),
|
|
Some("staging"),
|
|
None,
|
|
"127.0.0.1:18199"
|
|
)
|
|
.is_err());
|
|
assert!(parse_sbc_post_commit_fault(
|
|
Some("drop"),
|
|
Some("staging"),
|
|
Some(SBC_FAULT_ACK),
|
|
"0.0.0.0:8099"
|
|
)
|
|
.is_err());
|
|
assert_eq!(
|
|
parse_sbc_post_commit_fault(
|
|
Some("drop"),
|
|
Some("staging"),
|
|
Some(SBC_FAULT_ACK),
|
|
"0.0.0.0:18199"
|
|
)
|
|
.unwrap(),
|
|
SbcPostCommitFault::Drop
|
|
);
|
|
assert_eq!(
|
|
parse_sbc_post_commit_fault(
|
|
Some("delay:25"),
|
|
Some("staging"),
|
|
Some(SBC_FAULT_ACK),
|
|
"0.0.0.0:18199"
|
|
)
|
|
.unwrap(),
|
|
SbcPostCommitFault::Delay { millis: 25 }
|
|
);
|
|
}
|
|
}
|