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