//! Environment → typed configuration, shared by every OpenFUT service host. //! //! **This is the only crate in the migration that reads the environment.** The //! adapter and protocol crates never do, and each service host resolves its //! endpoints through here rather than parsing its own. That single construction //! path is what the deployment-address audit established; two hosts each //! parsing `OPENFUT_ADVERTISE` would be exactly the "separate helpers //! constructing endpoints from different sources of truth" the audit forbids. //! //! Bind and advertise stay distinct throughout: //! //! * **BIND** — where a listener binds. Per-host, never client-visible. //! * **ADVERTISE** — what the remote client is told to contact. Required, with //! no default, because a backend that guesses its own reachable address //! advertises a wrong one and fails far from the cause. use std::env; use std::fmt; use openfut_adapter_fifa17::blaze::config as adapter_config; use openfut_adapter_fifa17::blaze::{AdapterConfig, Endpoints, Identity}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfigError(pub String); impl fmt::Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } impl std::error::Error for ConfigError {} pub fn required(key: &str, why: &str) -> Result { match env::var(key) { Ok(v) if !v.trim().is_empty() => Ok(v), _ => Err(ConfigError(format!("{key} must be set — {why}"))), } } pub fn optional(key: &str, default: &str) -> String { optional_opt(key).unwrap_or_else(|| default.to_string()) } pub fn optional_opt(key: &str) -> Option { env::var(key).ok().filter(|v| !v.trim().is_empty()) } /// An optional numeric port. A present-but-invalid value is an ERROR, never a /// silent fallback — a typo must not quietly leave the previous port in place. pub fn optional_port(key: &str) -> Result, ConfigError> { match optional_opt(key) { None => Ok(None), Some(v) => v .trim() .parse() .map(Some) .map_err(|_| ConfigError(format!("{key} is not a valid port: {v:?}"))), } } pub fn required_port(key: &str, why: &str) -> Result { let raw = required(key, why)?; raw.trim() .parse() .map_err(|_| ConfigError(format!("{key} is not a valid port: {raw:?}"))) } /// Resolve the endpoints every client-visible URL is built from. /// /// POW hosts DERIVE from the advertised address, matching the deployed Python /// entrypoint (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must not fall back to /// loopback independently: that would leave a remote deployment emitting /// loopback POW URLs while every other URL was correct. pub fn endpoints_from_env() -> Result { let advertise = required( "OPENFUT_ADVERTISE", "it is the address the game machine uses to reach this host; \ there is no loopback fallback in remote mode", )?; let mut endpoints = Endpoints::advertising(&advertise); // The advertised-config bind, NOT any listener's bind. The adapter derives // nucleusConnect from it, reproducing the oracle; see the vault's // compatibility exceptions. endpoints.bind = optional("OPENFUT_BIND", "127.0.0.1"); if let Some(v) = optional_opt("POW_CONTENT_HOST") { endpoints.pow_content_host = v; } if let Some(v) = optional_opt("POW_HOST") { endpoints.pow_host = v; } if let Some(p) = optional_port("OPENFUT_BLAZE_ADVERTISED_PORT")? { endpoints.blaze_port = p; } if let Some(p) = optional_port("OPENFUT_UTAS_PORT")? { endpoints.utas_port = p; } Ok(endpoints) } /// The full adapter configuration a host hands to the FIFA 17 adapter. pub fn adapter_from_env() -> Result { Ok(AdapterConfig { identity: Identity::default(), endpoints: endpoints_from_env()?, server_version: adapter_config::DEFAULT_SERVER_VERSION.into(), }) } #[cfg(test)] mod tests { use super::*; /// Env is process-global, so this runs as one test rather than several that /// would race each other. #[test] fn env_contract() { let keys = [ "OPENFUT_ADVERTISE", "OPENFUT_BIND", "POW_CONTENT_HOST", "POW_HOST", "OPENFUT_BLAZE_ADVERTISED_PORT", "OPENFUT_UTAS_PORT", ]; let saved: Vec<_> = keys.iter().map(|k| (*k, env::var(k).ok())).collect(); for k in keys { env::remove_var(k); } // Missing advertise is refused, never defaulted. let err = endpoints_from_env().unwrap_err().to_string(); assert!(err.contains("OPENFUT_ADVERTISE"), "{err}"); // POW derives from advertise; no independent loopback fallback. env::set_var("OPENFUT_ADVERTISE", "198.51.100.7"); let e = endpoints_from_env().expect("resolves"); assert_eq!(e.pow_content_host, "198.51.100.7:8080"); assert_eq!(e.pow_host, "198.51.100.7:8094"); // Bind is independent of advertise. env::set_var("OPENFUT_BIND", "0.0.0.0"); let e = endpoints_from_env().unwrap(); assert_eq!(e.bind, "0.0.0.0"); assert_eq!(e.advertise, "198.51.100.7"); // A bad port is an error, not a silent fallback. env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "nope"); assert!(endpoints_from_env() .unwrap_err() .to_string() .contains("not a valid port")); env::set_var("OPENFUT_BLAZE_ADVERTISED_PORT", "42999"); assert_eq!(endpoints_from_env().unwrap().blaze_port, 42999); for (k, v) in saved { match v { Some(v) => env::set_var(k, v), None => env::remove_var(k), } } } }