feat(installer): implement Phase 4 — doctor, update and uninstall #7
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/install.rs
102
src/install.rs
@@ -1,9 +1,18 @@
|
|||||||
//! The `install` command.
|
//! The `install` command — and, in [`Mode::Update`], the deployment half of `update`.
|
||||||
//!
|
//!
|
||||||
//! Phases 1 to 3 of `docs/installer/PLAN.md`: resolve the bundle, validate the ServUO root, sync
|
//! 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,27 +319,46 @@ 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 {
|
//
|
||||||
let host = resolve_host(cli);
|
// An `update` prints none of it. The token has not changed, the website already holds it, and
|
||||||
println!(
|
// 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
|
||||||
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
|
// `update::closing` says so when it moved.
|
||||||
);
|
match (mode, &sidecar) {
|
||||||
if !sidecar.service.registered() {
|
(Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify),
|
||||||
ui::warn(
|
(Mode::Install, Some(sidecar)) => {
|
||||||
"No service was registered, so nothing is listening yet — the values above \
|
let host = resolve_host(cli);
|
||||||
describe the sidecar\n once you start it. See the steps printed above.",
|
println!(
|
||||||
|
"{}",
|
||||||
|
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
|
||||||
);
|
);
|
||||||
|
if !sidecar.service.registered() {
|
||||||
|
ui::warn(
|
||||||
|
"No service was registered, so nothing is listening yet — the values above \
|
||||||
|
describe the sidecar\n once you start it. See the steps printed above.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
(Mode::Install, None) => {}
|
||||||
}
|
}
|
||||||
Ok(())
|
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());
|
||||||
|
|||||||
98
src/lib.rs
98
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 {
|
||||||
// The chain is printed, not just the outermost message: "cannot write
|
Ok(code) => code,
|
||||||
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
Err(error) => {
|
||||||
eprintln!("\nerror: {error}");
|
// The chain is printed, not just the outermost message: "cannot write
|
||||||
for cause in error.chain().skip(1) {
|
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||||
eprintln!(" caused by: {cause}");
|
eprintln!("\nerror: {error}");
|
||||||
|
for cause in error.chain().skip(1) {
|
||||||
|
eprintln!(" caused by: {cause}");
|
||||||
|
}
|
||||||
|
1
|
||||||
}
|
}
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A command the contract documents but this phase has not built.
|
|
||||||
///
|
|
||||||
/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished.
|
|
||||||
fn not_implemented(command: Command) -> anyhow::Error {
|
|
||||||
let (phase, workaround) = match command {
|
|
||||||
Command::Doctor => (
|
|
||||||
"Phase 4",
|
|
||||||
"Check the deployment by hand: `[bridge status` in game, and \
|
|
||||||
`curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).",
|
|
||||||
),
|
|
||||||
Command::Update => (
|
|
||||||
"Phase 4",
|
|
||||||
"Re-run `install` to move the overlay to the current bundle; replace the sidecar \
|
|
||||||
binary by hand (INSTALL.md Appendix A6).",
|
|
||||||
),
|
|
||||||
Command::Uninstall => (
|
|
||||||
"Phase 4",
|
|
||||||
"Remove the sidecar service and binary by hand; the overlay files this installer \
|
|
||||||
deployed are listed in install.json.",
|
|
||||||
),
|
|
||||||
Command::Install => unreachable!("install is implemented"),
|
|
||||||
};
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"`{command}` is not implemented in this build — it arrives in {phase} \
|
|
||||||
(see docs/installer/PLAN.md §5).\n{workaround}"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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