Files
installer/src/install.rs
wtclaude 060b8815cf
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m2s
fix(installer): keep user_created sticky across re-runs
Found on the first real systemd host this installer has ever run on: a
container with systemd as PID 1, installing into /usr/bin, /etc and
/var/lib for real.

`service::prepare` answers "did THIS run create the service account",
which is false on every run after the first — by then the account exists.
Recording that verbatim made the field describe the run rather than the
state, with two consequences:

- `install.json` changed on an otherwise-identical second run, breaking
  the Phase 1 promise that a re-run with nothing new to do writes
  nothing.
- `uninstall` removes only an account it created, so after any second
  `install` it silently left behind the very user this tool had added.
  Reproduced before the fix: "left the runicgateway account alone — this
  installer did not create it", on a host where it plainly had.

The record now inherits `true` from a prior record naming the same
account, and only that account: inheriting across a rename would
authorize deleting a user this installer never made.

Not visible on Windows, where the SCM's virtual account is never created
by us and goes with the service — which is why three phases of Windows
smoke runs never showed it.

Verified after the fix on the same host: fresh install records
user_created true, an identical second run leaves install.json
byte-identical, and uninstall then removes the account, the unit, the
service and the binary — leaving sidecar.toml, the database and all 24
overlay files in the ServUO tree exactly where they were.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 04:00:29 -05:00

864 lines
35 KiB
Rust

