feat(installer): implement Phase 4 — doctor, update and uninstall
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 59s
Completes the command surface INSTALL.md §2 published before the binary
existed. With this, `edge` cuts a binary that does everything that guide
describes.
doctor (src/doctor.rs)
Reads only. Every row is answered by asking the thing itself — the
installed binary (--version, --print-config), the service manager, and
the sidecar's /health — because the record says what `install` did,
which is a different question from what is true now. --print-config is
run ONLY when the config already exists: that flag provisions, and a
diagnosis must not create the state it reports on. It is also run under
the environment the service pins (UOLINK_DB_PATH), so the config and
database it names are the ones the service opens, not the ones the
binary would pick on its own.
Exit 1 when any row failed, so a monitoring script can read it; a ⚠
never does that. A stopped shard is therefore a ⚠, not a ✗ — "you have
not started it" and "it is running and the bridge is dead" are
different problems and only the second is broken. Offline is a ⚠ too:
a shard host with no route to Gitea is a supported way to run this.
The patch row re-resolves each recorded patch against the tree from the
cached .patch, so a core upgrade or a restored backup that silently
removed the tier's edits is caught — nothing else here would notice.
update (src/update.rs, install.rs::Mode)
The same pipeline as install, not a second one: PLAN.md describes it as
"re-resolve the bundle, then move both components to it", which is what
an install over an existing deployment already does. Writing it twice
would give the sync rules and the protocol cross-checks two places to
disagree. What differs is small and lives in Mode — a prior record is
required, the tree comes from that record rather than detection, the
patch tier's scope narrows, and the close is a diff instead of a
handoff.
The token is not reprinted: it has not changed and the website has it.
A changed protocol number IS called out, because a stale value in
Admin → Shard is answered with 409 and looks like the shard going
offline.
Tier scope: features an earlier run recorded are re-resolved without
asking again (the record is the evidence of consent, including on an
unsupported ServUO); anything new the release offers is named but not
applied without --patches. A shard that declined stays declined.
uninstall (src/uninstall.rs, service::remove)
Removes the binary, the service and install.json; prints the overlay
files and the exact hunks, rendered from the cached patches with the
rung each landed at. Files edited since deployment are flagged so
nobody deletes their own work blind. The report is also written to a
file in the working directory — it is the only thing still needed after
the command exits, and it arrives at the end of the longest output this
tool produces.
Two deviations from PLAN.md §5, both deliberate:
- The cached patch set and patches/originals/ SURVIVE. That table put
them under "removed", but the report tells the operator to diff
against those originals — advice the same command would have made
impossible to follow. --purge removes them, with the config and the
database.
- --yes means yes here, not "take the default". The prompt defaults to
no (destructive), but the operator typed the verb; reading --yes as
"no" would leave an unattended uninstall unable to express itself,
and a script that appears to succeed while removing nothing is the
worse failure.
Exit 1 if a step could not be carried out — everything else still was.
Verified on this machine against a scratch tree built from the real
ServUO 57.4 files: a healthy doctor (exit 0), one with a deleted overlay
file, an edited one and a reverted patch (all three found, exit 1), a
--verify update that wrote nothing, a real update that repaired all three
and left install.json byte-identical, uninstall with and without --purge,
a second uninstall, and doctor/update on a host with no record. Linux
fmt/clippy/tests run in Docker as well as the Windows host.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
218
src/update.rs
Normal file
218
src/update.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user