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:
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());
|
||||
|
||||
Reference in New Issue
Block a user