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