Files
installer/src/rustgame/doctor.rs
wtclaude c9b773f79b
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m26s
feat(rust): name what a missing RaidableBases helper costs in doctor (PLAN_REDESIGNS §11.4)
RunicGatewayRaids.cs ships beside the bridge from this release (Rust-Plugins
release.yml lists it in the manifest's files), so doctor reports it like the
other helpers: without it the bridge still reports raid bases, but an
event's base cannot be placed, set or locked and despawns on its own timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-16 15:18:53 -05:00

375 lines
15 KiB
Rust

//! `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<i32> {
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::<Vec<_>>()
.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<Row> {
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::<Vec<_>>()
.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}")),
}
}