diff --git a/src/cli.rs b/src/cli.rs index d88ff08..dac8608 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -135,6 +135,9 @@ Options: --site-url install. Your site's base URL, for the Admin → Shard link. --yes Assume the default answer to every prompt. + On uninstall it means yes: that prompt + defaults to no, and typing `uninstall + --yes` is not an accident. --purge uninstall. Also delete sidecar.toml and uo-link.db, which are otherwise kept. -V, --version Print the installer version and exit. diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..5500765 --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,884 @@ +//! The `doctor` command — diagnose an existing deployment end to end. +//! +//! PLAN.md §5 Phase 4 calls this "the command that makes the whole thing supportable", and the row +//! that carries the phase is the last one: **has a shard actually dialed in?** Everything else can +//! be true — files copied, service running, hashes matching — while the bridge does nothing at all, +//! because ServUO shells out to `dotnet build`, prints the output, ignores the exit code and reloads +//! the previous `Scripts.dll` (§2.1). A clean boot is not evidence. `plugin_connected` is. +//! +//! Three rules shape this module: +//! +//! - **It writes nothing, anywhere.** Not to the ServUO tree, not to `install.json`, not to the +//! sidecar's config. That is why `--print-config` is run only when the config file already +//! exists: that flag *provisions* (it writes the file and mints a token when absent), so calling +//! it on a host that has none would have `doctor` create the very state it is reporting on. +//! - **It asks the thing itself, not the record.** The installed binary answers `--version` and +//! `--print-config`; the service manager answers `is-active`; the sidecar answers `/health`. The +//! record says what `install` *did*, which is a different question from what is true now — and +//! the gap between those two is the whole reason to run this. +//! - **A missing answer is a row, not an exception.** A host with no route to Gitea, a sidecar that +//! is down, a config this process cannot read: each degrades to one honest line and the report +//! still prints. The exit code is what a monitoring script reads — `1` if any row failed — and it +//! is deliberately not raised by a `⚠`, which means "worth knowing", not "broken". +//! +//! The token is never printed here. `--print-config` returns it (it is one document), and this +//! module reads the paths, the bind and the protocol out of that document and drops the rest. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Result; +use serde::Deserialize; + +use crate::cli::Cli; +use crate::record::{InstallRecord, LinkRecord}; +use crate::servuo::ServUoRoot; +use crate::{bundle, net, patch, paths, servuo, sidecar, ui}; + +/// Both network calls give up quickly. Every row here is context around local state, so a host with +/// no route out must produce its report seconds later rather than appear to hang. +const BUNDLE_TIMEOUT: Duration = Duration::from_secs(15); +const HEALTH_TIMEOUT: Duration = Duration::from_secs(5); + +/// The verdict on one row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mark { + Ok, + Warn, + Fail, +} + +impl Mark { + fn glyph(self) -> &'static str { + match self { + Self::Ok => "✓", + Self::Warn => "⚠", + Self::Fail => "✗", + } + } +} + +/// One line of the report, plus any detail lines that belong underneath it. +#[derive(Debug, Clone)] +pub struct Row { + pub mark: Mark, + pub label: String, + pub detail: String, + pub notes: Vec, +} + +impl Row { + fn new(mark: Mark, label: &str, detail: impl Into) -> Self { + Self { + mark, + label: label.to_string(), + detail: detail.into(), + notes: Vec::new(), + } + } + + fn ok(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Ok, label, detail) + } + + fn warn(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Warn, label, detail) + } + + fn fail(label: &str, detail: impl Into) -> Self { + Self::new(Mark::Fail, label, detail) + } + + fn note(mut self, note: impl Into) -> Self { + self.notes.push(note.into()); + self + } + + fn notes_from(mut self, notes: impl IntoIterator) -> Self { + self.notes.extend(notes); + self + } +} + +/// Runs every check and prints the report. The `i32` is the process exit code. +pub fn run(cli: &Cli) -> Result { + let layout = paths::layout(); + let record_path = layout.install_record(); + + println!( + "\nRunic Gateway installer {} — doctor", + env!("CARGO_PKG_VERSION") + ); + + let Some(record) = InstallRecord::load(&record_path)? else { + // Not an error in the `anyhow` sense — the command ran fine and the answer is "nothing is + // installed here". Exit 1 all the same, because a monitoring script asking after a + // deployment on this host has had its question answered in the negative. + println!(); + ui::warn(&format!( + "No deployment is recorded on this host.\n \ + Looked for {}\n \ + Run `install` 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( + "Install record", + format!( + "{} (bundle {}, installer {}, {})", + record_path.display(), + record.bundle.tag, + record.installer.version, + record.updated + ), + )]; + + // ── ServUO and the overlay ─────────────────────────────────────────────── + let root = open_root(cli, &record); + rows.push(servuo_row(&record, root.as_ref())); + rows.push(overlay_row(&record, root.as_ref())); + rows.push(patch_row(&record, root.as_ref(), &layout)); + + // ── The sidecar ────────────────────────────────────────────────────────── + // Asking the installed binary is what makes these rows describe the sidecar that will actually + // answer the website, rather than the one the record believes was installed. + let link = record.link_record(); + let live = link.as_ref().and_then(live_config); + let health = live.as_ref().and_then(|doc| health_of(&doc.web.bind)); + + rows.push(link_row(link.as_ref(), live.as_ref())); + rows.push(service_row(link.as_ref())); + rows.push(reachable_row(live.as_ref(), health.as_ref())); + rows.push(protocol_row(&record, live.as_ref(), health.as_ref())); + rows.push(shard_row(root.as_ref(), health.as_ref())); + + // ── The bundle ─────────────────────────────────────────────────────────── + rows.push(bundle_row(&record)); + + // ── Report ─────────────────────────────────────────────────────────────── + println!(); + for row in &rows { + println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail); + for note in &row.notes { + println!(" {note}"); + } + } + + let failed = rows.iter().filter(|r| r.mark == Mark::Fail).count(); + let warned = rows.iter().filter(|r| r.mark == Mark::Warn).count(); + println!(); + match (failed, warned) { + (0, 0) => println!("Everything checks out."), + (0, w) => println!("{w} thing(s) worth knowing about, nothing broken."), + (f, _) => println!( + "{f} check(s) failed. Start with the first ✗ above; \ + INSTALL.md's Troubleshooting table is keyed to these symptoms." + ), + } + Ok(if failed > 0 { 1 } else { 0 }) +} + +/// The ServUO root to inspect: `--servuo` if given, else the one the record names. +/// +/// [`servuo::open`] rather than `open_stopped`: a running shard is the *expected* state for a +/// diagnosis — it is the only state in which the shard-connected row can be true — and refusing to +/// report on a live host would make this command useless exactly when it is needed. +fn open_root(cli: &Cli, record: &InstallRecord) -> Result { + let path = cli + .servuo + .clone() + .unwrap_or_else(|| record.servuo.path.clone()); + servuo::open(Path::new(&path)) +} + +fn servuo_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row { + let root = match root { + Ok(root) => root, + Err(error) => { + return Row::fail("ServUO found", record.servuo.path.clone()) + .note(error.to_string().replace('\n', " ")) + } + }; + + let row = Row::ok( + "ServUO found", + format!("{} ({})", root.path.display(), root.version_display()), + ); + // A tree that has been upgraded under an install is the single most useful thing this row can + // say: the patch tier was resolved against the version recorded here, not against this one. + match &record.servuo.version { + Some(recorded) if Some(recorded) != root.version.as_ref() => Row::warn( + "ServUO found", + format!("{} ({})", root.path.display(), root.version_display()), + ) + .note(format!( + "this tree was {recorded} when Runic Gateway was installed — re-check the patch tier \ + below" + )), + _ if !root.is_supported_version() => row.note(format!( + "{} is the only supported version; the base overlay is expected to work anyway", + servuo::SUPPORTED_VERSION + )), + _ => row, + } +} + +/// Compares every deployed file against the record. +/// +/// The comparison that matters is *which* hash a file differs from (PLAN.md §7.0): differing from +/// what the installer put there means the operator edited it, while agreeing with the record on a +/// host whose bundle has moved on means the overlay upstream is newer. A file recorded as +/// `kept-operator-modified` is theirs by definition, so a further edit there is not a finding. +fn overlay_row(record: &InstallRecord, root: Result<&ServUoRoot, &anyhow::Error>) -> Row { + let Some(overlay) = &record.overlay else { + return Row::fail("Overlay in sync", "no overlay recorded in install.json"); + }; + let Ok(root) = root else { + return Row::fail( + "Overlay in sync", + format!("{} files recorded, tree unreadable", overlay.files.len()), + ); + }; + + let mut missing = Vec::new(); + let mut edited = Vec::new(); + let mut operator_owned = 0usize; + + for (rel, file) in &overlay.files { + if file.state == "kept-operator-modified" { + operator_owned += 1; + continue; + } + let path = patch::join(&root.path, rel); + match crate::util::sha256_file(&path) { + Ok(actual) if actual == file.on_disk_sha256 => {} + Ok(_) => edited.push(rel.clone()), + Err(_) => missing.push(rel.clone()), + } + } + + let total = overlay.files.len(); + let owned = if operator_owned > 0 { + format!(", {operator_owned} operator-owned") + } else { + String::new() + }; + + if missing.is_empty() && edited.is_empty() { + return Row::ok( + "Overlay in sync", + format!("{total} files, all hashes match install.json{owned}"), + ); + } + + let mark = if missing.is_empty() { + Mark::Warn + } else { + Mark::Fail + }; + let mut row = Row::new( + mark, + "Overlay in sync", + format!( + "{total} files{owned} — {} missing, {} edited since deployment", + missing.len(), + edited.len() + ), + ) + .notes_from(missing.iter().map(|f| format!("missing: {f}"))) + .notes_from(edited.iter().map(|f| format!("edited: {f}"))); + + if !missing.is_empty() { + row = row.note("run `update` (or `install`) to put the release's copies back"); + } + if !edited.is_empty() { + row = row.note( + "these are code files the overlay owns — an `update` overwrites them without asking", + ); + } + row +} + +/// Reports the patch tier from the record, then checks the tree still agrees with it. +/// +/// The check is not decoration. The tier's whole risk is that its edits sit inside files ServUO +/// itself ships, so a core upgrade, a hand revert, or a restored backup silently removes them — +/// and nothing else in this report would notice. The cached `.patch` (PLAN.md §2.2) is what makes +/// the check possible offline: resolving it against the current file must land on rung 0, because +/// the record says it is already applied. +fn patch_row( + record: &InstallRecord, + root: Result<&ServUoRoot, &anyhow::Error>, + layout: &paths::Layout, +) -> Row { + let records = record.patch_records(); + if records.is_empty() { + return Row::warn("Patch tier", "not applied — this is optional").note( + "the two optional features (vendor.sale events, in-game moderation audit) are \ + unavailable; INSTALL.md §4", + ); + } + + let applied: Vec = records + .iter() + .map(|f| { + let rungs: Vec<&str> = f.patches.iter().map(|p| p.rung.as_str()).collect(); + format!("{} ({})", f.feature, rungs.join(", ")) + }) + .collect(); + + let unsupported: Vec<&str> = records + .iter() + .filter(|f| f.unsupported_servuo) + .map(|f| f.feature.as_str()) + .collect(); + + let mut notes = Vec::new(); + let mut gone = 0usize; + + match root { + Ok(root) => { + for feature in &records { + for applied_patch in &feature.patches { + match verify_applied(layout, &applied_patch.name, &applied_patch.sha256) { + None => notes.push(format!( + "{}: no cached copy of the patch, so it could not be re-checked", + applied_patch.name + )), + Some(parsed) => { + let target = patch::join(&root.path, &applied_patch.target); + let content = std::fs::read(&target).unwrap_or_default(); + let resolution = patch::resolve(&parsed, &content); + if !matches!(resolution, patch::Resolution::AlreadyPresent { .. }) { + gone += 1; + notes.push(format!( + "{} is NO LONGER in {} — the file was replaced, reverted or \ + upgraded since it was applied", + applied_patch.name, applied_patch.target + )); + } + } + } + } + for companion in &feature.companions { + if !patch::join(&root.path, &companion.path).is_file() { + gone += 1; + notes.push(format!("{} is missing from the tree", companion.path)); + } + } + } + } + Err(_) => { + notes.push("the ServUO tree is unreadable, so nothing could be re-checked".into()) + } + } + + if !unsupported.is_empty() { + // The label follows the install (PLAN.md §2.2.2): whoever inherits this shard must be able + // to see it here, not only in the output of a run they never saw. + notes.push(format!( + "applied on an UNSUPPORTED ServUO ({}): {}", + records + .iter() + .find_map(|f| f.servuo_version.clone()) + .unwrap_or_else(|| "unknown".into()), + unsupported.join(", ") + )); + } + + let detail = format!("{} applied — {}", records.len(), applied.join("; ")); + let mark = if gone > 0 { + Mark::Fail + } else if unsupported.is_empty() { + Mark::Ok + } else { + Mark::Warn + }; + Row::new(mark, "Patch tier", detail).notes_from(notes) +} + +/// Finds the cached `.patch` for a recorded patch and parses it. +/// +/// By name first, which is what the tier writes; then by content hash across the cache, so a +/// release that renames a patch file does not silently turn this check off. +fn verify_applied( + layout: &paths::Layout, + name: &str, + sha256: &str, +) -> Option { + let by_name = layout.patches_dir().join(format!("{name}.patch")); + let candidates: Vec = if by_name.is_file() { + vec![by_name] + } else { + std::fs::read_dir(layout.patches_dir()) + .ok()? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "patch")) + .collect() + }; + + for path in candidates { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + if path.file_stem().is_some_and(|s| s == name) + || crate::util::sha256_bytes(&bytes) == sha256 + { + if let Ok(parsed) = crate::diff::parse(&bytes) { + return parsed.single_file().ok().cloned(); + } + } + } + None +} + +fn link_row(link: Option<&LinkRecord>, live: Option<&sidecar::ConfigDoc>) -> Row { + let Some(link) = link else { + return Row::fail("uo-link installed", "no sidecar recorded in install.json") + .note("the overlay alone does not reach a website — see INSTALL.md §2"); + }; + let binary = Path::new(&link.binary.path); + if !binary.is_file() { + return Row::fail( + "uo-link installed", + format!("{} is missing", link.binary.path), + ); + } + + // The version line comes from the binary, the hash decides whether it is the one that was + // installed. A hand-replaced binary that still reports the right version is exactly the case a + // version string alone would call healthy. + let reported = + sidecar::version_line(binary).unwrap_or_else(|| format!("uo-link {}", link.version)); + let row = match crate::util::sha256_file(binary) { + Ok(actual) if actual.eq_ignore_ascii_case(&link.binary.sha256) => { + Row::ok("uo-link installed", reported) + } + Ok(_) => Row::warn("uo-link installed", reported).note(format!( + "{} is not the binary this installer recorded — it was replaced by hand or by another \ + tool", + link.binary.path + )), + Err(error) => Row::warn("uo-link installed", reported) + .note(format!("cannot hash {}: {error}", link.binary.path)), + }; + + // The paths the *binary* resolves, under the same environment the service definition pins (see + // `live_config`) — not the ones the record believes it was told. A sidecar reading a different + // config from the one the installer wrote is a failure mode nothing else here would surface. + match live { + Some(doc) => row.note(format!( + "config {} database {}", + doc.config_path, doc.store.path + )), + None => row.note(format!( + "config {} (not readable by this run) database {}", + link.config_path, link.db_path + )), + } +} + +fn service_row(link: Option<&LinkRecord>) -> Row { + let Some(link) = link else { + return Row::fail("Service", "nothing recorded"); + }; + let Some(service) = &link.service else { + // A deliberate outcome, not a bug: a host with no service manager the installer can drive + // gets the binary, the config, and printed instructions (PLAN.md Phase 2). + return Row::warn("Service", "not registered") + .note("the binary and config are installed but nothing runs them — INSTALL.md §7"); + }; + + let status = crate::service::observe(&service.kind, &service.name); + let account = service + .user + .as_deref() + .map(|u| format!(" as {u}")) + .unwrap_or_default(); + let detail = format!("{} {}{account}", service.name, status.detail); + + if !status.present { + Row::fail("Service", detail).note("re-run `install` to register it again") + } else if !status.running { + Row::fail("Service", detail) + .note("a service that will not stay up usually cannot read its config — INSTALL.md §7") + } else if !status.enabled { + Row::warn("Service", detail).note("it is running but will not come back after a reboot") + } else { + Row::ok("Service", detail) + } +} + +/// Asks the installed binary what it resolves — but only if it has a config to read. +/// +/// Two rules are load-bearing here: +/// +/// - **Only when the config already exists.** `--print-config` provisions: it writes the file and +/// mints a token when there is none. `doctor` must not write, and a diagnosis that created the +/// very state it was asked to report on would be worse than one that said "no config". +/// - **Under the environment the service runs with.** The systemd unit pins `UOLINK_DB_PATH` +/// wherever the database does not land beside the config (`crate::paths`), so a bare +/// `--print-config` would report the path the binary picks *on its own* — `/etc/runicgateway/` +/// rather than `/var/lib/runicgateway/` — and INSTALL.md §7 promises this row names the file the +/// service actually opens. +/// +/// The returned document holds the auth token. Nothing here reads it, and nothing prints it. +fn live_config(link: &LinkRecord) -> Option { + let config = Path::new(&link.config_path); + if !config.is_file() { + return None; + } + let db = Path::new(&link.db_path); + let db_env = (config.parent() != db.parent()).then_some(db); + sidecar::print_config(Path::new(&link.binary.path), config, db_env).ok() +} + +/// The sidecar's `/health`, which needs no auth and is therefore safe to ask for from here. +/// +/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and +/// this check is about whether the process on *this* host is answering. +fn health_of(bind: &str) -> Option { + let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind); + let url = format!("http://127.0.0.1:{port}/health"); + let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?; + serde_json::from_str(&body).ok() +} + +/// The sidecar's `/health` document. Every field is optional so a newer sidecar that drops or +/// renames one still produces a report rather than a parse failure. +#[derive(Debug, Clone, Deserialize)] +pub struct Health { + pub status: Option, + pub protocol: Option, + pub plugin_connected: Option, + pub database: Option, + pub uptime: Option, + pub last_event: Option, +} + +fn reachable_row(live: Option<&sidecar::ConfigDoc>, health: Option<&Health>) -> Row { + let Some(doc) = live else { + return Row::fail("Sidecar reachable", "could not read the sidecar's config").note( + "either the config file is gone or this process cannot read it — it is deliberately \ + readable only by root/Administrator and the service account", + ); + }; + let port = doc + .web + .bind + .rsplit_once(':') + .map(|(_, p)| p.to_string()) + .unwrap_or_else(|| doc.web.bind.clone()); + + match health { + Some(health) => { + let uptime = health + .uptime + .as_deref() + .map(|u| format!(", up {u}")) + .unwrap_or_default(); + let db = health.database.as_deref().unwrap_or("unknown"); + Row::ok( + "Sidecar reachable", + format!( + "127.0.0.1:{port} /health {}{uptime}, database {db}", + health.status.as_deref().unwrap_or("ok") + ), + ) + } + None => Row::fail( + "Sidecar reachable", + format!("nothing answered http://127.0.0.1:{port}/health"), + ) + .note("the binary is installed; this is about whether it is running and listening"), + } +} + +fn protocol_row( + record: &InstallRecord, + live: Option<&sidecar::ConfigDoc>, + health: Option<&Health>, +) -> Row { + let overlay = record.overlay.as_ref().map(|o| o.protocol); + // `/health` first: that is the number the website is answered with. `--print-config` is the + // same value from the same binary and covers a sidecar that is installed but not running. + let sidecar = health.and_then(|h| h.protocol).or(live.map(|d| d.protocol)); + + match (sidecar, overlay) { + (Some(s), Some(o)) if s == o => { + Row::ok("Protocol", format!("sidecar {s} = overlay manifest {o}")) + } + (Some(s), Some(o)) => Row::fail("Protocol", format!("sidecar {s} ≠ overlay manifest {o}")) + .note( + "the sidecar rejects a mismatched website with 409 rather than mis-parsing it; \ + this pair was never checked together — run `update` to move both to one bundle", + ), + (s, o) => Row::warn( + "Protocol", + format!( + "sidecar {}, overlay manifest {}", + s.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()), + o.map(|v| v.to_string()).unwrap_or_else(|| "unknown".into()) + ), + ), + } +} + +/// The row the rest of the report exists to make trustworthy. +/// +/// A shard that is not running cannot have dialed in, so that case is a `⚠` with the reason rather +/// than a `✗`: reporting a stopped shard as a failure would train operators to ignore this line, +/// which is the one line worth reading. +fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>) -> Row { + let running = root.ok().and_then(|r| servuo::find_running(&r.path)); + + match (health.and_then(|h| h.plugin_connected), running) { + (Some(true), _) => { + let last = health + .and_then(|h| h.last_event.clone()) + .map(|e| format!(" (last event {e})")) + .unwrap_or_default(); + Row::ok("Shard connected", format!("yes{last}")) + } + (Some(false), Some(shard)) => Row::fail( + "Shard connected", + format!( + "no — the shard is running (pid {}) but has not dialed in", + shard.pid + ), + ) + .note( + "the classic silent failure: ServUO ignores the script build's exit code and reloads \ + the previous Scripts.dll", + ) + .note("run `[bridge status` in game, and check [shard] bind against Config/Bridge.cfg"), + (Some(false), None) => Row::warn( + "Shard connected", + "no — the shard process is not running on this host", + ) + .note("start ServUO and re-run doctor; nothing reaches the website until it dials in"), + (None, _) => Row::warn( + "Shard connected", + "unknown — the sidecar did not answer /health", + ), + } +} + +/// Whether this deployment is still the current bundle. +/// +/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way +/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it +/// would report a working deployment as broken. +fn bundle_row(record: &InstallRecord) -> Row { + let url = bundle::url_for(None); + let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) { + Ok(bundle) => bundle, + Err(error) => { + return Row::warn( + "Bundle", + format!("{} — could not check for a newer one", record.bundle.tag), + ) + .note(error.to_string().replace('\n', " ")) + } + }; + + if current.bundle == record.bundle.tag { + return Row::ok("Bundle", format!("{} — up to date", record.bundle.tag)); + } + + let mut moves = Vec::new(); + if let Some(link) = record.link_record() { + if link.version != current.link.version { + moves.push(format!( + "uo-link {} → {}", + link.version, current.link.version + )); + } + } + if let Some(overlay) = &record.overlay { + if overlay.version != current.overlay.version { + moves.push(format!( + "overlay {} → {}", + overlay.version, current.overlay.version + )); + } + } + let detail = if moves.is_empty() { + format!( + "{} → {} (no component changed)", + record.bundle.tag, current.bundle + ) + } else { + format!( + "{} → {}: {}", + record.bundle.tag, + current.bundle, + moves.join(", ") + ) + }; + Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA}; + use std::collections::BTreeMap; + + fn record() -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: "2026-08-05T10:00:00Z".into(), + bundle: BundleRef { + tag: "2026.08.04".into(), + protocol: 3, + url: "https://example/current.json".into(), + }, + servuo: ServUoRef { + path: "/opt/ServUO".into(), + version: Some("57.4".into()), + }, + overlay: Some(OverlayRecord { + repo: "RunicGateway/servuo-plugins".into(), + tag: "v0.1.1".into(), + version: "0.1.1".into(), + commit: "3a52abb".into(), + protocol: 3, + files: BTreeMap::new(), + }), + link: None, + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + fn health(protocol: u32, connected: bool) -> Health { + Health { + status: Some("ok".into()), + protocol: Some(protocol), + plugin_connected: Some(connected), + database: Some("ok".into()), + uptime: Some("2m".into()), + last_event: Some("2026-08-05T10:00:00Z".into()), + } + } + + #[test] + fn the_documented_health_document_parses() { + // Copied from INSTALL.md §6 — the shape doctor's last three rows are read from. + let body = r#"{"status":"ok","protocol":3,"plugin_connected":true,"database":"ok", + "uptime":"2m","last_event":"2026-08-04T18:22:10.412Z"}"#; + let health: Health = serde_json::from_str(body).unwrap(); + assert_eq!(health.protocol, Some(3)); + assert_eq!(health.plugin_connected, Some(true)); + } + + #[test] + fn a_health_document_missing_fields_still_parses() { + // A newer sidecar dropping or renaming a key must degrade to an unknown row, not to a + // doctor that cannot report at all. + let health: Health = serde_json::from_str(r#"{"status":"ok"}"#).unwrap(); + assert_eq!(health.protocol, None); + assert_eq!(health.plugin_connected, None); + } + + #[test] + fn a_protocol_mismatch_fails_the_row() { + assert_eq!( + protocol_row(&record(), None, Some(&health(3, true))).mark, + Mark::Ok + ); + assert_eq!( + protocol_row(&record(), None, Some(&health(4, true))).mark, + Mark::Fail + ); + // Nothing to compare is not a failure — an unreachable sidecar is already its own ✗ row, + // and reporting the same outage twice buries the one that names the cause. + assert_eq!(protocol_row(&record(), None, None).mark, Mark::Warn); + } + + #[test] + fn a_stopped_shard_is_a_warning_and_a_silent_one_is_a_failure() { + // The distinction the whole row exists for: "you have not started it" and "it is running + // and the bridge is dead" are different problems, and only the second is broken. + let err = anyhow::anyhow!("no tree"); + assert_eq!( + shard_row(Err(&err), Some(&health(3, false))).mark, + Mark::Warn + ); + assert_eq!(shard_row(Err(&err), Some(&health(3, true))).mark, Mark::Ok); + assert_eq!(shard_row(Err(&err), None).mark, Mark::Warn); + } + + #[test] + fn an_empty_patch_tier_is_a_warning_not_a_failure() { + // The tier is optional and most shards will decline it. A ✗ there would make a correct + // install look broken forever. + let layout = paths::layout(); + let err = anyhow::anyhow!("no tree"); + assert_eq!(patch_row(&record(), Err(&err), &layout).mark, Mark::Warn); + } + + #[test] + fn a_missing_overlay_file_fails_and_an_edited_one_warns() { + let dir = crate::util::TempDir::new("rg-test-doctor").unwrap(); + let root = ServUoRoot { + path: dir.path().to_path_buf(), + version: Some("57.4".into()), + }; + std::fs::create_dir_all(dir.path().join("Scripts")).unwrap(); + std::fs::write(dir.path().join("Scripts/A.cs"), b"deployed").unwrap(); + let deployed = crate::util::sha256_bytes(b"deployed"); + + let mut record = record(); + let files = &mut record.overlay.as_mut().unwrap().files; + files.insert( + "Scripts/A.cs".into(), + FileRecord { + overlay_sha256: deployed.clone(), + on_disk_sha256: deployed.clone(), + state: "deployed".into(), + }, + ); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Ok); + + std::fs::write(dir.path().join("Scripts/A.cs"), b"edited by hand").unwrap(); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Warn); + + std::fs::remove_file(dir.path().join("Scripts/A.cs")).unwrap(); + assert_eq!(overlay_row(&record, Ok(&root)).mark, Mark::Fail); + } + + #[test] + fn an_operator_owned_file_is_never_reported_as_drift() { + // Bridge.cfg is *meant* to be edited in place; flagging it every run would teach operators + // to ignore this row. + let dir = crate::util::TempDir::new("rg-test-doctor-owned").unwrap(); + let root = ServUoRoot { + path: dir.path().to_path_buf(), + version: Some("57.4".into()), + }; + std::fs::create_dir_all(dir.path().join("Config")).unwrap(); + std::fs::write(dir.path().join("Config/Bridge.cfg"), b"edited again").unwrap(); + + let mut record = record(); + record.overlay.as_mut().unwrap().files.insert( + "Config/Bridge.cfg".into(), + FileRecord { + overlay_sha256: "aa".into(), + on_disk_sha256: "bb".into(), + state: "kept-operator-modified".into(), + }, + ); + let row = overlay_row(&record, Ok(&root)); + assert_eq!(row.mark, Mark::Ok); + assert!(row.detail.contains("operator-owned"), "{}", row.detail); + } +} diff --git a/src/install.rs b/src/install.rs index db6b708..b06447d 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,9 +1,18 @@ -//! The `install` command. +//! The `install` command — and, in [`Mode::Update`], the deployment half of `update`. //! //! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync //! the overlay, run the optional patch tier, install the sidecar and register its service, record //! what was deployed, and print the values the website needs. //! +//! **`update` is this same pipeline, not a second one.** PLAN.md §5 Phase 4 describes it as +//! "re-resolve the bundle, then move both components to it" — which is what an `install` over an +//! existing deployment already does, down to keeping a modified `Bridge.cfg` and restarting the +//! service after replacing its binary. Writing it twice would mean two places for the sync rules, +//! the protocol cross-checks and the record-carrying logic to disagree. What actually differs is +//! decided by [`Mode`] and is small: where the ServUO root comes from, whether a prior record is +//! required, how much of the patch tier is in scope, and what is printed at the end. The +//! update-only parts live in [`crate::update`]. +//! //! The order of the run is not incidental: //! //! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this @@ -31,8 +40,37 @@ use crate::servuo::ServUoRoot; use crate::util::TempDir; use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui}; +/// Which verb is driving the pipeline. +/// +/// The two runs are the same deployment; what differs is what may be assumed. An `install` may be +/// the first thing that ever ran on this host, so it detects or asks for a ServUO root and offers +/// the patch tier. An `update` is by definition a second run, so it already knows the tree, and its +/// tier scope is what a previous run recorded rather than a fresh offer (PLAN.md §5 Phase 4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + Install, + Update, +} + +impl Mode { + pub fn is_update(self) -> bool { + matches!(self, Self::Update) + } +} + pub fn run(cli: &Cli) -> Result<()> { + deploy(cli, Mode::Install) +} + +pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let layout = paths::layout(); + let record_path = layout.install_record(); + // Loaded before anything else because `update` is defined by it: without a record there is + // nothing to update, and the honest answer is to say so before touching the network. + let prior = InstallRecord::load(&record_path)?; + if mode.is_update() { + crate::update::require_prior(prior.as_ref(), &record_path)?; + } // ── What to install ────────────────────────────────────────────────────── // The bundle is resolved first, and its sidecar asset looked up immediately, so a run that @@ -41,8 +79,13 @@ pub fn run(cli: &Cli) -> Result<()> { let sidecar_asset = bundle.sidecar_asset()?.clone(); println!( - "\nRunic Gateway installer {} — bundle {} (protocol {}){}", + "\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}", env!("CARGO_PKG_VERSION"), + if mode.is_update() { + "update" + } else { + "install" + }, bundle.bundle, bundle.protocol, if cli.verify { @@ -54,7 +97,7 @@ pub fn run(cli: &Cli) -> Result<()> { println!(); // ── Where to install it ────────────────────────────────────────────────── - let root = resolve_root(cli)?; + let root = resolve_root(cli, mode, prior.as_ref())?; ui::row( "ServUO", &format!("{} ({})", root.path.display(), root.version_display()), @@ -137,8 +180,6 @@ pub fn run(cli: &Cli) -> Result<()> { } // ── Plan the sync ──────────────────────────────────────────────────────── - let record_path = layout.install_record(); - let prior = InstallRecord::load(&record_path)?; let prior_files = prior_overlay_files(prior.as_ref(), &root); let planned = overlay::plan(&unpacked, &root.path, prior_files)?; @@ -196,6 +237,7 @@ pub fn run(cli: &Cli) -> Result<()> { // near side of the running-shard check that guards it. let tier = tier::run( cli, + mode, &root, &unpacked, manifest.patch_tier.as_ref(), @@ -248,7 +290,12 @@ pub fn run(cli: &Cli) -> Result<()> { // ── Closing notes ──────────────────────────────────────────────────────── println!(); if cli.verify { - println!("Nothing was written. Re-run without --verify to deploy."); + // Worded for the verb that was typed, and said exactly once: `update`'s own closing block + // deliberately does not repeat it. + println!( + "Nothing was written. Re-run without --verify to {}.", + if mode.is_update() { "update" } else { "deploy" } + ); } else if summary.writes_anything() || tier.core_rebuild { if tier.core_rebuild { // Said again here, after everything else, because it is the one step whose omission @@ -272,27 +319,46 @@ pub fn run(cli: &Cli) -> Result<()> { // ── The one manual step ────────────────────────────────────────────────── // Last, and after the record, because it is the only thing left for the operator to do. The // token goes to the terminal and nowhere else (PLAN.md §6). - if let Some(sidecar) = &sidecar { - let host = resolve_host(cli); - println!( - "{}", - sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref()) - ); - if !sidecar.service.registered() { - ui::warn( - "No service was registered, so nothing is listening yet — the values above \ - describe the sidecar\n once you start it. See the steps printed above.", + // + // An `update` prints none of it. The token has not changed, the website already holds it, and + // reprinting a secret that nobody has to act on puts it in one more scrollback for no reason. + // What an update *can* change is the protocol number the website is configured with, and + // `update::closing` says so when it moved. + match (mode, &sidecar) { + (Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify), + (Mode::Install, Some(sidecar)) => { + let host = resolve_host(cli); + println!( + "{}", + sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref()) ); + if !sidecar.service.registered() { + ui::warn( + "No service was registered, so nothing is listening yet — the values above \ + describe the sidecar\n once you start it. See the steps printed above.", + ); + } } + (Mode::Install, None) => {} } Ok(()) } -/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt. -fn resolve_root(cli: &Cli) -> Result { +/// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection +/// (confirmed), else a prompt. +/// +/// An update never prompts and never guesses. The tree it is updating is the one `install.json` +/// names — detection could plausibly find a *different* shard on a host that has two, and moving a +/// deployment to another tree is not something an `update` should be able to do by accident. +fn resolve_root(cli: &Cli, mode: Mode, prior: Option<&InstallRecord>) -> Result { if let Some(path) = &cli.servuo { return servuo::open_stopped(&PathBuf::from(path)); } + if mode.is_update() { + if let Some(prior) = prior { + return servuo::open_stopped(&PathBuf::from(&prior.servuo.path)); + } + } if let Some(detected) = servuo::detect() { let question = format!("Use the ServUO installation at {}?", detected.display()); diff --git a/src/lib.rs b/src/lib.rs index 35fb1d5..0f41f3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,15 @@ //! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary //! existed, is `docs/installer/INSTALL.md`. //! -//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation, -//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service, -//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and -//! each of them says so when reached rather than failing as though it were a typo. +//! **This build implements Phases 1 to 4** — the whole of what `INSTALL.md` describes: bundle +//! resolution, ServUO detection and validation, the overlay sync, the optional patch tier, +//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two +//! commands `doctor`, `update` and `uninstall`. //! //! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same -//! convention as the sidecar's CLI. +//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that +//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that. +//! `uninstall` does the same for a step it could not carry out — everything else was still removed. //! //! ## Why the library target is called `rgdeploy` //! @@ -29,6 +31,7 @@ pub mod bundle; pub mod cli; pub mod diff; +pub mod doctor; pub mod install; pub mod net; pub mod overlay; @@ -40,6 +43,8 @@ pub mod servuo; pub mod sidecar; pub mod tier; pub mod ui; +pub mod uninstall; +pub mod update; pub mod util; use cli::{Command, Mode}; @@ -58,57 +63,36 @@ pub fn run() -> i32 { } }; - let result = match parsed.mode { + // Every arm yields the process exit code, because one of them has more than two outcomes: + // `doctor` completes successfully while reporting a broken deployment, and a monitoring script + // has to be able to tell that from a healthy one (see `doctor::run`). + let result: anyhow::Result = match parsed.mode { Mode::Help => { print!("{}", cli::USAGE); - Ok(()) + Ok(0) } Mode::Version => { println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION")); - Ok(()) + Ok(0) } - Mode::Run(Command::Install) => install::run(&parsed), - Mode::Run(command) => Err(not_implemented(command)), + Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0), + Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0), + Mode::Run(Command::Doctor) => doctor::run(&parsed), + Mode::Run(Command::Uninstall) => uninstall::run(&parsed), }; - if let Err(error) = result { - // The chain is printed, not just the outermost message: "cannot write - // /etc/runicgateway/install.json" is only actionable with the OS error still attached. - eprintln!("\nerror: {error}"); - for cause in error.chain().skip(1) { - eprintln!(" caused by: {cause}"); + match result { + Ok(code) => code, + Err(error) => { + // The chain is printed, not just the outermost message: "cannot write + // /etc/runicgateway/install.json" is only actionable with the OS error still attached. + eprintln!("\nerror: {error}"); + for cause in error.chain().skip(1) { + eprintln!(" caused by: {cause}"); + } + 1 } - return 1; } - 0 -} - -/// A command the contract documents but this phase has not built. -/// -/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished. -fn not_implemented(command: Command) -> anyhow::Error { - let (phase, workaround) = match command { - Command::Doctor => ( - "Phase 4", - "Check the deployment by hand: `[bridge status` in game, and \ - `curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).", - ), - Command::Update => ( - "Phase 4", - "Re-run `install` to move the overlay to the current bundle; replace the sidecar \ - binary by hand (INSTALL.md Appendix A6).", - ), - Command::Uninstall => ( - "Phase 4", - "Remove the sidecar service and binary by hand; the overlay files this installer \ - deployed are listed in install.json.", - ), - Command::Install => unreachable!("install is implemented"), - }; - anyhow::anyhow!( - "`{command}` is not implemented in this build — it arrives in {phase} \ - (see docs/installer/PLAN.md §5).\n{workaround}" - ) } #[cfg(test)] @@ -116,17 +100,17 @@ mod tests { use super::*; #[test] - fn unfinished_commands_name_their_phase_and_a_way_through() { - // An operator who runs `doctor` today must not be left thinking they typed it wrong, and - // must not be left with nothing to do either. - for command in [Command::Doctor, Command::Update, Command::Uninstall] { - let message = not_implemented(command).to_string(); - assert!(message.contains(&command.to_string()), "{message}"); - assert!(message.contains("Phase 4"), "{message}"); - assert!( - message.contains("INSTALL.md") || message.contains("install.json"), - "{message}" - ); + fn every_documented_command_has_an_implementation() { + // The published contract is INSTALL.md §2's four commands. This build answers all of them, + // so the parser and the dispatcher must not be able to drift apart — an unhandled arm here + // used to be a "not implemented" message, and is now a compile error by construction. + for command in [ + Command::Install, + Command::Doctor, + Command::Update, + Command::Uninstall, + ] { + assert!(cli::USAGE.contains(&command.to_string()), "{command}"); } } } diff --git a/src/net.rs b/src/net.rs index c407c3d..2049e74 100644 --- a/src/net.rs +++ b/src/net.rs @@ -21,22 +21,33 @@ fn user_agent() -> String { format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION")) } +/// The default global timeout. Generous because the overlay tarball travels over whatever link the +/// shard host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the +/// run with a message instead of hanging an operator's terminal indefinitely. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); + /// One agent per call is fine at this volume, and it keeps the timeouts in one place. -/// -/// The global timeout is generous because the overlay tarball travels over whatever link the shard -/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run -/// with a message instead of hanging an operator's terminal indefinitely. -fn agent() -> ureq::Agent { +fn agent(timeout: Duration) -> ureq::Agent { ureq::Agent::config_builder() .user_agent(user_agent()) - .timeout_global(Some(Duration::from_secs(300))) + .timeout_global(Some(timeout)) .build() .into() } /// Fetches a small text document (the bundle manifest). pub fn get_text(url: &str) -> Result { - let mut response = agent() + get_text_within(url, DEFAULT_TIMEOUT) +} + +/// Fetches a small text document, giving up after `timeout`. +/// +/// `doctor` uses this for both of its network calls, and the short timeout is the point: every one +/// of its rows is optional context around local state, so a host with no route out must produce a +/// report a few seconds later rather than a terminal that appears to have hung. The install path +/// keeps [`DEFAULT_TIMEOUT`], where a slow answer is still worth waiting for. +pub fn get_text_within(url: &str, timeout: Duration) -> Result { + let mut response = agent(timeout) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; @@ -60,7 +71,7 @@ pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Resul bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})"); } - let mut response = agent() + let mut response = agent(DEFAULT_TIMEOUT) .get(url) .call() .with_context(|| format!("cannot reach {url}"))?; diff --git a/src/service.rs b/src/service.rs index 3a2a81d..ce18d95 100644 --- a/src/service.rs +++ b/src/service.rs @@ -31,6 +31,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; +use crate::record::ServiceRecord; // `command_line` is used only by the Windows registration path, so it is qualified at its call // site rather than imported here — an unconditional import is an unused-import error on Linux. use crate::util::{run, run_ok}; @@ -471,6 +472,221 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> { } } +/// What the service manager says about a registered service, read without changing anything. +/// +/// Every field is answered by asking the manager rather than by trusting `install.json`: the record +/// says what registration *did*, and `doctor`'s job is to find out what is true now. A service an +/// operator disabled by hand is exactly the case a record cannot know about. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Status { + pub present: bool, + pub running: bool, + pub enabled: bool, + /// The one-line form for a `doctor` row — `running, enabled`, `stopped, enabled`, `not found`. + pub detail: String, +} + +impl Status { + fn absent(detail: impl Into) -> Self { + Self { + present: false, + running: false, + enabled: false, + detail: detail.into(), + } + } +} + +/// Reads back the state of the service `install.json` recorded, changing nothing. +/// +/// `kind` is taken from the record rather than from this platform so that a record written on the +/// other OS produces an honest "this host has no such manager" instead of a confident answer from +/// the wrong tool. +pub fn observe(kind: &str, name: &str) -> Status { + observe_platform(kind, name) +} + +#[cfg(unix)] +fn observe_platform(kind: &str, name: &str) -> Status { + if kind != "systemd" { + return Status::absent(format!("recorded as {kind}, which this host does not run")); + } + let active = one_word(run("systemctl", &["is-active", name])); + // `is-enabled` on an absent unit fails with an empty stdout, which `one_word` reports as + // "unknown" — so a unit that is neither known nor active is one systemd has never heard of. + let enabled = one_word(run("systemctl", &["is-enabled", name])); + if enabled == "unknown" && active != "active" { + return Status::absent("not found by systemd".to_string()); + } + Status { + present: true, + running: active == "active", + enabled: enabled == "enabled", + detail: format!("{active}, {enabled}"), + } +} + +#[cfg(windows)] +fn observe_platform(kind: &str, name: &str) -> Status { + if kind != "windows-scm" { + return Status::absent(format!("recorded as {kind}, which this host does not run")); + } + // Only the service this installer registers is queried by name; anything else would be reading + // another product's service out of a hand-edited record. + if name != WINDOWS_SERVICE || !windows_service_exists() { + return Status::absent("not registered with the service manager".to_string()); + } + let state = windows_service_state(); + let start = windows_start_type(); + Status { + present: true, + running: state.contains("RUNNING"), + enabled: start.contains("AUTO_START"), + detail: format!("{}, {}", state.to_lowercase(), start.to_lowercase()), + } +} + +/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but +/// was set to manual start is reported as such rather than as healthy. +#[cfg(windows)] +fn windows_start_type() -> String { + let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else { + return "unknown".to_string(); + }; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.trim_start().starts_with("START_TYPE") { + // " START_TYPE : 2 AUTO_START" + if let Some((_, value)) = line.split_once(':') { + return value.split_whitespace().last().unwrap_or("unknown").into(); + } + } + } + "unknown".to_string() +} + +/// What removing a service actually managed to do. +/// +/// Never an `Err`: `uninstall` has usually already removed something by the time this runs, so a +/// step that fails must be *reported* and the rest carried out. Ending halfway with an error would +/// leave a host in a state neither the record nor the operator can describe. +#[derive(Debug, Default, Clone)] +pub struct Removal { + pub done: Vec, + pub problems: Vec, +} + +/// Stops, disables and deletes the service recorded in `install.json`. +/// +/// The service account is removed only when the record says **this installer created it** +/// (PLAN.md §5): deleting an account that was already on the host is not this tool's business, and +/// on Windows there is nothing to delete — the SCM's virtual account goes with the service. +pub fn remove(record: &ServiceRecord) -> Removal { + remove_platform(record) +} + +#[cfg(unix)] +fn remove_platform(record: &ServiceRecord) -> Removal { + let mut out = Removal::default(); + if record.kind != "systemd" { + out.problems.push(format!( + "the record describes a {} service, which this host does not run — remove it from the \ + host that has it", + record.kind + )); + return out; + } + + // Neither stop nor disable is `run_ok`: a unit that is already stopped, already disabled, or + // gone entirely exits non-zero, and all three are the desired end state rather than failures. + let _ = run("systemctl", &["stop", &record.name]); + let _ = run("systemctl", &["disable", &record.name]); + out.done + .push(format!("stopped and disabled {}", record.name)); + + if let Some(unit) = &record.unit_path { + let path = Path::new(unit); + match std::fs::remove_file(path) { + Ok(()) => out.done.push(format!("removed {unit}")), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + out.done.push(format!("{unit} was already gone")) + } + Err(error) => out.problems.push(format!("cannot remove {unit}: {error}")), + } + } + let _ = run("systemctl", &["daemon-reload"]); + // A unit that failed before being removed stays listed as failed until this is run. + let _ = run("systemctl", &["reset-failed", &record.name]); + + if let (Some(user), true) = (record.user.as_deref(), record.user_created) { + match run_ok("userdel", &[user]).or_else(|_| run_ok("deluser", &[user])) { + Ok(_) => out.done.push(format!("removed the {user} service user")), + Err(error) => out.problems.push(format!( + "cannot remove the {user} service user ({}); remove it by hand if you want it gone", + error.to_string().replace('\n', " ") + )), + } + } else if let Some(user) = record.user.as_deref() { + out.done.push(format!( + "left the {user} account alone — this installer did not create it" + )); + } + out +} + +#[cfg(windows)] +fn remove_platform(record: &ServiceRecord) -> Removal { + let mut out = Removal::default(); + if record.kind != "windows-scm" { + out.problems.push(format!( + "the record describes a {} service, which this host does not run — remove it from the \ + host that has it", + record.kind + )); + return out; + } + if !windows_service_exists() { + out.done + .push(format!("{} was already unregistered", record.name)); + return out; + } + + // Stopping first is not politeness: `sc delete` on a running service only marks it for deletion, + // and the service — and its lock on the binary this uninstall is about to remove — survives + // until the process exits. + if let Err(error) = stop_windows_service() { + out.problems + .push(error.to_string().replace('\n', " ").to_string()); + } else { + out.done.push(format!("stopped {}", record.name)); + } + + match run("sc.exe", &["delete", &record.name]) { + // 1072 is ERROR_SERVICE_MARKED_FOR_DELETE: something still holds a handle (an open + // services.msc is the usual culprit) and the entry goes when it is released. + Ok(output) if output.status.success() => { + out.done + .push(format!("deleted the {} service", record.name)); + } + Ok(output) if output.status.code() == Some(1072) => out.done.push(format!( + "{} is marked for deletion — it disappears once whatever has it open (services.msc?) \ + is closed", + record.name + )), + Ok(output) => out.problems.push(format!( + "sc.exe delete {} failed with exit code {}", + record.name, + output.status.code().unwrap_or(-1) + )), + Err(error) => out + .problems + .push(format!("cannot run sc.exe delete: {error}")), + } + + // The virtual account exists only as long as the service does, so there is nothing to remove. + out +} + /// Registers, enables and starts the service — or explains why it did not. /// /// `binary_changed` decides restart versus start: a replaced binary under an already-running diff --git a/src/tier.rs b/src/tier.rs index 131c29a..b041a04 100644 --- a/src/tier.rs +++ b/src/tier.rs @@ -69,17 +69,35 @@ impl Outcome { /// /// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than /// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]). +/// +/// ## Scope under `update` +/// +/// An `update` re-resolves **only the features a previous run recorded as applied**, and does so +/// without asking again. Two things follow from that, and both are deliberate: +/// +/// - It is not a fresh offer. A shard that declined the tier stays unpatched through every update, +/// which is what "opt-in" has to mean if it means anything; the new release's features are named +/// so the operator knows they exist, and `--patches` is how they are taken up. +/// - Consent is not re-asked for what is already in the tree — including on an unsupported ServUO, +/// where `install` demanded a second flag. The record is the evidence that the operator opted in, +/// and re-prompting would make an unattended update of a working shard impossible on precisely +/// the hosts that most need the patches re-checked after an overlay moves. +/// +/// Normally every one of those re-resolutions lands on rung 0 and writes nothing. When a release +/// genuinely changes a patch, it is applied through the same ladder as any other — the target is +/// still a file the operator may have edited, and nothing here loosens that check. #[allow(clippy::too_many_arguments)] pub fn run( cli: &Cli, + mode: crate::install::Mode, root: &ServUoRoot, unpacked: &Path, declared: Option<&Tier>, layout: &paths::Layout, prior: &[FeatureRecord], ) -> Result { - let tier = Tier::resolve(declared); - if tier.features.is_empty() { + let declared_tier = Tier::resolve(declared); + if declared_tier.features.is_empty() { ui::row( "Patch tier", "not offered — this overlay declares no patches", @@ -88,6 +106,34 @@ pub fn run( } let supported = root.is_supported_version(); + // Under `update`, scope narrows to what is already applied unless --patches widens it back. + let widen = cli.patches == PatchChoice::Yes; + let tier = if mode.is_update() && !widen { + scope_to_applied(&declared_tier, prior) + } else { + declared_tier.clone() + }; + + if mode.is_update() && !widen { + if tier.features.is_empty() { + ui::row( + "Patch tier", + "nothing to re-check — no feature was applied by an earlier run", + ); + print_available(&declared_tier); + return Ok(Outcome::skipped()); + } + ui::row( + "Patch tier", + &format!( + "re-checking {} feature(s) an earlier run applied", + tier.features.len() + ), + ); + announce_new_features(&declared_tier, &tier); + return apply_tier(cli, root, unpacked, &tier, layout, prior, supported); + } + match consent(cli, root, supported, &tier)? { Consent::Yes => {} Consent::No(reason) => { @@ -114,6 +160,52 @@ pub fn run( apply_tier(cli, root, unpacked, &tier, layout, prior, supported) } +/// The subset of a release's tier that a previous run actually applied. +fn scope_to_applied(declared: &Tier, prior: &[FeatureRecord]) -> Tier { + let applied = patch::index_records(prior); + Tier { + features: declared + .features + .iter() + .filter(|f| applied.contains_key(f.name.as_str())) + .cloned() + .collect(), + } +} + +/// Names what this release offers that the shard does not have, without offering it. +/// +/// An update must not quietly become the moment a shard acquires edits to stock ServUO files, but +/// an operator who never learns the feature exists cannot opt in either. +fn announce_new_features(declared: &Tier, in_scope: &Tier) { + let scoped: Vec<&str> = in_scope.features.iter().map(|f| f.name.as_str()).collect(); + let new: Vec<&Feature> = declared + .features + .iter() + .filter(|f| !scoped.contains(&f.name.as_str())) + .collect(); + if new.is_empty() { + return; + } + println!( + " This release also offers {} feature(s) this shard does not have:", + new.len() + ); + for feature in new { + println!(" - {}", feature.summary); + } + println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); +} + +/// The tier's offer, printed by an update that has nothing of its own to re-check. +fn print_available(declared: &Tier) { + println!(" This release offers:"); + for feature in &declared.features { + println!(" - {}", feature.summary); + } + println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)"); +} + enum Consent { Yes, /// Not selected, with the reason to print. @@ -288,6 +380,19 @@ fn apply_tier( )); } + // A feature an earlier run applied that this release no longer declares still has its edits + // sitting in the ServUO tree. Its record is carried through rather than dropped: `uninstall` + // renders the hunks to revert from these entries, and a record that quietly forgot them would + // leave the operator with modified stock files and nothing saying so. + let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); + let carried: Vec<&FeatureRecord> = prior + .iter() + .filter(|r| !in_scope.contains(&r.feature.as_str())) + .collect(); + for record in &carried { + records.push((*record).clone()); + } + // ── Report ─────────────────────────────────────────────────────────────── println!(); ui::row( @@ -305,6 +410,13 @@ fn apply_tier( for line in lines { println!("{line}"); } + for record in &carried { + println!( + " · {:<30} applied by an earlier run; this release's tier does not describe it,\n \ + so it was left exactly as it is and its record kept", + record.feature + ); + } if core_rebuild { println!(); @@ -681,6 +793,58 @@ mod tests { ); } + #[test] + fn an_update_re_checks_only_what_an_earlier_run_applied() { + // The scope rule for `update`: it must not become the moment a shard acquires edits to + // stock ServUO files, and it must not stop re-checking the ones it already has. + let tier = Tier::builtin(); + let applied = |name: &str| FeatureRecord { + feature: name.into(), + rebuild: Rebuild::Scripts, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: Vec::new(), + companions: Vec::new(), + }; + + let scoped = scope_to_applied(&tier, &[applied("moderation-audit")]); + assert_eq!(scoped.features.len(), 1); + assert_eq!(scoped.features[0].name, "moderation-audit"); + + // A host that declined the tier stays declined through every update. + assert!(scope_to_applied(&tier, &[]).features.is_empty()); + // And one that took everything keeps re-checking everything. + let all: Vec = tier.features.iter().map(|f| applied(&f.name)).collect(); + assert_eq!( + scope_to_applied(&tier, &all).features.len(), + tier.features.len() + ); + } + + #[test] + fn a_feature_the_release_no_longer_declares_is_still_scoped_out_not_forgotten() { + // `scope_to_applied` can only return what the release declares, so a record for a feature + // that has been withdrawn falls outside it — which is exactly why `apply_tier` carries such + // records through instead of rebuilding the section from what it processed. + let tier = Tier { + features: vec![feature()], + }; + let withdrawn = FeatureRecord { + feature: "some-old-feature".into(), + rebuild: Rebuild::Scripts, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: Vec::new(), + companions: Vec::new(), + }; + assert!(scope_to_applied(&tier, std::slice::from_ref(&withdrawn)) + .features + .is_empty()); + + let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect(); + assert!(!in_scope.contains(&withdrawn.feature.as_str())); + } + #[test] fn nothing_that_was_not_written_is_reported_as_applied() { // Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch diff --git a/src/uninstall.rs b/src/uninstall.rs new file mode 100644 index 0000000..27a33bf --- /dev/null +++ b/src/uninstall.rs @@ -0,0 +1,640 @@ +//! The `uninstall` command — remove what the installer exclusively owns, print the rest. +//! +//! PLAN.md §5 is unusually specific about the shape of this command, and the reason is worth +//! keeping in front of whoever edits it: **the installer cannot know what the operator has changed +//! in their own ServUO tree since deployment.** A clever automatic revert — deleting the overlay's +//! files, reversing the patch hunks — would silently eat work that is not ours to judge. So this +//! command draws a hard line: +//! +//! | | | +//! |---|---| +//! | Removed | the sidecar binary, its service entry, `install.json` | +//! | Kept | `sidecar.toml`, `uo-link.db`, the cached patch set and the pre-patch originals (`--purge` drops them) | +//! | Printed, not done | every overlay file in the ServUO tree, and the exact hunks each applied patch added | +//! +//! ## Why the patch cache outlives the uninstall +//! +//! PLAN.md's table put the cached patch set under "removed", but the report this command prints +//! tells the operator to diff their stock files against the pre-patch copies under +//! `patches/originals/` — advice that the same command would have made impossible to follow. The +//! cache and the originals are the only offline record of what the tier changed once the release +//! tarball is gone, so they survive by default and `--purge` is what removes them, alongside the +//! config and the database. The report names every path it left behind. +//! +//! ## Why the report is a file as well as output +//! +//! It is the only thing the operator still needs after this command exits, and it arrives at the +//! end of the longest output the installer ever produces. A terminal's scrollback is not a place to +//! keep the list of files somebody has to go and delete by hand. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use crate::cli::Cli; +use crate::diff::HunkLine; +use crate::record::{now_rfc3339, InstallRecord, LinkRecord}; +use crate::{patch, paths, service, ui, util}; + +pub fn run(cli: &Cli) -> Result { + let layout = paths::layout(); + let record_path = layout.install_record(); + + println!( + "\nRunic Gateway installer {} — uninstall", + env!("CARGO_PKG_VERSION") + ); + + let Some(record) = InstallRecord::load(&record_path)? else { + println!(); + ui::warn(&format!( + "Nothing to uninstall — no deployment is recorded on this host.\n \ + Looked for {}\n \ + If this host is installed, this run cannot see its record: run as \ + root/Administrator, and set {} to the same value the install used (if any).", + record_path.display(), + paths::STATE_DIR_ENV + )); + return Ok(0); + }; + + let link = record.link_record(); + print_intent(&record, link.as_ref(), &layout, cli.purge); + + // Default **no**, because this is the one command that removes a running service and the + // listing above is what the operator is being asked about — a defaulted-yes prompt on a + // destructive action is answered by reflex rather than read. + // + // `--yes` is nevertheless a **yes** here, not "take the default". Everywhere else that flag + // answers an offer the run made (the patch tier, a detected ServUO root), so taking the safe + // default is right. Here the operator typed the destructive verb themselves; reading `--yes` as + // "no" would leave an unattended uninstall with no way to express itself at all, and a script + // that appeared to succeed while removing nothing is the worse of the two failures. + let proceed = if cli.assume_yes { + println!("Remove the components listed above? [y/N] (--yes)"); + true + } else { + ui::confirm("Remove the components listed above?", false, false)? + }; + if !proceed { + println!("\nNothing was removed."); + return Ok(0); + } + + // ── Remove what is exclusively ours ────────────────────────────────────── + println!(); + ui::heading("Removing"); + let mut done: Vec = Vec::new(); + let mut problems: Vec = Vec::new(); + + if let Some(link) = &link { + if let Some(service_record) = &link.service { + let removal = service::remove(service_record); + done.extend(removal.done); + problems.extend(removal.problems); + } + remove_file(Path::new(&link.binary.path), &mut done, &mut problems); + + if cli.purge { + remove_file(Path::new(&link.config_path), &mut done, &mut problems); + // The database is removed with its journal and WAL siblings; SQLite writes those beside + // it, and leaving them behind would confuse the next install rather than protect + // anything. + for suffix in ["", "-journal", "-wal", "-shm"] { + let path = PathBuf::from(format!("{}{suffix}", link.db_path)); + if path.exists() { + remove_file(&path, &mut done, &mut problems); + } + } + } else { + done.push(format!( + "kept {} and {} (--purge removes them)", + link.config_path, link.db_path + )); + } + } + + // The report is written before the record is, because it is rendered *from* the record. + let report = render_report(&record, link.as_ref(), &layout, cli.purge); + let report_path = write_report(&report, &layout); + + if cli.purge { + remove_dir(&layout.patches_dir(), &mut done, &mut problems); + } else if layout.patches_dir().exists() { + done.push(format!( + "kept {} — the cached patches and the pre-patch originals you need to revert by hand", + layout.patches_dir().display() + )); + } + remove_file(&record_path, &mut done, &mut problems); + + for line in &done { + println!(" · {line}"); + } + for problem in &problems { + println!(); + ui::warn(problem); + } + + // ── What only the operator can do ──────────────────────────────────────── + print!("{report}"); + match &report_path { + Ok(path) => println!("This report is also saved at:\n {}\n", path.display()), + Err(error) => ui::warn(&format!( + "The report above could not be saved to a file ({error}) — copy it out of this \ + terminal before you lose it." + )), + } + + // A step that could not be carried out is worth an exit code, for the same reason `doctor` has + // one: the run itself succeeded, and only the shell knows whether anybody is reading the + // output. Everything that *could* be removed still was. + Ok(if problems.is_empty() { 0 } else { 1 }) +} + +/// Says exactly what will happen, before asking. Nothing here touches the disk. +fn print_intent( + record: &InstallRecord, + link: Option<&LinkRecord>, + layout: &paths::Layout, + purge: bool, +) { + println!(); + ui::heading("This will remove"); + match link { + Some(link) => { + if let Some(service) = &link.service { + println!(" · the {} service", service.name); + if let (Some(user), true) = (service.user.as_deref(), service.user_created) { + println!(" · the {user} account, which the installer created"); + } + } + println!(" · {}", link.binary.path); + if purge { + println!(" · {} [--purge]", link.config_path); + println!(" · {} [--purge]", link.db_path); + } + } + None => println!(" · (no sidecar is recorded on this host)"), + } + println!(" · {}", layout.install_record().display()); + if purge { + println!(" · {} [--purge]", layout.patches_dir().display()); + } + + println!(); + ui::heading("This will NOT touch"); + println!(" · your ServUO tree — every deployed file is listed for you to delete"); + println!( + " · any patched stock file — the hunks to revert are printed with the rung each landed at" + ); + println!(" · your shard, which is neither stopped nor started"); + if !purge { + if let Some(link) = link { + println!(" · {} (the auth token)", link.config_path); + println!(" · {} (event history)", link.db_path); + } + println!( + " · {} (cached patches and pre-patch originals)", + layout.patches_dir().display() + ); + } + let _ = record; + println!(); +} + +/// The report: everything the operator has to finish by hand. +fn render_report( + record: &InstallRecord, + link: Option<&LinkRecord>, + layout: &paths::Layout, + purge: bool, +) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "\n{:=<78}\nRunic Gateway — what is left for you to do\ngenerated {} installer {}\n{:=<78}\n", + "", + now_rfc3339(), + env!("CARGO_PKG_VERSION"), + "" + ); + let _ = writeln!( + out, + "ServUO tree {}{}", + record.servuo.path, + record + .servuo + .version + .as_ref() + .map(|v| format!(" ({v})")) + .unwrap_or_default() + ); + if let Some(link) = link { + // Stated flatly, with no claim about what this run managed to remove: the report is + // rendered from the record and is about what is *left* to do. A line asserting "(removed)" + // is a line that can be wrong — a binary locked by a still-running process is exactly the + // case where it would be. + let _ = writeln!(out, "uo-link {}", link.version); + } + + render_overlay_section(&mut out, record); + render_patch_section(&mut out, record, layout, purge); + + let _ = writeln!( + out, + "The installer never deletes from a ServUO tree and never reverses a patch: it cannot know\n\ + what you have changed in those files since they were deployed. Both lists above are\n\ + yours to act on, or to ignore — an unused Bridge plugin is inert once the sidecar is gone.\n" + ); + out +} + +/// Every overlay file, by path, flagged where the copy on disk is no longer the one deployed. +/// +/// The flag is the point: an operator deleting this list file by file must not lose their own +/// `Bridge.cfg` settings, or an edit they made to a script, without being told which lines those +/// are. +fn render_overlay_section(out: &mut String, record: &InstallRecord) { + let Some(overlay) = &record.overlay else { + return; + }; + let root = Path::new(&record.servuo.path); + let _ = writeln!( + out, + "\n── Overlay files deployed into your ServUO tree ─────────────────────────────\n\n\ + {} file(s) from servuo-plugins {}. Delete them if you want the shard back to stock:\n", + overlay.files.len(), + overlay.version + ); + + for (rel, file) in &overlay.files { + let path = patch::join(root, rel); + let note = match util::sha256_file(&path) { + Err(_) => " (already gone)", + Ok(actual) if actual == file.on_disk_sha256 => "", + Ok(_) => " ← EDITED SINCE DEPLOYMENT — check before deleting", + }; + let _ = writeln!(out, " {}{note}", path.display()); + } + let _ = writeln!( + out, + "\n The Bridge scripts are inert without a sidecar, so leaving them in place is safe.\n" + ); +} + +/// The exact hunks each applied patch added, rendered from the cached `.patch` files. +/// +/// Rendered rather than referenced: the release tarball is long gone by the time somebody reads +/// this, and "apply the reverse of the patch" is not something an operator can do from a filename. +/// The rung each hunk landed at is printed with it, because a `region-match` apply means the +/// surrounding file was already the operator's and deserves a closer look than a stock-hash one. +fn render_patch_section( + out: &mut String, + record: &InstallRecord, + layout: &paths::Layout, + purge: bool, +) { + let features = record.patch_records(); + if features.is_empty() { + return; + } + + let _ = writeln!( + out, + "\n── Stock ServUO files this installer patched ────────────────────────────────\n" + ); + if features.iter().any(|f| f.unsupported_servuo) { + let _ = writeln!( + out, + " ⚠ Some of these were applied on an UNSUPPORTED ServUO version ({}).\n", + features + .iter() + .find_map(|f| f.servuo_version.clone()) + .unwrap_or_else(|| "unknown".into()) + ); + } + + for feature in &features { + let _ = writeln!(out, " Feature: {}", feature.feature); + for applied in &feature.patches { + let _ = writeln!( + out, + "\n {} → {}\n applied by: {}", + applied.name, + patch::join(Path::new(&record.servuo.path), &applied.target).display(), + applied.rung + ); + match render_hunks(layout, &applied.name, &applied.sha256) { + Some(text) => out.push_str(&text), + None => { + let _ = writeln!( + out, + " (the cached copy of this patch could not be read — the hunks it added \ + start\n near line {})", + applied + .hunks + .first() + .map(|h| h.matched_line) + .unwrap_or_default() + ); + } + } + } + if !feature.companions.is_empty() { + let _ = writeln!(out, "\n Companion files added by this feature:"); + for companion in &feature.companions { + let _ = writeln!( + out, + " {}", + patch::join(Path::new(&record.servuo.path), &companion.path).display() + ); + } + } + let _ = writeln!(out); + } + + let originals = layout.patch_originals_dir(); + if purge { + let _ = writeln!( + out, + " The pre-patch copies of these files were removed by --purge, so the lines above are\n\ + the only record of what changed.\n" + ); + } else if originals.exists() { + let _ = writeln!( + out, + " Each of those files as it was BEFORE the tier first touched it is kept here:\n \ + {}\n Diff against it rather than reversing the hunks by eye — after a region-match \ + apply the\n rest of the file was already yours.\n", + originals.display() + ); + } +} + +/// Renders one cached patch's added and removed lines, indented for the report. +fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option { + let path = layout.patches_dir().join(format!("{name}.patch")); + let bytes = std::fs::read(&path).ok()?; + if !sha256.is_empty() && util::sha256_bytes(&bytes) != sha256 { + // Not fatal — a re-run with a newer release can legitimately have replaced the cache — but + // the operator should know the text below is not byte-for-byte what was applied. + let parsed = crate::diff::parse(&bytes).ok()?; + let file = parsed.single_file().ok()?; + let mut out = String::from( + " (the cached patch differs from the one recorded; showing the cached copy)\n", + ); + out.push_str(&hunk_text(file)); + return Some(out); + } + let parsed = crate::diff::parse(&bytes).ok()?; + Some(hunk_text(parsed.single_file().ok()?)) +} + +fn hunk_text(file: &crate::diff::FilePatch) -> String { + let mut out = String::new(); + for hunk in &file.hunks { + let _ = writeln!( + out, + " @@ around line {} @@", + if hunk.old_start > 0 { + hunk.old_start + } else { + 1 + } + ); + for line in &hunk.lines { + let (sign, bytes) = match line { + HunkLine::Context(b) => (' ', b), + HunkLine::Added(b) => ('+', b), + HunkLine::Removed(b) => ('-', b), + }; + let _ = writeln!(out, " {sign}{}", String::from_utf8_lossy(bytes)); + } + } + out +} + +/// Writes the report where the operator ran the command, falling back to the state directory. +/// +/// The working directory is the one place they are certainly looking; `/etc/runicgateway` is being +/// emptied by this very command, and a report inside a directory the operator has just been told is +/// gone is a report nobody finds. +fn write_report(report: &str, layout: &paths::Layout) -> Result { + let name = format!( + "runicgateway-uninstall-{}.txt", + now_rfc3339().replace([':', '-'], "").replace('Z', "") + ); + let cwd = std::env::current_dir().unwrap_or_else(|_| layout.state_dir.clone()); + let primary = cwd.join(&name); + if util::write_atomic(&primary, report.as_bytes()).is_ok() { + return Ok(primary); + } + let fallback = layout.state_dir.join(&name); + util::write_atomic(&fallback, report.as_bytes()).with_context(|| { + format!( + "cannot write the uninstall report to {}", + fallback.display() + ) + })?; + Ok(fallback) +} + +fn remove_file(path: &Path, done: &mut Vec, problems: &mut Vec) { + match std::fs::remove_file(path) { + Ok(()) => done.push(format!("removed {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + done.push(format!("{} was already gone", path.display())) + } + Err(error) => { + // A permission error on the sidecar binary is nearly always a running process holding + // it, not an access-control problem: Windows locks a running executable, and a service + // this command knows about was already stopped above. Saying so beats sending the + // operator to look at ACLs. + let hint = if error.kind() == std::io::ErrorKind::PermissionDenied { + "\n If something is still running it — a sidecar started by hand, or a service \ + this installer did not register — stop that first and delete the file." + } else { + "" + }; + problems.push(format!("cannot remove {}: {error}{hint}", path.display())) + } + } +} + +fn remove_dir(path: &Path, done: &mut Vec, problems: &mut Vec) { + if !path.exists() { + return; + } + match std::fs::remove_dir_all(path) { + Ok(()) => done.push(format!("removed {}", path.display())), + Err(error) => problems.push(format!("cannot remove {}: {error}", path.display())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::patch::{AppliedPatch, FeatureRecord, Rebuild}; + use crate::record::{BundleRef, FileRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA}; + use std::collections::BTreeMap; + + const PATCH: &[u8] = b"\ +--- a/Scripts/Commands/Logging.cs ++++ b/Scripts/Commands/Logging.cs +@@ -10,3 +10,4 @@ public static class CommandLogging + public static void WriteLine() + { ++ BridgeModerationAudit.Raise(); + } +"; + + fn record(root: &Path) -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: "2026-08-05T10:00:00Z".into(), + bundle: BundleRef { + tag: "2026.08.04".into(), + protocol: 3, + url: "https://example/current.json".into(), + }, + servuo: ServUoRef { + path: root.display().to_string(), + version: Some("57.4".into()), + }, + overlay: Some(OverlayRecord { + repo: "RunicGateway/servuo-plugins".into(), + tag: "v0.1.1".into(), + version: "0.1.1".into(), + commit: "3a52abb".into(), + protocol: 3, + files: BTreeMap::from([ + ( + "Scripts/Custom/Bridge/BridgeLink.cs".to_string(), + FileRecord { + overlay_sha256: util::sha256_bytes(b"deployed"), + on_disk_sha256: util::sha256_bytes(b"deployed"), + state: "deployed".into(), + }, + ), + ( + "Config/Bridge.cfg".to_string(), + FileRecord { + overlay_sha256: util::sha256_bytes(b"shipped"), + on_disk_sha256: util::sha256_bytes(b"mine"), + state: "kept-operator-modified".into(), + }, + ), + ]), + }), + link: None, + patches: vec![serde_json::to_value(FeatureRecord { + feature: "moderation-audit".into(), + rebuild: Rebuild::Scripts, + servuo_version: Some("57.4".into()), + unsupported_servuo: false, + patches: vec![AppliedPatch { + name: "commandlogging-event".into(), + target: "Scripts/Commands/Logging.cs".into(), + rung: "region-match".into(), + sha256: util::sha256_bytes(PATCH), + hunks: Vec::new(), + }], + companions: Vec::new(), + }) + .unwrap()], + extra: BTreeMap::new(), + } + } + + fn layout_in(dir: &Path) -> paths::Layout { + paths::Layout { + state_dir: dir.to_path_buf(), + data_dir: dir.join("data"), + sidecar_bin: dir.join("bin").join("uo-link-sidecar"), + relocated: true, + } + } + + #[test] + fn the_report_lists_every_overlay_file_and_flags_the_edited_ones() { + let dir = util::TempDir::new("rg-test-uninstall").unwrap(); + let root = dir.path().join("ServUO"); + std::fs::create_dir_all(root.join("Scripts/Custom/Bridge")).unwrap(); + std::fs::create_dir_all(root.join("Config")).unwrap(); + std::fs::write( + root.join("Scripts/Custom/Bridge/BridgeLink.cs"), + b"deployed", + ) + .unwrap(); + // Edited after deployment: the operator must be warned before deleting this one. + std::fs::write(root.join("Config/Bridge.cfg"), b"changed again").unwrap(); + + let layout = layout_in(dir.path()); + let record = record(&root); + let report = render_report(&record, None, &layout, false); + + assert!(report.contains("BridgeLink.cs"), "{report}"); + assert!( + report.contains("EDITED SINCE DEPLOYMENT"), + "the edited Bridge.cfg must be flagged:\n{report}" + ); + } + + #[test] + fn the_report_renders_the_hunks_from_the_cached_patch() { + // The whole reason the tier caches its patches: this text has to be produceable long after + // the release tarball is gone. + let dir = util::TempDir::new("rg-test-uninstall-hunks").unwrap(); + let layout = layout_in(dir.path()); + std::fs::create_dir_all(layout.patches_dir()).unwrap(); + std::fs::write( + layout.patches_dir().join("commandlogging-event.patch"), + PATCH, + ) + .unwrap(); + + let report = render_report(&record(&dir.path().join("ServUO")), None, &layout, false); + assert!(report.contains("commandlogging-event"), "{report}"); + assert!( + report.contains("+ BridgeModerationAudit.Raise();"), + "the added line must appear verbatim:\n{report}" + ); + assert!(report.contains("region-match"), "{report}"); + } + + #[test] + fn a_missing_patch_cache_degrades_to_a_line_number() { + // --purge on an earlier run, or a hand-cleaned /etc: the report still has to say something + // useful rather than claim there was nothing to revert. + let dir = util::TempDir::new("rg-test-uninstall-nocache").unwrap(); + let report = render_report( + &record(&dir.path().join("ServUO")), + None, + &layout_in(dir.path()), + false, + ); + assert!(report.contains("could not be read"), "{report}"); + assert!(report.contains("commandlogging-event"), "{report}"); + } + + #[test] + fn the_report_is_written_where_the_operator_is_standing() { + let dir = util::TempDir::new("rg-test-uninstall-report").unwrap(); + let path = write_report("hello", &layout_in(dir.path())).unwrap(); + assert!(path.is_file()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + assert!( + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("runicgateway-uninstall-"), + "{path:?}" + ); + let _ = std::fs::remove_file(path); + } +} diff --git a/src/update.rs b/src/update.rs new file mode 100644 index 0000000..3394604 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,218 @@ +//! The `update` command — move an existing deployment to the current bundle. +//! +//! The deployment itself is [`crate::install`] in [`Mode::Update`]; this module holds only what is +//! genuinely different, which is smaller than it looks: +//! +//! - **A prior record is required.** `update` on a host that has never been installed is a typo, or +//! a state directory the run cannot see — never a reason to perform a first install under a verb +//! that promises to preserve what is already there. +//! - **Both halves move together.** The bundle is the compat matrix (PLAN.md §7.1): resolving it +//! and taking both components from it is what stops an update from landing two independently +//! latest artifacts whose protocol versions disagree. That property comes free from reusing the +//! install pipeline — it is stated here because it is the whole reason `update` is not simply +//! "download the newest sidecar". +//! - **The close is a diff, not a handoff.** What moved, what the operator must now do (restart the +//! shard; and, only if the protocol number changed, edit one field in Admin → Shard), and nothing +//! else. The auth token is not reprinted: it has not changed, the website already has it, and a +//! secret that requires no action does not belong in another terminal scrollback. +//! +//! What `update` deliberately does **not** do is restart the shard (the installer never owns +//! another process's lifecycle — PLAN.md §8) or widen the patch tier on its own. The tier's scope +//! under this verb is `[crate::tier]`'s business: features a previous run recorded are re-resolved +//! against the new release, and anything new is named but not applied without `--patches`. + +use std::path::Path; + +use anyhow::{bail, Result}; + +use crate::bundle::Bundle; +use crate::cli::Cli; +use crate::install::{self, Mode}; +use crate::record::InstallRecord; +use crate::{paths, ui}; + +pub fn run(cli: &Cli) -> Result<()> { + install::deploy(cli, Mode::Update) +} + +/// Refuses an update on a host with nothing recorded. +/// +/// Phrased around the state directory rather than the verb, because the overwhelmingly likely cause +/// is a run that cannot see the state it is looking for: a re-run without the `RUNICGATEWAY_STATE_DIR` +/// that the install used, or an unelevated shell on Windows. +pub fn require_prior(prior: Option<&InstallRecord>, record_path: &Path) -> Result<()> { + if prior.is_some() { + return Ok(()); + } + bail!( + "there is no deployment to update — {} does not exist.\n\ + Run `install` to deploy for the first time. If this host *is* installed, this run cannot \ + see its record: check that you are running as root/Administrator, and that {} is set to \ + the same value the install used (if any).", + record_path.display(), + paths::STATE_DIR_ENV + ) +} + +/// The end of an update: what moved, and what the operator has to do about it. +pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallRecord, verify: bool) { + let moves = describe_moves(prior, now); + + println!(); + ui::heading(if verify { + "Would move [--verify]" + } else { + "Updated" + }); + if moves.is_empty() { + println!(" Both halves were already on bundle {}.", bundle.bundle); + } else { + for line in &moves { + println!(" {line}"); + } + } + + // The one thing an update can change that the *website* has to be told about. The token, the + // URLs and the ports are all unchanged, so this is the only reason to reopen Admin → Shard — + // and it must be said plainly, because a stale number there is answered with 409 by the + // sidecar rather than mis-parsed, which looks to an operator like the shard going offline. + let previous_protocol = prior.map(|p| p.bundle.protocol); + if previous_protocol.is_some_and(|p| p != bundle.protocol) { + println!(); + ui::warn(&format!( + "The protocol version changed: {} → {}.\n \ + Update the Protocol version field in Admin → Shard on your website. Nothing else \ + changed —\n the URLs and the auth token are the same, and the sidecar answers a \ + website still set to\n {} with 409 rather than mis-parsing it.", + previous_protocol.unwrap_or(bundle.protocol), + bundle.protocol, + previous_protocol.unwrap_or(bundle.protocol), + )); + } + + // No "nothing was written" line here: the shared closing in `install::deploy` has already said + // it, in the wording of the verb that was typed. Saying it twice reads like two dry runs. +} + +/// The version moves between two records, as printed lines. +/// +/// Compared per component rather than by bundle tag: a new bundle whose components happen to be +/// unchanged is not something to report as an upgrade, and the tag alone cannot say which half +/// actually moved. +fn describe_moves(prior: Option<&InstallRecord>, now: &InstallRecord) -> Vec { + let Some(prior) = prior else { + return Vec::new(); + }; + let mut moves = Vec::new(); + + if prior.bundle.tag != now.bundle.tag { + moves.push(format!( + "bundle {} → {}", + prior.bundle.tag, now.bundle.tag + )); + } + match (prior.link_record(), now.link_record()) { + (Some(before), Some(after)) if before.version != after.version => moves.push(format!( + "uo-link {} → {} (service restarted)", + before.version, after.version + )), + _ => {} + } + match (&prior.overlay, &now.overlay) { + (Some(before), Some(after)) if before.version != after.version => moves.push(format!( + "overlay {} → {} (ServUO must be restarted to compile it)", + before.version, after.version + )), + _ => {} + } + moves +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::record::{ + BinaryRef, BundleRef, InstallerInfo, LinkRecord, OverlayRecord, ServUoRef, SCHEMA, + }; + use std::collections::BTreeMap; + + fn record(bundle_tag: &str, protocol: u32, link: &str, overlay: &str) -> InstallRecord { + InstallRecord { + schema: SCHEMA, + installer: InstallerInfo { + version: "0.1.0".into(), + }, + updated: "2026-08-05T10:00:00Z".into(), + bundle: BundleRef { + tag: bundle_tag.into(), + protocol, + url: "https://example/current.json".into(), + }, + servuo: ServUoRef { + path: "/opt/ServUO".into(), + version: Some("57.4".into()), + }, + overlay: Some(OverlayRecord { + repo: "RunicGateway/servuo-plugins".into(), + tag: format!("v{overlay}"), + version: overlay.into(), + commit: "3a52abb".into(), + protocol, + files: BTreeMap::new(), + }), + link: serde_json::to_value(LinkRecord { + repo: "RunicGateway/link".into(), + tag: format!("v{link}"), + version: link.into(), + protocol, + binary: BinaryRef { + path: "/usr/bin/runicgateway-link".into(), + sha256: "aa".into(), + }, + config_path: "/etc/runicgateway/sidecar.toml".into(), + db_path: "/var/lib/runicgateway/uo-link.db".into(), + service: None, + }) + .ok(), + patches: Vec::new(), + extra: BTreeMap::new(), + } + } + + #[test] + fn an_update_with_nothing_recorded_is_refused_with_the_state_dir_named() { + // The failure this message exists for is a run that cannot *see* an install, not one that + // has none — so the text has to point at the state directory, not just say "run install". + let error = require_prior(None, Path::new("/etc/runicgateway/install.json")).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("install.json"), "{message}"); + assert!(message.contains(paths::STATE_DIR_ENV), "{message}"); + assert!(require_prior(Some(&record("a", 3, "1.1.0", "0.1.1")), Path::new("x")).is_ok()); + } + + #[test] + fn only_components_that_actually_moved_are_reported() { + let before = record("2026.08.04", 3, "1.1.0", "0.1.1"); + let after = record("2026.09.01", 3, "1.2.0", "0.1.1"); + let moves = describe_moves(Some(&before), &after); + + assert!( + moves.iter().any(|m| m.contains("uo-link 1.1.0 → 1.2.0")), + "{moves:?}" + ); + // The overlay did not move, so nothing may tell the operator to restart their shard for it. + assert!(!moves.iter().any(|m| m.contains("overlay")), "{moves:?}"); + assert!(moves.iter().any(|m| m.contains("bundle")), "{moves:?}"); + } + + #[test] + fn a_new_bundle_with_unchanged_components_reports_only_the_bundle() { + // The nightly cron can publish a new tag whose matrix is identical; calling that an upgrade + // would send an operator looking for a change that does not exist. + let before = record("2026.08.04", 3, "1.1.0", "0.1.1"); + let after = record("2026.08.05", 3, "1.1.0", "0.1.1"); + let moves = describe_moves(Some(&before), &after); + assert_eq!(moves.len(), 1, "{moves:?}"); + assert!(moves[0].contains("bundle"), "{moves:?}"); + } +}