All checks were successful
PR Checks / rust-gates (pull_request) Successful in 3m26s
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
375 lines
15 KiB
Rust
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}")),
|
|
}
|
|
}
|