//! 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
//! platform, the ServUO root, and whether this process can write where it must. A run that
//! cannot finish should end before a single file enters the ServUO tree.
//! 2. **Overlay, then the patch tier, then the sidecar.** Everything that edits the ServUO tree
//! happens together, on the near side of the running-shard check that guards it — and the tier
//! comes second because a feature's companion `.cs` lands in a directory the overlay creates.
//! 3. **Provision the config before registering the service** (PLAN.md §5): `--print-config` writes
//! the file the service definition points at, so the service is never started against a config
//! that does not exist yet.
//! 4. **Record last, print the handoff after that.** The token block is the final thing on screen
//! because it is the only thing the operator still has to act on.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use crate::cli::Cli;
use crate::record::{
now_rfc3339, BinaryRef, BundleRef, InstallRecord, InstallerInfo, LinkRecord, OverlayRecord,
ServUoRef, ServiceRecord, SCHEMA,
};
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
// cannot be completed fails here — before a single file has entered the ServUO tree.
let (bundle, bundle_url) = bundle::fetch(cli.bundle.as_deref())?;
let sidecar_asset = bundle.sidecar_asset()?.clone();
println!(
"\nRunic Gateway installer {}{} to bundle {} (protocol {}){}",
env!("CARGO_PKG_VERSION"),
if mode.is_update() {
"update"
} else {
"install"
},
bundle.bundle,
bundle.protocol,
if cli.verify {
" [--verify: nothing will be written]"
} else {
""
}
);
println!();
// ── Where to install it ──────────────────────────────────────────────────
let root = resolve_root(cli, mode, prior.as_ref())?;
ui::row(
"ServUO",
&format!("{} ({})", root.path.display(), root.version_display()),
);
// Reaching this line means `servuo::open_stopped` found no shard running out of this tree; a
// running one has already ended the run.
ui::row("Shard process", "not running");
ui::row(
"Overlay",
&format!(
"{:<24} protocol {}",
format!("servuo-plugins {}", bundle.overlay.tag),
bundle.overlay.protocol
),
);
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {}",
format!("uo-link {}", bundle.link.tag),
bundle.link.protocol
),
);
if !root.is_supported_version() {
println!();
ui::warn(&format!(
"This tree reports ServUO {}. {} is the only supported version.\n \
The base overlay only adds files and is expected to work broadly, so the install \
continues.\n \
The patch tier is the part that is version-sensitive — see INSTALL.md §4.",
root.version_display(),
servuo::SUPPORTED_VERSION
));
}
// Everything below writes into system directories. Finding out here beats finding out after
// the ServUO tree has been half-deployed into — the ServUO half is the one an operator cannot
// simply re-run their way out of.
if !cli.verify {
preflight_writable(&layout)?;
}
// ── Fetch and unpack the overlay ─────────────────────────────────────────
let scratch = TempDir::new("runicgateway-installer")?;
let tarball = scratch.path().join(&bundle.overlay.asset.name);
println!();
net::download_verified(
&bundle.overlay.asset.url,
&tarball,
&bundle.overlay.asset.sha256,
)?;
ui::ok(&format!(
"overlay tarball verified sha256 {}",
&bundle.overlay.asset.sha256[..8.min(bundle.overlay.asset.sha256.len())]
));
let unpacked = overlay::extract(&tarball, &scratch.path().join("unpacked"))?;
let manifest = overlay::read_manifest(&unpacked)?;
overlay::verify_payload(&unpacked, &manifest)?;
// The bundle and the artifact must agree. They are produced by different repos at different
// times, and gate 1 of the compose job (PLAN.md §7.1) is what normally keeps them in step —
// this is the same check applied to the artifact actually on disk.
if manifest.protocol != bundle.overlay.protocol {
bail!(
"the overlay release declares protocol {} but bundle {} recorded {}. \
Refusing to deploy a pair that was never checked together.",
manifest.protocol,
bundle.bundle,
bundle.overlay.protocol
);
}
if manifest.version != bundle.overlay.version {
bail!(
"bundle {} names overlay {} but the downloaded tarball contains {}",
bundle.bundle,
bundle.overlay.version,
manifest.version
);
}
// ── Plan the sync ────────────────────────────────────────────────────────
let prior_files = prior_overlay_files(prior.as_ref(), &root);
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
let summary = overlay::summarize(&planned);
ui::heading("Overlay sync");
let lines = overlay::render(&planned);
if lines.is_empty() {
println!(" (no changes)");
}
for line in lines {
println!("{line}");
}
if cli.verify {
println!(
"\n VERIFY only. add={} change={} unchanged={} kept={} (nothing written)",
summary.add, summary.change, summary.unchanged, summary.kept
);
} else {
overlay::apply(&planned)?;
// "deployed" is claimed only when something actually moved. A run that copied nothing
// reporting "deployed" would read as a fresh install to anyone skimming the output.
println!(
"\n {} add={} change={} unchanged={} kept={}",
if summary.writes_anything() {
"deployed."
} else {
"unchanged."
},
summary.add,
summary.change,
summary.unchanged,
summary.kept
);
}
for file in planned
.iter()
.filter(|f| f.action == overlay::Action::KeptOperatorModified)
{
println!();
ui::warn(&format!(
"{} has local edits — left exactly as it is.\n \
The release ships its own copy of this file; if you want the new defaults, compare \
yours against\n the one in {}\n and merge by hand. \
Every other overlay file is code and is overwritten unconditionally.",
file.rel, bundle.overlay.asset.url
));
}
// ── The patch tier ───────────────────────────────────────────────────────
// After the overlay, because a feature's companion `.cs` lands in the directory the overlay
// creates, and before the sidecar, because everything that edits the ServUO tree belongs on the
// near side of the running-shard check that guards it.
let tier = tier::run(
cli,
mode,
&root,
&unpacked,
manifest.patch_tier.as_ref(),
&layout,
&prior
.as_ref()
.map(|p| p.patch_records())
.unwrap_or_default(),
)?;
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(
cli,
&bundle,
&sidecar_asset,
&layout,
scratch.path(),
prior.as_ref(),
)?;
// ── Record ───────────────────────────────────────────────────────────────
let record = build_record(
prior.as_ref(),
&Deployment {
bundle: &bundle,
bundle_url: &bundle_url,
root: &root,
manifest: &manifest,
planned: &planned,
sidecar: sidecar.as_ref(),
tier: &tier,
verify: cli.verify,
},
);
if cli.verify {
println!("\n {} not written (--verify)", record_path.display());
} else {
match prior.as_ref() {
Some(previous) if previous.same_deployment_as(&record) => {
println!("\n {} unchanged", record_path.display());
}
_ => {
record.save(&record_path).with_context(|| {
format!(
"cannot write {} — run as root/Administrator, or set {} to a writable \
directory for a test run",
record_path.display(),
paths::STATE_DIR_ENV
)
})?;
println!("\n Recorded {}", record_path.display());
}
}
}
// ── Closing notes ────────────────────────────────────────────────────────
println!();
if cli.verify {
// 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
// produces a shard that boots perfectly and never emits the events it was patched for.
println!(
"A CORE ServUO file was patched — rebuild the solution before starting:\n \
dotnet build ServUO.sln\n"
);
}
println!(
"Scripts changed — ServUO rebuilds Scripts.dll on next boot.\n\
Start your shard when ready; the installer does not start it for you.\n\
Note that ServUO ignores the script build's exit code, so a clean boot is not proof \
the plugin compiled:\n watch for \"[Bridge] enabled=True\" in the boot output, or \
run `[bridge status` in game (INSTALL.md §6)."
);
} else {
println!("The ServUO tree already has this overlay — nothing was changed there.");
}
// ── 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).
//
// 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 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());
if ui::confirm(&question, true, cli.assume_yes)? {
return servuo::open_stopped(&detected);
}
} else if cli.assume_yes {
// --yes cannot invent a path, and picking one would be the worst possible guess.
bail!(
"no ServUO installation was found near this binary or the working directory. \
Pass --servuo <path>."
);
}
let answer = ui::prompt("Path to your ServUO root", None)
.context("a ServUO root is required; pass --servuo <path> for an unattended run")?;
servuo::open_stopped(&PathBuf::from(answer.trim().trim_matches('"')))
}
/// The previous run's file map, but only when it describes *this* tree.
///
/// The map is what distinguishes an operator-edited `Bridge.cfg` from an upstream change, and that
/// judgement is only meaningful about the tree it was recorded for. A host whose record points at a
/// different root — a shard moved or rebuilt beside the old one — is treated as having no prior
/// deployment here, which errs toward keeping the operator's file.
fn prior_overlay_files<'a>(
prior: Option<&'a InstallRecord>,
root: &ServUoRoot,
) -> Option<&'a std::collections::BTreeMap<String, crate::record::FileRecord>> {
let prior = prior?;
if Path::new(&prior.servuo.path) != root.path {
return None;
}
prior.overlay_files()
}
/// The sidecar half of a run: binary, config, service. `None` under `--verify`.
struct SidecarOutcome {
record: LinkRecord,
doc: sidecar::ConfigDoc,
service: service::Outcome,
}
/// Installs the sidecar, provisions its config, and registers its service.
///
/// The step order is the one PLAN.md §5 fixes: stop anything running the old binary, replace it,
/// **then** `--print-config` (which writes the config the service will be pointed at), then
/// register. Doing the last two the other way round registers a service against a file that does
/// not exist yet, which fails in a way that looks like a broken sidecar rather than a sequencing
/// mistake.
fn install_sidecar(
cli: &Cli,
bundle: &bundle::Bundle,
asset: &bundle::Asset,
layout: &paths::Layout,
scratch: &Path,
prior: Option<&InstallRecord>,
) -> Result<Option<SidecarOutcome>> {
ui::heading("uo-link sidecar");
let action = sidecar::decide(asset, &layout.sidecar_bin)?;
ui::row(
"binary",
&format!("{} {}", layout.sidecar_bin.display(), action.label()),
);
if cli.verify {
ui::row(
"config",
&format!(
"{} (would be provisioned)",
layout.sidecar_config().display()
),
);
ui::row("database", &layout.sidecar_db().display().to_string());
println!(
"\n VERIFY only. Nothing installed, no service registered, no token read.\n \
The token handoff needs a provisioned config, so it is only printed by a real run."
);
return Ok(None);
}
// Detect the service manager and make sure the account exists *before* a config file is
// written that then has to be owned by it.
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if action.writes() {
// The binary is locked on Windows while its service runs, and on Linux replacing it under a
// live process leaves the old code serving until something restarts it.
service::stop_for_replacement(&prepared.manager)?;
let sha = sidecar::place(asset, &layout.sidecar_bin, scratch)?;
ui::ok(&format!(
"sidecar binary verified sha256 {}",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
std::fs::create_dir_all(&layout.data_dir)
.with_context(|| format!("cannot create {}", layout.data_dir.display()))?;
let config_path = layout.sidecar_config();
let db_path = layout.sidecar_db();
// The database is pinned by environment only where it does not already land beside the config.
// See `crate::paths` for why that is a platform difference rather than an inconsistency.
let db_env = (config_path.parent() != db_path.parent()).then_some(db_path.as_path());
let doc = sidecar::print_config(&layout.sidecar_bin, &config_path, db_env)?;
// The installed binary is the only thing that will actually speak to the website, so its
// protocol version is the one that counts. The bundle's gate 1 (PLAN.md §7.1) read this number
// from source at the release tag; a disagreement means the pair in front of us is not the pair
// CI checked, and the sidecar would answer the website with 409 rather than mis-parsing.
if doc.protocol != bundle.protocol {
bail!(
"the installed sidecar reports protocol {} but bundle {} was composed at protocol {}. \
Refusing to register a service for a pair that was never checked together — the \
binary is installed but no service has been created.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.link.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.link.version
));
}
ui::row(
"config",
&format!(
"{} {}",
doc.config_path,
if doc.config_created {
"created"
} else {
"already present"
}
),
);
ui::row("database", &doc.store.path);
ui::row(
"listening on",
&format!("shard {} website {}", doc.shard.bind, doc.web.bind),
);
// The config holds the auth token, and neither default location protects it on its own. This
// runs before registration so the file is never left readable while the rest of the run
// happens; on Windows the service account does not exist until `sc create` creates it, so the
// grant for it comes after.
service::protect_config(
&config_path,
&layout.data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
unit_path,
user,
user_created,
state,
} => {
ui::row("service", &format!("{name} {state}"));
if let Some(user) = user {
ui::row("running as", user);
}
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: unit_path.as_ref().map(|p| p.display().to_string()),
user: user.clone(),
user_created: *user_created || created_by_an_earlier_run(prior, user.as_deref()),
})
}
service::Outcome::Skipped { reason, manual } => {
println!();
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n \
The binary and its config are in place; nothing is running them. Do this by hand:"
));
print!("{manual}");
None
}
};
Ok(Some(SidecarOutcome {
record: LinkRecord {
repo: bundle.link.repo.clone(),
tag: bundle.link.tag.clone(),
version: doc.version.clone(),
protocol: doc.protocol,
binary: BinaryRef {
path: layout.sidecar_bin.display().to_string(),
sha256: binary_sha256,
},
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
service: outcome,
}))
}
/// Whether an earlier run of this installer created the service account.
///
/// **`user_created` has to be sticky, and this is why.** `service::prepare` answers "did *this run*
/// create the account", which is `false` on every run after the first — the account exists by then.
/// Recording that verbatim makes the field describe the run instead of the state, with two
/// consequences: `install.json` changes on an otherwise-identical second run (breaking Phase 1's
/// "a second run writes nothing"), and `uninstall` — which removes only an account it created —
/// silently leaves behind the very account this tool added. Both were caught on the first real
/// systemd host the installer ever ran on, and neither is visible on Windows, where the SCM's
/// virtual account is never "created" by us at all.
///
/// Matched on the account *name*: a record naming a different user describes a different account,
/// and inheriting `true` from it would authorize deleting one this installer never made.
fn created_by_an_earlier_run(prior: Option<&InstallRecord>, user: Option<&str>) -> bool {
let (Some(prior), Some(user)) = (prior, user) else {
return false;
};
prior
.link_record()
.and_then(|link| link.service)
.is_some_and(|service| service.user.as_deref() == Some(user) && service.user_created)
}
/// Fails the run before it writes anything if this process cannot write where it must.
///
/// `create_dir_all` succeeding is not the same question as "can this process write here" — it
/// returns `Ok` for a directory that already exists and is read-only to us — so each location is
/// probed with a real file. The alternative, discovering it three steps later, leaves a ServUO tree
/// that has already been deployed into.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.sidecar_bin
.parent()
.unwrap_or(&layout.sidecar_bin)
.to_path_buf();
for dir in [&layout.state_dir, &layout.data_dir, &bin_dir] {
std::fs::create_dir_all(dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}.\n \
Run this installer as {}, or set {} to a writable directory for a test run \
(no service is registered then).",
dir.display(),
if cfg!(windows) {
"Administrator, from an elevated PowerShell"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}
/// The hostname the website should use to reach this machine.
///
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}
let detected = sysinfo::System::host_name()
.filter(|h| !h.trim().is_empty())
.unwrap_or_else(|| "this-host".to_string());
if cli.assume_yes {
return detected;
}
let answer = ui::prompt(
"Hostname your website should use to reach this machine",
Some(&detected),
);
answer.unwrap_or(detected)
}
/// Everything one run produced, gathered so the record can be built from a single value.
///
/// The three halves are assembled at different points and the record needs all of them, which is
/// how this grew a parameter per step. A struct keeps the call site readable and, more usefully,
/// makes it obvious at a glance that nothing else feeds `install.json`.
struct Deployment<'a> {
bundle: &'a bundle::Bundle,
bundle_url: &'a str,
root: &'a ServUoRoot,
manifest: &'a overlay::Manifest,
planned: &'a [overlay::PlannedFile],
sidecar: Option<&'a SidecarOutcome>,
tier: &'a tier::Outcome,
/// A dry run reports everything and records nothing, so every "carry the previous value
/// through" branch below turns on it.
verify: bool,
}
fn build_record(prior: Option<&InstallRecord>, run: &Deployment<'_>) -> InstallRecord {
let Deployment {
bundle,
bundle_url,
root,
manifest,
planned,
sidecar,
tier,
verify,
} = *run;
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
},
updated: now_rfc3339(),
bundle: BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: bundle_url.to_string(),
},
servuo: ServUoRef {
path: root.path.to_string_lossy().to_string(),
version: root.version.clone(),
},
overlay: Some(OverlayRecord {
repo: manifest.repo.clone(),
tag: bundle.overlay.tag.clone(),
version: manifest.version.clone(),
commit: manifest.commit.clone(),
protocol: manifest.protocol,
files: overlay::file_records(planned),
}),
// A `--verify` run installs no sidecar and must not erase the record of one that is
// already there; the same reasoning keeps the patch section and any field a
// newer installer wrote intact. A record that forgot a running service would make
// `doctor` and `uninstall` forget it too.
link: match sidecar {
Some(outcome) => serde_json::to_value(&outcome.record).ok(),
None => prior.and_then(|p| p.link.clone()),
},
// The tier's own records replace the section only when it actually ran and wrote. A
// declined tier, a refused one, and a `--verify` dry run all leave the previous record
// exactly as it was — an install where the operator said "not this time" must not erase
// the evidence of patches applied on an earlier one.
patches: match (tier.ran && !verify, prior) {
(true, _) => tier
.records
.iter()
.filter_map(|r| serde_json::to_value(r).ok())
.collect(),
(false, Some(previous)) => previous.patches.clone(),
(false, None) => Vec::new(),
},
extra: prior.map(|p| p.extra.clone()).unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::record::{FileRecord, ServUoRef};
use std::collections::BTreeMap;
fn record_for(path: &str) -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: now_rfc3339(),
bundle: BundleRef {
tag: "2026.08.04".into(),
protocol: 3,
url: "https://example/current.json".into(),
},
servuo: ServUoRef {
path: path.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::from([(
"Config/Bridge.cfg".to_string(),
FileRecord {
overlay_sha256: "aa".into(),
on_disk_sha256: "bb".into(),
state: "kept-operator-modified".into(),
},
)]),
}),
link: None,
patches: Vec::new(),
extra: BTreeMap::new(),
}
}
fn root_at(path: &str) -> ServUoRoot {
ServUoRoot {
path: PathBuf::from(path),
version: Some("57.4".into()),
}
}
#[test]
fn an_account_this_installer_created_stays_recorded_as_created() {
// Caught on the first real systemd host: `prepare` reports "did THIS run create it", which
// is false from the second run on. Recording that verbatim rewrote install.json on an
// identical re-run and, worse, left `uninstall` believing the account was somebody else's.
let mut record = record_for("/opt/ServUO");
let service = |user: &str, created: bool| ServiceRecord {
kind: "systemd".into(),
name: "runicgateway-link.service".into(),
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
user: Some(user.into()),
user_created: created,
};
let with_service = |record: &mut InstallRecord, service: ServiceRecord| {
record.link = serde_json::to_value(LinkRecord {
repo: "RunicGateway/link".into(),
tag: "v1.1.0".into(),
version: "1.1.0".into(),
protocol: 3,
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: Some(service),
})
.ok();
};
with_service(&mut record, service("runicgateway", true));
assert!(created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
// An account this installer found already there stays somebody else's, forever.
with_service(&mut record, service("runicgateway", false));
assert!(!created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
// A record naming a different account says nothing about this one — inheriting `true`
// there would authorize deleting a user this installer never made.
with_service(&mut record, service("someone-else", true));
assert!(!created_by_an_earlier_run(
Some(&record),
Some("runicgateway")
));
assert!(!created_by_an_earlier_run(None, Some("runicgateway")));
assert!(!created_by_an_earlier_run(Some(&record), None));
}
#[test]
fn a_record_for_this_tree_is_used() {
let record = record_for("/opt/ServUO");
let files = prior_overlay_files(Some(&record), &root_at("/opt/ServUO"));
assert!(files.is_some_and(|f| f.contains_key("Config/Bridge.cfg")));
}
#[test]
fn a_record_for_a_different_tree_is_ignored() {
// Otherwise a second shard on the same host would inherit the first's hashes and could
// have its Bridge.cfg overwritten on the strength of a comparison that never applied to it.
let record = record_for("/opt/ServUO-old");
assert!(prior_overlay_files(Some(&record), &root_at("/opt/ServUO")).is_none());
assert!(prior_overlay_files(None, &root_at("/opt/ServUO")).is_none());
}
}