Merge pull request 'feat(installer): implement Phase 4 — doctor, update and uninstall' (#7) from feat/phase4-diagnostics-updates into edge
Reviewed-on: #7
This commit is contained in:
@@ -135,6 +135,9 @@ Options:
|
||||
--site-url <URL> install. Your site's base URL, for the
|
||||
Admin → Shard link.
|
||||
--yes Assume the default answer to every prompt.
|
||||
On uninstall it means yes: that prompt
|
||||
defaults to no, and typing `uninstall
|
||||
--yes` is not an accident.
|
||||
--purge uninstall. Also delete sidecar.toml and
|
||||
uo-link.db, which are otherwise kept.
|
||||
-V, --version Print the installer version and exit.
|
||||
|
||||
884
src/doctor.rs
Normal file
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
|
||||
//! the overlay, run the optional patch tier, install the sidecar and register its service, record
|
||||
//! what was deployed, and print the values the website needs.
|
||||
//!
|
||||
//! **`update` is this same pipeline, not a second one.** PLAN.md §5 Phase 4 describes it as
|
||||
//! "re-resolve the bundle, then move both components to it" — which is what an `install` over an
|
||||
//! existing deployment already does, down to keeping a modified `Bridge.cfg` and restarting the
|
||||
//! service after replacing its binary. Writing it twice would mean two places for the sync rules,
|
||||
//! the protocol cross-checks and the record-carrying logic to disagree. What actually differs is
|
||||
//! decided by [`Mode`] and is small: where the ServUO root comes from, whether a prior record is
|
||||
//! required, how much of the patch tier is in scope, and what is printed at the end. The
|
||||
//! update-only parts live in [`crate::update`].
|
||||
//!
|
||||
//! The order of the run is not incidental:
|
||||
//!
|
||||
//! 1. **Resolve everything that can fail cheaply first** — the bundle, the sidecar asset for this
|
||||
@@ -31,8 +40,37 @@ use crate::servuo::ServUoRoot;
|
||||
use crate::util::TempDir;
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
||||
|
||||
/// Which verb is driving the pipeline.
|
||||
///
|
||||
/// The two runs are the same deployment; what differs is what may be assumed. An `install` may be
|
||||
/// the first thing that ever ran on this host, so it detects or asks for a ServUO root and offers
|
||||
/// the patch tier. An `update` is by definition a second run, so it already knows the tree, and its
|
||||
/// tier scope is what a previous run recorded rather than a fresh offer (PLAN.md §5 Phase 4).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn is_update(self) -> bool {
|
||||
matches!(self, Self::Update)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<()> {
|
||||
deploy(cli, Mode::Install)
|
||||
}
|
||||
|
||||
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
let layout = paths::layout();
|
||||
let record_path = layout.install_record();
|
||||
// Loaded before anything else because `update` is defined by it: without a record there is
|
||||
// nothing to update, and the honest answer is to say so before touching the network.
|
||||
let prior = InstallRecord::load(&record_path)?;
|
||||
if mode.is_update() {
|
||||
crate::update::require_prior(prior.as_ref(), &record_path)?;
|
||||
}
|
||||
|
||||
// ── What to install ──────────────────────────────────────────────────────
|
||||
// The bundle is resolved first, and its sidecar asset looked up immediately, so a run that
|
||||
@@ -41,8 +79,13 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
let sidecar_asset = bundle.sidecar_asset()?.clone();
|
||||
|
||||
println!(
|
||||
"\nRunic Gateway installer {} — bundle {} (protocol {}){}",
|
||||
"\nRunic Gateway installer {} — {} to bundle {} (protocol {}){}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
if mode.is_update() {
|
||||
"update"
|
||||
} else {
|
||||
"install"
|
||||
},
|
||||
bundle.bundle,
|
||||
bundle.protocol,
|
||||
if cli.verify {
|
||||
@@ -54,7 +97,7 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
println!();
|
||||
|
||||
// ── Where to install it ──────────────────────────────────────────────────
|
||||
let root = resolve_root(cli)?;
|
||||
let root = resolve_root(cli, mode, prior.as_ref())?;
|
||||
ui::row(
|
||||
"ServUO",
|
||||
&format!("{} ({})", root.path.display(), root.version_display()),
|
||||
@@ -137,8 +180,6 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
}
|
||||
|
||||
// ── Plan the sync ────────────────────────────────────────────────────────
|
||||
let record_path = layout.install_record();
|
||||
let prior = InstallRecord::load(&record_path)?;
|
||||
let prior_files = prior_overlay_files(prior.as_ref(), &root);
|
||||
|
||||
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||
@@ -196,6 +237,7 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
// near side of the running-shard check that guards it.
|
||||
let tier = tier::run(
|
||||
cli,
|
||||
mode,
|
||||
&root,
|
||||
&unpacked,
|
||||
manifest.patch_tier.as_ref(),
|
||||
@@ -248,7 +290,12 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
// ── Closing notes ────────────────────────────────────────────────────────
|
||||
println!();
|
||||
if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
// Worded for the verb that was typed, and said exactly once: `update`'s own closing block
|
||||
// deliberately does not repeat it.
|
||||
println!(
|
||||
"Nothing was written. Re-run without --verify to {}.",
|
||||
if mode.is_update() { "update" } else { "deploy" }
|
||||
);
|
||||
} else if summary.writes_anything() || tier.core_rebuild {
|
||||
if tier.core_rebuild {
|
||||
// Said again here, after everything else, because it is the one step whose omission
|
||||
@@ -272,27 +319,46 @@ pub fn run(cli: &Cli) -> Result<()> {
|
||||
// ── The one manual step ──────────────────────────────────────────────────
|
||||
// Last, and after the record, because it is the only thing left for the operator to do. The
|
||||
// token goes to the terminal and nowhere else (PLAN.md §6).
|
||||
if let Some(sidecar) = &sidecar {
|
||||
let host = resolve_host(cli);
|
||||
println!(
|
||||
"{}",
|
||||
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
|
||||
);
|
||||
if !sidecar.service.registered() {
|
||||
ui::warn(
|
||||
"No service was registered, so nothing is listening yet — the values above \
|
||||
describe the sidecar\n once you start it. See the steps printed above.",
|
||||
//
|
||||
// An `update` prints none of it. The token has not changed, the website already holds it, and
|
||||
// reprinting a secret that nobody has to act on puts it in one more scrollback for no reason.
|
||||
// What an update *can* change is the protocol number the website is configured with, and
|
||||
// `update::closing` says so when it moved.
|
||||
match (mode, &sidecar) {
|
||||
(Mode::Update, _) => crate::update::closing(prior.as_ref(), &bundle, &record, cli.verify),
|
||||
(Mode::Install, Some(sidecar)) => {
|
||||
let host = resolve_host(cli);
|
||||
println!(
|
||||
"{}",
|
||||
sidecar::handoff(&sidecar.doc, &host, cli.site_url.as_deref())
|
||||
);
|
||||
if !sidecar.service.registered() {
|
||||
ui::warn(
|
||||
"No service was registered, so nothing is listening yet — the values above \
|
||||
describe the sidecar\n once you start it. See the steps printed above.",
|
||||
);
|
||||
}
|
||||
}
|
||||
(Mode::Install, None) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
|
||||
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
|
||||
/// Resolves the ServUO root: `--servuo`, else the recorded tree on an update, else detection
|
||||
/// (confirmed), else a prompt.
|
||||
///
|
||||
/// An update never prompts and never guesses. The tree it is updating is the one `install.json`
|
||||
/// names — detection could plausibly find a *different* shard on a host that has two, and moving a
|
||||
/// deployment to another tree is not something an `update` should be able to do by accident.
|
||||
fn resolve_root(cli: &Cli, mode: Mode, prior: Option<&InstallRecord>) -> Result<ServUoRoot> {
|
||||
if let Some(path) = &cli.servuo {
|
||||
return servuo::open_stopped(&PathBuf::from(path));
|
||||
}
|
||||
if mode.is_update() {
|
||||
if let Some(prior) = prior {
|
||||
return servuo::open_stopped(&PathBuf::from(&prior.servuo.path));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(detected) = servuo::detect() {
|
||||
let question = format!("Use the ServUO installation at {}?", detected.display());
|
||||
|
||||
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
|
||||
//! existed, is `docs/installer/INSTALL.md`.
|
||||
//!
|
||||
//! **This build implements Phases 1 to 3:** bundle resolution, ServUO detection and validation,
|
||||
//! the overlay sync, the optional patch tier, `install.json`, the uo-link sidecar and its service,
|
||||
//! and the token handoff. `doctor`, `update` and `uninstall` (Phase 4) are not implemented, and
|
||||
//! each of them says so when reached rather than failing as though it were a typo.
|
||||
//! **This build implements Phases 1 to 4** — the whole of what `INSTALL.md` describes: bundle
|
||||
//! resolution, ServUO detection and validation, the overlay sync, the optional patch tier,
|
||||
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
|
||||
//! commands `doctor`, `update` and `uninstall`.
|
||||
//!
|
||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||
//! convention as the sidecar's CLI.
|
||||
//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that
|
||||
//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that.
|
||||
//! `uninstall` does the same for a step it could not carry out — everything else was still removed.
|
||||
//!
|
||||
//! ## Why the library target is called `rgdeploy`
|
||||
//!
|
||||
@@ -29,6 +31,7 @@
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod diff;
|
||||
pub mod doctor;
|
||||
pub mod install;
|
||||
pub mod net;
|
||||
pub mod overlay;
|
||||
@@ -40,6 +43,8 @@ pub mod servuo;
|
||||
pub mod sidecar;
|
||||
pub mod tier;
|
||||
pub mod ui;
|
||||
pub mod uninstall;
|
||||
pub mod update;
|
||||
pub mod util;
|
||||
|
||||
use cli::{Command, Mode};
|
||||
@@ -58,57 +63,36 @@ pub fn run() -> i32 {
|
||||
}
|
||||
};
|
||||
|
||||
let result = match parsed.mode {
|
||||
// Every arm yields the process exit code, because one of them has more than two outcomes:
|
||||
// `doctor` completes successfully while reporting a broken deployment, and a monitoring script
|
||||
// has to be able to tell that from a healthy one (see `doctor::run`).
|
||||
let result: anyhow::Result<i32> = match parsed.mode {
|
||||
Mode::Help => {
|
||||
print!("{}", cli::USAGE);
|
||||
Ok(())
|
||||
Ok(0)
|
||||
}
|
||||
Mode::Version => {
|
||||
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
Ok(0)
|
||||
}
|
||||
Mode::Run(Command::Install) => install::run(&parsed),
|
||||
Mode::Run(command) => Err(not_implemented(command)),
|
||||
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
|
||||
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
|
||||
Mode::Run(Command::Doctor) => doctor::run(&parsed),
|
||||
Mode::Run(Command::Uninstall) => uninstall::run(&parsed),
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
// The chain is printed, not just the outermost message: "cannot write
|
||||
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||
eprintln!("\nerror: {error}");
|
||||
for cause in error.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
match result {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
// The chain is printed, not just the outermost message: "cannot write
|
||||
// /etc/runicgateway/install.json" is only actionable with the OS error still attached.
|
||||
eprintln!("\nerror: {error}");
|
||||
for cause in error.chain().skip(1) {
|
||||
eprintln!(" caused by: {cause}");
|
||||
}
|
||||
1
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// A command the contract documents but this phase has not built.
|
||||
///
|
||||
/// Exit `1`, not `2`: the operator typed something valid, and the tool is what is unfinished.
|
||||
fn not_implemented(command: Command) -> anyhow::Error {
|
||||
let (phase, workaround) = match command {
|
||||
Command::Doctor => (
|
||||
"Phase 4",
|
||||
"Check the deployment by hand: `[bridge status` in game, and \
|
||||
`curl -s http://127.0.0.1:8080/health` on the shard host (INSTALL.md §6).",
|
||||
),
|
||||
Command::Update => (
|
||||
"Phase 4",
|
||||
"Re-run `install` to move the overlay to the current bundle; replace the sidecar \
|
||||
binary by hand (INSTALL.md Appendix A6).",
|
||||
),
|
||||
Command::Uninstall => (
|
||||
"Phase 4",
|
||||
"Remove the sidecar service and binary by hand; the overlay files this installer \
|
||||
deployed are listed in install.json.",
|
||||
),
|
||||
Command::Install => unreachable!("install is implemented"),
|
||||
};
|
||||
anyhow::anyhow!(
|
||||
"`{command}` is not implemented in this build — it arrives in {phase} \
|
||||
(see docs/installer/PLAN.md §5).\n{workaround}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -116,17 +100,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unfinished_commands_name_their_phase_and_a_way_through() {
|
||||
// An operator who runs `doctor` today must not be left thinking they typed it wrong, and
|
||||
// must not be left with nothing to do either.
|
||||
for command in [Command::Doctor, Command::Update, Command::Uninstall] {
|
||||
let message = not_implemented(command).to_string();
|
||||
assert!(message.contains(&command.to_string()), "{message}");
|
||||
assert!(message.contains("Phase 4"), "{message}");
|
||||
assert!(
|
||||
message.contains("INSTALL.md") || message.contains("install.json"),
|
||||
"{message}"
|
||||
);
|
||||
fn every_documented_command_has_an_implementation() {
|
||||
// The published contract is INSTALL.md §2's four commands. This build answers all of them,
|
||||
// so the parser and the dispatcher must not be able to drift apart — an unhandled arm here
|
||||
// used to be a "not implemented" message, and is now a compile error by construction.
|
||||
for command in [
|
||||
Command::Install,
|
||||
Command::Doctor,
|
||||
Command::Update,
|
||||
Command::Uninstall,
|
||||
] {
|
||||
assert!(cli::USAGE.contains(&command.to_string()), "{command}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
27
src/net.rs
27
src/net.rs
@@ -21,22 +21,33 @@ fn user_agent() -> String {
|
||||
format!("runicgateway-installer/{}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// The default global timeout. Generous because the overlay tarball travels over whatever link the
|
||||
/// shard host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the
|
||||
/// run with a message instead of hanging an operator's terminal indefinitely.
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// One agent per call is fine at this volume, and it keeps the timeouts in one place.
|
||||
///
|
||||
/// The global timeout is generous because the overlay tarball travels over whatever link the shard
|
||||
/// host has, and a slow VPS is not a failure. It exists so a black-holed connection ends the run
|
||||
/// with a message instead of hanging an operator's terminal indefinitely.
|
||||
fn agent() -> ureq::Agent {
|
||||
fn agent(timeout: Duration) -> ureq::Agent {
|
||||
ureq::Agent::config_builder()
|
||||
.user_agent(user_agent())
|
||||
.timeout_global(Some(Duration::from_secs(300)))
|
||||
.timeout_global(Some(timeout))
|
||||
.build()
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Fetches a small text document (the bundle manifest).
|
||||
pub fn get_text(url: &str) -> Result<String> {
|
||||
let mut response = agent()
|
||||
get_text_within(url, DEFAULT_TIMEOUT)
|
||||
}
|
||||
|
||||
/// Fetches a small text document, giving up after `timeout`.
|
||||
///
|
||||
/// `doctor` uses this for both of its network calls, and the short timeout is the point: every one
|
||||
/// of its rows is optional context around local state, so a host with no route out must produce a
|
||||
/// report a few seconds later rather than a terminal that appears to have hung. The install path
|
||||
/// keeps [`DEFAULT_TIMEOUT`], where a slow answer is still worth waiting for.
|
||||
pub fn get_text_within(url: &str, timeout: Duration) -> Result<String> {
|
||||
let mut response = agent(timeout)
|
||||
.get(url)
|
||||
.call()
|
||||
.with_context(|| format!("cannot reach {url}"))?;
|
||||
@@ -60,7 +71,7 @@ pub fn download_verified(url: &str, dest: &Path, expected_sha256: &str) -> Resul
|
||||
bail!("refusing to download {url}: the bundle records an unusable SHA256 ({expected_sha256:?})");
|
||||
}
|
||||
|
||||
let mut response = agent()
|
||||
let mut response = agent(DEFAULT_TIMEOUT)
|
||||
.get(url)
|
||||
.call()
|
||||
.with_context(|| format!("cannot reach {url}"))?;
|
||||
|
||||
216
src/service.rs
216
src/service.rs
@@ -31,6 +31,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::record::ServiceRecord;
|
||||
// `command_line` is used only by the Windows registration path, so it is qualified at its call
|
||||
// site rather than imported here — an unconditional import is an unused-import error on Linux.
|
||||
use crate::util::{run, run_ok};
|
||||
@@ -471,6 +472,221 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the service manager says about a registered service, read without changing anything.
|
||||
///
|
||||
/// Every field is answered by asking the manager rather than by trusting `install.json`: the record
|
||||
/// says what registration *did*, and `doctor`'s job is to find out what is true now. A service an
|
||||
/// operator disabled by hand is exactly the case a record cannot know about.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Status {
|
||||
pub present: bool,
|
||||
pub running: bool,
|
||||
pub enabled: bool,
|
||||
/// The one-line form for a `doctor` row — `running, enabled`, `stopped, enabled`, `not found`.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
fn absent(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
present: false,
|
||||
running: false,
|
||||
enabled: false,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads back the state of the service `install.json` recorded, changing nothing.
|
||||
///
|
||||
/// `kind` is taken from the record rather than from this platform so that a record written on the
|
||||
/// other OS produces an honest "this host has no such manager" instead of a confident answer from
|
||||
/// the wrong tool.
|
||||
pub fn observe(kind: &str, name: &str) -> Status {
|
||||
observe_platform(kind, name)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn observe_platform(kind: &str, name: &str) -> Status {
|
||||
if kind != "systemd" {
|
||||
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
||||
}
|
||||
let active = one_word(run("systemctl", &["is-active", name]));
|
||||
// `is-enabled` on an absent unit fails with an empty stdout, which `one_word` reports as
|
||||
// "unknown" — so a unit that is neither known nor active is one systemd has never heard of.
|
||||
let enabled = one_word(run("systemctl", &["is-enabled", name]));
|
||||
if enabled == "unknown" && active != "active" {
|
||||
return Status::absent("not found by systemd".to_string());
|
||||
}
|
||||
Status {
|
||||
present: true,
|
||||
running: active == "active",
|
||||
enabled: enabled == "enabled",
|
||||
detail: format!("{active}, {enabled}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn observe_platform(kind: &str, name: &str) -> Status {
|
||||
if kind != "windows-scm" {
|
||||
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
||||
}
|
||||
// Only the service this installer registers is queried by name; anything else would be reading
|
||||
// another product's service out of a hand-edited record.
|
||||
if name != WINDOWS_SERVICE || !windows_service_exists() {
|
||||
return Status::absent("not registered with the service manager".to_string());
|
||||
}
|
||||
let state = windows_service_state();
|
||||
let start = windows_start_type();
|
||||
Status {
|
||||
present: true,
|
||||
running: state.contains("RUNNING"),
|
||||
enabled: start.contains("AUTO_START"),
|
||||
detail: format!("{}, {}", state.to_lowercase(), start.to_lowercase()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but
|
||||
/// was set to manual start is reported as such rather than as healthy.
|
||||
#[cfg(windows)]
|
||||
fn windows_start_type() -> String {
|
||||
let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.trim_start().starts_with("START_TYPE") {
|
||||
// " START_TYPE : 2 AUTO_START"
|
||||
if let Some((_, value)) = line.split_once(':') {
|
||||
return value.split_whitespace().last().unwrap_or("unknown").into();
|
||||
}
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
/// What removing a service actually managed to do.
|
||||
///
|
||||
/// Never an `Err`: `uninstall` has usually already removed something by the time this runs, so a
|
||||
/// step that fails must be *reported* and the rest carried out. Ending halfway with an error would
|
||||
/// leave a host in a state neither the record nor the operator can describe.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Removal {
|
||||
pub done: Vec<String>,
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// Stops, disables and deletes the service recorded in `install.json`.
|
||||
///
|
||||
/// The service account is removed only when the record says **this installer created it**
|
||||
/// (PLAN.md §5): deleting an account that was already on the host is not this tool's business, and
|
||||
/// on Windows there is nothing to delete — the SCM's virtual account goes with the service.
|
||||
pub fn remove(record: &ServiceRecord) -> Removal {
|
||||
remove_platform(record)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
let mut out = Removal::default();
|
||||
if record.kind != "systemd" {
|
||||
out.problems.push(format!(
|
||||
"the record describes a {} service, which this host does not run — remove it from the \
|
||||
host that has it",
|
||||
record.kind
|
||||
));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Neither stop nor disable is `run_ok`: a unit that is already stopped, already disabled, or
|
||||
// gone entirely exits non-zero, and all three are the desired end state rather than failures.
|
||||
let _ = run("systemctl", &["stop", &record.name]);
|
||||
let _ = run("systemctl", &["disable", &record.name]);
|
||||
out.done
|
||||
.push(format!("stopped and disabled {}", record.name));
|
||||
|
||||
if let Some(unit) = &record.unit_path {
|
||||
let path = Path::new(unit);
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => out.done.push(format!("removed {unit}")),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
out.done.push(format!("{unit} was already gone"))
|
||||
}
|
||||
Err(error) => out.problems.push(format!("cannot remove {unit}: {error}")),
|
||||
}
|
||||
}
|
||||
let _ = run("systemctl", &["daemon-reload"]);
|
||||
// A unit that failed before being removed stays listed as failed until this is run.
|
||||
let _ = run("systemctl", &["reset-failed", &record.name]);
|
||||
|
||||
if let (Some(user), true) = (record.user.as_deref(), record.user_created) {
|
||||
match run_ok("userdel", &[user]).or_else(|_| run_ok("deluser", &[user])) {
|
||||
Ok(_) => out.done.push(format!("removed the {user} service user")),
|
||||
Err(error) => out.problems.push(format!(
|
||||
"cannot remove the {user} service user ({}); remove it by hand if you want it gone",
|
||||
error.to_string().replace('\n', " ")
|
||||
)),
|
||||
}
|
||||
} else if let Some(user) = record.user.as_deref() {
|
||||
out.done.push(format!(
|
||||
"left the {user} account alone — this installer did not create it"
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
let mut out = Removal::default();
|
||||
if record.kind != "windows-scm" {
|
||||
out.problems.push(format!(
|
||||
"the record describes a {} service, which this host does not run — remove it from the \
|
||||
host that has it",
|
||||
record.kind
|
||||
));
|
||||
return out;
|
||||
}
|
||||
if !windows_service_exists() {
|
||||
out.done
|
||||
.push(format!("{} was already unregistered", record.name));
|
||||
return out;
|
||||
}
|
||||
|
||||
// Stopping first is not politeness: `sc delete` on a running service only marks it for deletion,
|
||||
// and the service — and its lock on the binary this uninstall is about to remove — survives
|
||||
// until the process exits.
|
||||
if let Err(error) = stop_windows_service() {
|
||||
out.problems
|
||||
.push(error.to_string().replace('\n', " ").to_string());
|
||||
} else {
|
||||
out.done.push(format!("stopped {}", record.name));
|
||||
}
|
||||
|
||||
match run("sc.exe", &["delete", &record.name]) {
|
||||
// 1072 is ERROR_SERVICE_MARKED_FOR_DELETE: something still holds a handle (an open
|
||||
// services.msc is the usual culprit) and the entry goes when it is released.
|
||||
Ok(output) if output.status.success() => {
|
||||
out.done
|
||||
.push(format!("deleted the {} service", record.name));
|
||||
}
|
||||
Ok(output) if output.status.code() == Some(1072) => out.done.push(format!(
|
||||
"{} is marked for deletion — it disappears once whatever has it open (services.msc?) \
|
||||
is closed",
|
||||
record.name
|
||||
)),
|
||||
Ok(output) => out.problems.push(format!(
|
||||
"sc.exe delete {} failed with exit code {}",
|
||||
record.name,
|
||||
output.status.code().unwrap_or(-1)
|
||||
)),
|
||||
Err(error) => out
|
||||
.problems
|
||||
.push(format!("cannot run sc.exe delete: {error}")),
|
||||
}
|
||||
|
||||
// The virtual account exists only as long as the service does, so there is nothing to remove.
|
||||
out
|
||||
}
|
||||
|
||||
/// Registers, enables and starts the service — or explains why it did not.
|
||||
///
|
||||
/// `binary_changed` decides restart versus start: a replaced binary under an already-running
|
||||
|
||||
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
|
||||
/// re-minted, which is what keeps a second `install` byte-identical (see [`record_for`]).
|
||||
///
|
||||
/// ## Scope under `update`
|
||||
///
|
||||
/// An `update` re-resolves **only the features a previous run recorded as applied**, and does so
|
||||
/// without asking again. Two things follow from that, and both are deliberate:
|
||||
///
|
||||
/// - It is not a fresh offer. A shard that declined the tier stays unpatched through every update,
|
||||
/// which is what "opt-in" has to mean if it means anything; the new release's features are named
|
||||
/// so the operator knows they exist, and `--patches` is how they are taken up.
|
||||
/// - Consent is not re-asked for what is already in the tree — including on an unsupported ServUO,
|
||||
/// where `install` demanded a second flag. The record is the evidence that the operator opted in,
|
||||
/// and re-prompting would make an unattended update of a working shard impossible on precisely
|
||||
/// the hosts that most need the patches re-checked after an overlay moves.
|
||||
///
|
||||
/// Normally every one of those re-resolutions lands on rung 0 and writes nothing. When a release
|
||||
/// genuinely changes a patch, it is applied through the same ladder as any other — the target is
|
||||
/// still a file the operator may have edited, and nothing here loosens that check.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
cli: &Cli,
|
||||
mode: crate::install::Mode,
|
||||
root: &ServUoRoot,
|
||||
unpacked: &Path,
|
||||
declared: Option<&Tier>,
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
) -> Result<Outcome> {
|
||||
let tier = Tier::resolve(declared);
|
||||
if tier.features.is_empty() {
|
||||
let declared_tier = Tier::resolve(declared);
|
||||
if declared_tier.features.is_empty() {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"not offered — this overlay declares no patches",
|
||||
@@ -88,6 +106,34 @@ pub fn run(
|
||||
}
|
||||
let supported = root.is_supported_version();
|
||||
|
||||
// Under `update`, scope narrows to what is already applied unless --patches widens it back.
|
||||
let widen = cli.patches == PatchChoice::Yes;
|
||||
let tier = if mode.is_update() && !widen {
|
||||
scope_to_applied(&declared_tier, prior)
|
||||
} else {
|
||||
declared_tier.clone()
|
||||
};
|
||||
|
||||
if mode.is_update() && !widen {
|
||||
if tier.features.is_empty() {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"nothing to re-check — no feature was applied by an earlier run",
|
||||
);
|
||||
print_available(&declared_tier);
|
||||
return Ok(Outcome::skipped());
|
||||
}
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
&format!(
|
||||
"re-checking {} feature(s) an earlier run applied",
|
||||
tier.features.len()
|
||||
),
|
||||
);
|
||||
announce_new_features(&declared_tier, &tier);
|
||||
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported);
|
||||
}
|
||||
|
||||
match consent(cli, root, supported, &tier)? {
|
||||
Consent::Yes => {}
|
||||
Consent::No(reason) => {
|
||||
@@ -114,6 +160,52 @@ pub fn run(
|
||||
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
|
||||
}
|
||||
|
||||
/// The subset of a release's tier that a previous run actually applied.
|
||||
fn scope_to_applied(declared: &Tier, prior: &[FeatureRecord]) -> Tier {
|
||||
let applied = patch::index_records(prior);
|
||||
Tier {
|
||||
features: declared
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| applied.contains_key(f.name.as_str()))
|
||||
.cloned()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Names what this release offers that the shard does not have, without offering it.
|
||||
///
|
||||
/// An update must not quietly become the moment a shard acquires edits to stock ServUO files, but
|
||||
/// an operator who never learns the feature exists cannot opt in either.
|
||||
fn announce_new_features(declared: &Tier, in_scope: &Tier) {
|
||||
let scoped: Vec<&str> = in_scope.features.iter().map(|f| f.name.as_str()).collect();
|
||||
let new: Vec<&Feature> = declared
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| !scoped.contains(&f.name.as_str()))
|
||||
.collect();
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
println!(
|
||||
" This release also offers {} feature(s) this shard does not have:",
|
||||
new.len()
|
||||
);
|
||||
for feature in new {
|
||||
println!(" - {}", feature.summary);
|
||||
}
|
||||
println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)");
|
||||
}
|
||||
|
||||
/// The tier's offer, printed by an update that has nothing of its own to re-check.
|
||||
fn print_available(declared: &Tier) {
|
||||
println!(" This release offers:");
|
||||
for feature in &declared.features {
|
||||
println!(" - {}", feature.summary);
|
||||
}
|
||||
println!(" Add them with: install --patches (they edit stock ServUO files; INSTALL.md §4)");
|
||||
}
|
||||
|
||||
enum Consent {
|
||||
Yes,
|
||||
/// Not selected, with the reason to print.
|
||||
@@ -288,6 +380,19 @@ fn apply_tier(
|
||||
));
|
||||
}
|
||||
|
||||
// A feature an earlier run applied that this release no longer declares still has its edits
|
||||
// sitting in the ServUO tree. Its record is carried through rather than dropped: `uninstall`
|
||||
// renders the hunks to revert from these entries, and a record that quietly forgot them would
|
||||
// leave the operator with modified stock files and nothing saying so.
|
||||
let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect();
|
||||
let carried: Vec<&FeatureRecord> = prior
|
||||
.iter()
|
||||
.filter(|r| !in_scope.contains(&r.feature.as_str()))
|
||||
.collect();
|
||||
for record in &carried {
|
||||
records.push((*record).clone());
|
||||
}
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────────
|
||||
println!();
|
||||
ui::row(
|
||||
@@ -305,6 +410,13 @@ fn apply_tier(
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
for record in &carried {
|
||||
println!(
|
||||
" · {:<30} applied by an earlier run; this release's tier does not describe it,\n \
|
||||
so it was left exactly as it is and its record kept",
|
||||
record.feature
|
||||
);
|
||||
}
|
||||
|
||||
if core_rebuild {
|
||||
println!();
|
||||
@@ -681,6 +793,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_re_checks_only_what_an_earlier_run_applied() {
|
||||
// The scope rule for `update`: it must not become the moment a shard acquires edits to
|
||||
// stock ServUO files, and it must not stop re-checking the ones it already has.
|
||||
let tier = Tier::builtin();
|
||||
let applied = |name: &str| FeatureRecord {
|
||||
feature: name.into(),
|
||||
rebuild: Rebuild::Scripts,
|
||||
servuo_version: Some("57.4".into()),
|
||||
unsupported_servuo: false,
|
||||
patches: Vec::new(),
|
||||
companions: Vec::new(),
|
||||
};
|
||||
|
||||
let scoped = scope_to_applied(&tier, &[applied("moderation-audit")]);
|
||||
assert_eq!(scoped.features.len(), 1);
|
||||
assert_eq!(scoped.features[0].name, "moderation-audit");
|
||||
|
||||
// A host that declined the tier stays declined through every update.
|
||||
assert!(scope_to_applied(&tier, &[]).features.is_empty());
|
||||
// And one that took everything keeps re-checking everything.
|
||||
let all: Vec<FeatureRecord> = tier.features.iter().map(|f| applied(&f.name)).collect();
|
||||
assert_eq!(
|
||||
scope_to_applied(&tier, &all).features.len(),
|
||||
tier.features.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feature_the_release_no_longer_declares_is_still_scoped_out_not_forgotten() {
|
||||
// `scope_to_applied` can only return what the release declares, so a record for a feature
|
||||
// that has been withdrawn falls outside it — which is exactly why `apply_tier` carries such
|
||||
// records through instead of rebuilding the section from what it processed.
|
||||
let tier = Tier {
|
||||
features: vec![feature()],
|
||||
};
|
||||
let withdrawn = FeatureRecord {
|
||||
feature: "some-old-feature".into(),
|
||||
rebuild: Rebuild::Scripts,
|
||||
servuo_version: Some("57.4".into()),
|
||||
unsupported_servuo: false,
|
||||
patches: Vec::new(),
|
||||
companions: Vec::new(),
|
||||
};
|
||||
assert!(scope_to_applied(&tier, std::slice::from_ref(&withdrawn))
|
||||
.features
|
||||
.is_empty());
|
||||
|
||||
let in_scope: Vec<&str> = tier.features.iter().map(|f| f.name.as_str()).collect();
|
||||
assert!(!in_scope.contains(&withdrawn.feature.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_that_was_not_written_is_reported_as_applied() {
|
||||
// Both halves caught on a live tree: a --verify run said "applied at line 75", and a patch
|
||||
|
||||
640
src/uninstall.rs
Normal file
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