Merge pull request 'feat(installer): implement Phase 4 — doctor, update and uninstall' (#7) from feat/phase4-diagnostics-updates into edge

Reviewed-on: #7
This commit is contained in:
2026-08-05 08:49:44 +00:00
9 changed files with 2271 additions and 85 deletions

View File

@@ -135,6 +135,9 @@ Options:
--site-url <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.

884
src/doctor.rs Normal file
View File

@@ -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<String>,
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
detail: detail.into(),
notes: Vec::new(),
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> 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<i32> {
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<ServUoRoot> {
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<String> = 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<crate::diff::FilePatch> {
let by_name = layout.patches_dir().join(format!("{name}.patch"));
let candidates: Vec<PathBuf> = 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<sidecar::ConfigDoc> {
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<Health> {
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<String>,
pub protocol: Option<u32>,
pub plugin_connected: Option<bool>,
pub database: Option<String>,
pub uptime: Option<String>,
pub last_event: Option<String>,
}
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);
}
}

View File

@@ -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,7 +319,14 @@ 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 {
//
// 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!(
"{}",
@@ -285,14 +339,26 @@ pub fn run(cli: &Cli) -> Result<()> {
);
}
}
(Mode::Install, None) => {}
}
Ok(())
}
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
/// 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<ServUoRoot> {
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());

View File

@@ -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<i32> = 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 {
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}");
}
return 1;
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}");
}
}
}

View File

@@ -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<String> {
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<String> {
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}"))?;

View File

@@ -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<String>) -> 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<String>,
pub problems: Vec<String>,
}
/// 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

View File

@@ -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<Outcome> {
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<FeatureRecord> = 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

640
src/uninstall.rs Normal file
View File

@@ -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<i32> {
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<String> = Vec::new();
let mut problems: Vec<String> = 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<String> {
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<PathBuf> {
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<String>, problems: &mut Vec<String>) {
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<String>, problems: &mut Vec<String>) {
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);
}
}

218
src/update.rs Normal file
View File

@@ -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<String> {
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:?}");
}
}