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:
@@ -135,6 +135,9 @@ Options:
|
|||||||
--site-url <URL> install. Your site's base URL, for the
|
--site-url <URL> install. Your site's base URL, for the
|
||||||
Admin → Shard link.
|
Admin → Shard link.
|
||||||
--yes Assume the default answer to every prompt.
|
--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
|
--purge uninstall. Also delete sidecar.toml and
|
||||||
uo-link.db, which are otherwise kept.
|
uo-link.db, which are otherwise kept.
|
||||||
-V, --version Print the installer version and exit.
|
-V, --version Print the installer version and exit.
|
||||||
|
|||||||
884
src/doctor.rs
Normal file
884
src/doctor.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
//! 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
|
//! 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.
|
//! 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:
|
//! The order of the run is not incidental:
|
||||||
//!
|
//!
|
||||||
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
|
//! 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::util::TempDir;
|
||||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
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<()> {
|
pub fn run(cli: &Cli) -> Result<()> {
|
||||||
|
deploy(cli, Mode::Install)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||||
let layout = paths::layout();
|
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 ──────────────────────────────────────────────────────
|
// ── What to install ──────────────────────────────────────────────────────
|
||||||
// The bundle is resolved first, and its sidecar asset looked up immediately, so a run that
|
// 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();
|
let sidecar_asset = bundle.sidecar_asset()?.clone();
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\nRunic Gateway installer {} — bundle {} (protocol {}){}",
|
"\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}",
|
||||||
env!("CARGO_PKG_VERSION"),
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
if mode.is_update() {
|
||||||
|
"update"
|
||||||
|
} else {
|
||||||
|
"install"
|
||||||
|
},
|
||||||
bundle.bundle,
|
bundle.bundle,
|
||||||
bundle.protocol,
|
bundle.protocol,
|
||||||
if cli.verify {
|
if cli.verify {
|
||||||
@@ -54,7 +97,7 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
|
|
||||||
// ── Where to install it ──────────────────────────────────────────────────
|
// ── Where to install it ──────────────────────────────────────────────────
|
||||||
let root = resolve_root(cli)?;
|
let root = resolve_root(cli, mode, prior.as_ref())?;
|
||||||
ui::row(
|
ui::row(
|
||||||
"ServUO",
|
"ServUO",
|
||||||
&format!("{} ({})", root.path.display(), root.version_display()),
|
&format!("{} ({})", root.path.display(), root.version_display()),
|
||||||
@@ -137,8 +180,6 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Plan the sync ────────────────────────────────────────────────────────
|
// ── 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 prior_files = prior_overlay_files(prior.as_ref(), &root);
|
||||||
|
|
||||||
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
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.
|
// near side of the running-shard check that guards it.
|
||||||
let tier = tier::run(
|
let tier = tier::run(
|
||||||
cli,
|
cli,
|
||||||
|
mode,
|
||||||
&root,
|
&root,
|
||||||
&unpacked,
|
&unpacked,
|
||||||
manifest.patch_tier.as_ref(),
|
manifest.patch_tier.as_ref(),
|
||||||
@@ -248,7 +290,12 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
// ── Closing notes ────────────────────────────────────────────────────────
|
// ── Closing notes ────────────────────────────────────────────────────────
|
||||||
println!();
|
println!();
|
||||||
if cli.verify {
|
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 {
|
} else if summary.writes_anything() || tier.core_rebuild {
|
||||||
if tier.core_rebuild {
|
if tier.core_rebuild {
|
||||||
// Said again here, after everything else, because it is the one step whose omission
|
// 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 ──────────────────────────────────────────────────
|
// ── The one manual step ──────────────────────────────────────────────────
|
||||||
// Last, and after the record, because it is the only thing left for the operator to do. The
|
// 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).
|
// 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);
|
let host = resolve_host(cli);
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
@@ -285,14 +339,26 @@ pub fn run(cli: &Cli) -> Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
(Mode::Install, None) => {}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
|
/// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection
|
||||||
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
|
/// (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 {
|
if let Some(path) = &cli.servuo {
|
||||||
return servuo::open_stopped(&PathBuf::from(path));
|
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() {
|
if let Some(detected) = servuo::detect() {
|
||||||
let question = format!("Use the ServUO installation at {}?", detected.display());
|
let question = format!("Use the ServUO installation at {}?", detected.display());
|
||||||
|
|||||||
86
src/lib.rs
86
src/lib.rs
@@ -4,13 +4,15 @@
|
|||||||
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
//! record is `docs/installer/PLAN.md`; the operator-facing contract, written before this binary
|
||||||
//! existed, is `docs/installer/INSTALL.md`.
|
//! existed, is `docs/installer/INSTALL.md`.
|
||||||
//!
|
//!
|
||||||
//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation,
|
//! **This build implements Phases 1 to 4** — the whole of what `INSTALL.md` describes: bundle
|
||||||
//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service,
|
//! resolution, ServUO detection and validation, the overlay sync, the optional patch tier,
|
||||||
//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and
|
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
|
||||||
//! each of them says so when reached rather than failing as though it were a typo.
|
//! commands `doctor`, `update` and `uninstall`.
|
||||||
//!
|
//!
|
||||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
//! 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`
|
//! ## Why the library target is called `rgdeploy`
|
||||||
//!
|
//!
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
pub mod bundle;
|
pub mod bundle;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod doctor;
|
||||||
pub mod install;
|
pub mod install;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod overlay;
|
pub mod overlay;
|
||||||
@@ -40,6 +43,8 @@ pub mod servuo;
|
|||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod tier;
|
pub mod tier;
|
||||||
pub mod ui;
|
pub mod ui;
|
||||||
|
pub mod uninstall;
|
||||||
|
pub mod update;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|
||||||
use cli::{Command, Mode};
|
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 => {
|
Mode::Help => {
|
||||||
print!("{}", cli::USAGE);
|
print!("{}", cli::USAGE);
|
||||||
Ok(())
|
Ok(0)
|
||||||
}
|
}
|
||||||
Mode::Version => {
|
Mode::Version => {
|
||||||
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||||
Ok(())
|
Ok(0)
|
||||||
}
|
}
|
||||||
Mode::Run(Command::Install) => install::run(&parsed),
|
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
|
||||||
Mode::Run(command) => Err(not_implemented(command)),
|
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
|
// The chain is printed, not just the outermost message: "cannot write
|
||||||
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||||
eprintln!("\nerror: {error}");
|
eprintln!("\nerror: {error}");
|
||||||
for cause in error.chain().skip(1) {
|
for cause in error.chain().skip(1) {
|
||||||
eprintln!(" caused by: {cause}");
|
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)]
|
#[cfg(test)]
|
||||||
@@ -116,17 +100,17 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unfinished_commands_name_their_phase_and_a_way_through() {
|
fn every_documented_command_has_an_implementation() {
|
||||||
// An operator who runs `doctor` today must not be left thinking they typed it wrong, and
|
// The published contract is INSTALL.md §2's four commands. This build answers all of them,
|
||||||
// must not be left with nothing to do either.
|
// so the parser and the dispatcher must not be able to drift apart — an unhandled arm here
|
||||||
for command in [Command::Doctor, Command::Update, Command::Uninstall] {
|
// used to be a "not implemented" message, and is now a compile error by construction.
|
||||||
let message = not_implemented(command).to_string();
|
for command in [
|
||||||
assert!(message.contains(&command.to_string()), "{message}");
|
Command::Install,
|
||||||
assert!(message.contains("Phase 4"), "{message}");
|
Command::Doctor,
|
||||||
assert!(
|
Command::Update,
|
||||||
message.contains("INSTALL.md") || message.contains("install.json"),
|
Command::Uninstall,
|
||||||
"{message}"
|
] {
|
||||||
);
|
assert!(cli::USAGE.contains(&command.to_string()), "{command}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/net.rs
27
src/net.rs
@@ -21,22 +21,33 @@ fn user_agent() -> String {
|
|||||||
format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION"))
|
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.
|
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
|
||||||
///
|
fn agent(timeout: Duration) -> ureq::Agent {
|
||||||
/// 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 {
|
|
||||||
ureq::Agent::config_builder()
|
ureq::Agent::config_builder()
|
||||||
.user_agent(user_agent())
|
.user_agent(user_agent())
|
||||||
.timeout_global(Some(Duration::from_secs(300)))
|
.timeout_global(Some(timeout))
|
||||||
.build()
|
.build()
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches a small text document (the bundle manifest).
|
/// Fetches a small text document (the bundle manifest).
|
||||||
pub fn get_text(url: &str) -> Result<String> {
|
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)
|
.get(url)
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("cannot reach {url}"))?;
|
.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:?})");
|
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)
|
.get(url)
|
||||||
.call()
|
.call()
|
||||||
.with_context(|| format!("cannot reach {url}"))?;
|
.with_context(|| format!("cannot reach {url}"))?;
|
||||||
|
|||||||
216
src/service.rs
216
src/service.rs
@@ -31,6 +31,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
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
|
// `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.
|
// site rather than imported here — an unconditional import is an unused-import error on Linux.
|
||||||
use crate::util::{run, run_ok};
|
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.
|
/// 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
|
/// `binary_changed` decides restart versus start: a replaced binary under an already-running
|
||||||
|
|||||||
168
src/tier.rs
168
src/tier.rs
@@ -69,17 +69,35 @@ impl Outcome {
|
|||||||
///
|
///
|
||||||
/// `prior` is the previous run's records: on rung 0 they are preserved verbatim rather than
|
/// `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`]).
|
/// 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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn run(
|
pub fn run(
|
||||||
cli: &Cli,
|
cli: &Cli,
|
||||||
|
mode: crate::install::Mode,
|
||||||
root: &ServUoRoot,
|
root: &ServUoRoot,
|
||||||
unpacked: &Path,
|
unpacked: &Path,
|
||||||
declared: Option<&Tier>,
|
declared: Option<&Tier>,
|
||||||
layout: &paths::Layout,
|
layout: &paths::Layout,
|
||||||
prior: &[FeatureRecord],
|
prior: &[FeatureRecord],
|
||||||
) -> Result<Outcome> {
|
) -> Result<Outcome> {
|
||||||
let tier = Tier::resolve(declared);
|
let declared_tier = Tier::resolve(declared);
|
||||||
if tier.features.is_empty() {
|
if declared_tier.features.is_empty() {
|
||||||
ui::row(
|
ui::row(
|
||||||
"Patch tier",
|
"Patch tier",
|
||||||
"not offered — this overlay declares no patches",
|
"not offered — this overlay declares no patches",
|
||||||
@@ -88,6 +106,34 @@ pub fn run(
|
|||||||
}
|
}
|
||||||
let supported = root.is_supported_version();
|
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)? {
|
match consent(cli, root, supported, &tier)? {
|
||||||
Consent::Yes => {}
|
Consent::Yes => {}
|
||||||
Consent::No(reason) => {
|
Consent::No(reason) => {
|
||||||
@@ -114,6 +160,52 @@ pub fn run(
|
|||||||
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
|
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 {
|
enum Consent {
|
||||||
Yes,
|
Yes,
|
||||||
/// Not selected, with the reason to print.
|
/// 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 ───────────────────────────────────────────────────────────────
|
// ── Report ───────────────────────────────────────────────────────────────
|
||||||
println!();
|
println!();
|
||||||
ui::row(
|
ui::row(
|
||||||
@@ -305,6 +410,13 @@ fn apply_tier(
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
println!("{line}");
|
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 {
|
if core_rebuild {
|
||||||
println!();
|
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]
|
#[test]
|
||||||
fn nothing_that_was_not_written_is_reported_as_applied() {
|
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
|
// Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch
|
||||||
|
|||||||
640
src/uninstall.rs
Normal file
640
src/uninstall.rs
Normal 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
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