//! Is the mission gateway configured, and is anything listening? //! //! The third sibling of [`crate::runtime_preflight`] and //! [`crate::validator_preflight`], for the same class of failure: the //! configuration is absent or wrong, and nothing says so until a mission pays //! for it. //! //! `ZEROCLAW_GATEWAY_URL` and `ZEROCLAW_TOKEN` have no defaults and are read at //! FIRST USE, inside `ZeroClawDriveExecutor::from_env`. So a deployment missing //! them boots clean, serves every page, lists every mission — and fails the //! first time someone presses run, with an error that surfaces on a phase //! rather than at startup. The information exists the whole time; nobody is //! told until it is expensive. //! //! A report, not a gate, matching its siblings. A server with no gateway should //! still boot: the frontend, the catalogue and every read path work without it, //! and refusing to start would turn a degraded deployment into a dead one. use std::time::Duration; const PROBE_TIMEOUT: Duration = Duration::from_secs(5); /// What the preflight found. #[derive(Debug, PartialEq, Eq)] pub enum Verdict { /// No gateway configured. Missions on the container tier cannot run. NotConfigured { missing: Vec }, /// Configured but nothing answered at that address. Unreachable { url: String, error: String }, /// Configured and something answered. Reachable { url: String }, } impl Verdict { /// The line to print at boot. /// /// Each names the CONSEQUENCE, not just the state. "ZEROCLAW_TOKEN not set" /// tells an operator what is missing; it does not tell them that every /// container-tier mission they launch will fail on its first phase. pub fn message(&self) -> String { match self { Verdict::NotConfigured { missing } => format!( "gateway_preflight: NOT CONFIGURED ({}) — container-tier missions \ cannot run. They will launch, provision a runtime, and fail on \ the first turn; the server is otherwise healthy", missing.join(", ") ), Verdict::Unreachable { url, error } => format!( "gateway_preflight: {url} is configured but did not answer ({error}) \ — container-tier missions will fail on their first turn. The \ config is right and the machine is not" ), Verdict::Reachable { url } => { format!("gateway_preflight: {url} answered") } } } } /// Which required variables are absent. /// /// Split from the network probe so the rule is testable without a gateway: /// this is the half that is pure, and it is the half that is wrong most often. pub fn missing_config(url: Option<&str>, token: Option<&str>, pairing: Option<&str>) -> Vec { let mut missing = Vec::new(); if url.map(str::trim).unwrap_or("").is_empty() { missing.push("ZEROCLAW_GATEWAY_URL".to_string()); } // Either credential works: a durable token, or a one-time pairing code the // executor exchanges on first use. let has_token = !token.map(str::trim).unwrap_or("").is_empty(); let has_pairing = !pairing.map(str::trim).unwrap_or("").is_empty(); if !has_token && !has_pairing { missing.push("ZEROCLAW_TOKEN or ZEROCLAW_PAIRING_CODE".to_string()); } missing } fn env_opt(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.trim().is_empty()) } /// Probe the configured gateway. pub async fn check() -> Verdict { let url = env_opt("ZEROCLAW_GATEWAY_URL"); let missing = missing_config( url.as_deref(), env_opt("ZEROCLAW_TOKEN").as_deref(), env_opt("ZEROCLAW_PAIRING_CODE").as_deref(), ); if !missing.is_empty() { return Verdict::NotConfigured { missing }; } let url = url.expect("checked above"); // Any HTTP answer proves something is listening and routable, which is the // question this preflight exists to answer. Authenticating here would need // a pairing exchange that BURNS a one-time code — a preflight that costs // the deployment its credential is worse than no preflight. let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() { Ok(c) => c, Err(e) => { return Verdict::Unreachable { url, error: e.to_string(), } } }; match client.get(&url).send().await { Ok(_) => Verdict::Reachable { url }, Err(e) => Verdict::Unreachable { url, error: e.to_string(), }, } } /// Run the probe and print the verdict. Never panics, never blocks boot. pub fn report_at_boot() { tokio::spawn(async { eprintln!("{}", check().await.message()); }); } #[cfg(test)] mod tests { use super::*; #[test] fn a_fully_configured_deployment_is_missing_nothing() { assert!(missing_config(Some("http://gw:42617"), Some("tok"), None).is_empty()); // A pairing code alone is enough — the executor exchanges it on first use. assert!(missing_config(Some("http://gw:42617"), None, Some("123456")).is_empty()); } #[test] fn an_empty_string_counts_as_absent() { // The failure this whole module exists for: `unwrap_or_default` and an // empty env var turn "unconfigured" into "configured with nothing", // which fails later as a 401 rather than now as a missing setting. let missing = missing_config(Some(" "), Some(""), Some(" ")); assert_eq!(missing.len(), 2, "both must be reported: {missing:?}"); assert!(missing[0].contains("GATEWAY_URL")); assert!(missing[1].contains("ZEROCLAW_TOKEN")); } #[test] fn the_message_names_the_consequence_not_just_the_state() { let v = Verdict::NotConfigured { missing: vec!["ZEROCLAW_GATEWAY_URL".into()], }; let m = v.message(); assert!(m.contains("ZEROCLAW_GATEWAY_URL")); assert!( m.contains("cannot run"), "an operator needs to know what stops working, not only what is \ unset: {m}" ); } }