//! `doctor --game rust` — every recorded instance, or one with `--server-id`. //! //! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only //! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3): //! //! - the framework, and whether the plugin file is still the one deployed; //! - each helper deployed beside it (D182), as a warning when it is gone or edited — the bridge //! runs without one, and the row says what that costs; //! - that the plugin's config names this instance; //! - `requires_plugins` present, as a warning (D153); //! - the service registered and running; //! - `/health`: reachable, the protocol, and whether the plugin is connected. //! //! **The framework version floor is not measured from files.** A plugin that compiled and connected //! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and //! the floors are printed beside a plugin that is not. use std::path::Path; use anyhow::Result; use super::plugin; use super::record::{Instance, RustRecord}; use super::server; use crate::cli::Cli; use crate::doctor::{health_of, report, Row}; use crate::{bundle, paths, service, ui}; pub fn run(cli: &Cli) -> Result { let layout = paths::layout(); let record_path = layout.rust_record(); println!( "\nRunic Gateway installer {} — doctor (Rust)", env!("CARGO_PKG_VERSION") ); let Some(record) = RustRecord::load(&record_path)? else { println!(); ui::warn(&format!( "No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \ rust` first. If you installed with {} set, set it again for this run.", record_path.display(), paths::STATE_DIR_ENV )); return Ok(1); }; let mut rows = vec![Row::ok( "Rust record", format!( "{} (bundle {}, {} instance(s), installer {})", record_path.display(), record.bundle.tag, record.instances.len(), record.installer.version ), )]; let selected: Vec<(&String, &Instance)> = match &cli.server_id { Some(id) => match record.instances.get_key_value(id) { Some(pair) => vec![pair], None => { rows.push(Row::fail( "Instance", format!( "{id:?} is not recorded (recorded: {})", record .instances .keys() .cloned() .collect::>() .join(", ") ), )); return Ok(report(&rows)); } }, None => record.instances.iter().collect(), }; // The floors, fetched once, for the note beside a plugin that has not connected. Read from the // installed bundle rather than the current one: they describe the plugin that is installed. let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok(); for (id, instance) in selected { rows.extend(instance_rows( id, instance, &record, installed_bundle.as_ref().map(|b| &b.0), )); } rows.push(bundle_row(&record)); Ok(report(&rows)) } fn instance_rows( id: &str, instance: &Instance, record: &RustRecord, bundle: Option<&bundle::RustBundle>, ) -> Vec { let mut rows = Vec::new(); let label = |what: &str| format!("{id}: {what}"); // ── The server and its framework ───────────────────────────────────────── let server = server::open(Path::new(&instance.server_root)); match &server { Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok( &label("server"), format!("{} ({})", s.path.display(), instance.framework), )), Ok(s) => rows.push( Row::fail( &label("server"), format!( "{} now runs {}, but the plugin was installed for {}", s.path.display(), s.framework.as_str(), instance.framework ), ) .note(format!( "reinstall it: install --game rust --server-id {id} --rust {}", s.path.display() )), ), Err(e) => rows.push(Row::fail(&label("server"), e.to_string())), } // ── The plugin file: ours ──────────────────────────────────────────────── let plugin_path = Path::new(&instance.plugin_path); match std::fs::read(plugin_path) { Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => { rows.push(Row::ok( &label("plugin"), format!( "{} (Rust-Plugins {})", instance.plugin_path, record.plugin.tag ), )) } Ok(_) => rows.push( Row::fail( &label("plugin"), format!("{} is not the file that was deployed", instance.plugin_path), ) .note("edited or replaced by hand; `update --game rust` puts the released one back"), ), Err(_) => rows.push(Row::fail( &label("plugin"), format!("{} is missing", instance.plugin_path), )), } // ── The helpers: ours too, and optional to the bridge (D182) ───────────── // A warning rather than a failure: the bridge runs without a helper and falls back. What is // lost is said, so an operator who removed one on purpose knows what they chose. for (name, helper) in &instance.helpers { match std::fs::read(&helper.path) { Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => { rows.push(Row::ok(&label("helper"), helper.path.clone())) } Ok(_) => rows.push( Row::warn( &label("helper"), format!("{} is not the file that was deployed", helper.path), ) .note( "edited or replaced by hand; `update --game rust` puts the released one back", ), ), Err(_) => rows.push( Row::warn(&label("helper"), format!("{} is missing", helper.path)) .note(helper_absence(name)) .note("`update --game rust` puts it back"), ), } } // ── RunicNPC: ours, and required (D224, D310) ───────────────────────────── // A failure when it is missing or was never placed: since RunicNPC's stage 9 the bridge // refuses every NPC an event places without it. A file edited by hand still loads, so that // stays a warning, as the bridge's own helpers are. if let Some(file) = &instance.npc { let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?"); match std::fs::read(&file.path) { Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => { rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path))) } Ok(_) => rows.push( Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path)) .note("edited or replaced by hand; `update --game rust` puts the released one back"), ), Err(_) => rows.push( Row::fail(&label("RunicNPC"), format!("{} is missing", file.path)) .note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included") .note("`update --game rust` puts it back"), ), } } else { rows.push( Row::fail(&label("RunicNPC"), "not installed") .note("RunicNPC is required: without it every NPC an event places is refused, Rust's own scientists included") .note("`update --game rust` installs it from the current bundle"), ); } // ── The plugin's config: the website's ─────────────────────────────────── match plugin::read_config(Path::new(&instance.plugin_config)) { Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok( &label("plugin config"), format!( "ServerId {id}, Port {}", view.port .map(|p| p.to_string()) .unwrap_or_else(|| "default".into()) ), )), Ok(Some(view)) => rows.push(Row::fail( &label("plugin config"), format!( "{} says ServerId {:?}; this instance is {id:?}", instance.plugin_config, view.server_id.unwrap_or_else(|| "main (default)".into()) ), )), Ok(None) => rows.push(Row::warn( &label("plugin config"), format!( "{} is gone — the plugin will write one with ServerId \"main\" on its next load", instance.plugin_config ), )), Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())), } // ── Third-party plugins: reported, never installed (D153) ──────────────── if let (Ok(s), Some(b)) = (&server, bundle) { let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins); rows.push(if missing.is_empty() { Row::ok( &label("required plugins"), b.payload.compat.requires_plugins.join(", "), ) } else { Row::warn( &label("required plugins"), format!( "missing {} in {}", missing.join(", "), s.plugins_dir().display() ), ) .note("install from uMod; the features that use them stay off until then") }); } // ── The service ────────────────────────────────────────────────────────── match &instance.service { Some(svc) => { let status = service::observe(&svc.kind, &svc.name); let detail = format!("{} {}", svc.name, status.detail); rows.push(if status.running && status.enabled { Row::ok(&label("service"), detail) } else { Row::fail(&label("service"), detail) }); } None => rows.push(Row::fail( &label("service"), "none registered — the install printed the steps to register it by hand", )), } // ── The sidecar, and the plugin through it ─────────────────────────────── let health = health_of(&format!("127.0.0.1:{}", instance.web_port)); let Some(health) = health else { rows.push(Row::fail( &label("sidecar"), format!( "nothing answered http://127.0.0.1:{}/health", instance.web_port ), )); return rows; }; rows.push(Row::ok( &label("sidecar"), format!( "127.0.0.1:{} /health {}, database {}", instance.web_port, health.status.as_deref().unwrap_or("ok"), health.database.as_deref().unwrap_or("unknown") ), )); match health.protocol { Some(p) if p == record.bundle.protocol => { rows.push(Row::ok(&label("protocol"), p.to_string())) } Some(p) => rows.push(Row::fail( &label("protocol"), format!( "the sidecar speaks {p}; bundle {} is {}", record.bundle.tag, record.bundle.protocol ), )), None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")), } let floors = bundle .map(|b| { b.payload .compat .frameworks .iter() .map(|(f, v)| format!("{f} {}", v.min_version)) .collect::>() .join(", ") }) .unwrap_or_default(); let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path)); rows.push(match (health.plugin_connected, running) { (Some(true), _) => Row::ok(&label("plugin connected"), "yes"), (_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"), (_, true) => { Row::fail(&label("plugin connected"), "no, and the server is running").note(format!( "the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \ error; the plugin is known good on {floors}", instance.game_port )) } }); rows } /// Whether a newer Rust bundle than the installed one is published. /// What a missing helper costs, in the operator's words. Unknown helpers get a generic line. fn helper_absence(name: &str) -> String { match name { "RunicGatewayZones.cs" => { "the bridge still runs and scores its zones by position, but ZoneManager's \ own flags miss anybody already standing in a zone when it is created or restored" .to_string() } "RunicGatewayDomes.cs" => { "the bridge still runs and its zones still open, but the site offers no dome over \ an event zone, because ZoneDomes cannot be called without it" .to_string() } "RunicGatewayRaids.cs" => { "the bridge still runs and still reports raid bases, but an event's RaidableBases base \ is not the event's: it cannot be placed, set or locked, and despawns on its own timer" .to_string() } _ => format!("the bridge still runs without {name}; what it helped with falls back"), } } fn bundle_row(record: &RustRecord) -> Row { match bundle::fetch_rust(None) { Ok((current, _)) if current.bundle == record.bundle.tag => { Row::ok("Bundle", format!("{} is current", record.bundle.tag)) } Ok((current, _)) => Row::warn( "Bundle", format!( "{} is installed; {} is published", record.bundle.tag, current.bundle ), ) .note("update --game rust moves every instance to it"), Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")), } }