All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m2s
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>
864 lines
35 KiB
Rust
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());
|
|
}
|
|
}
|