All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m8s
`sc start` ran inside registration, before `grant_service_access` let the new virtual account read `sidecar.toml` — which `protect_config` had locked to SYSTEM and Administrators. The first start died on "Access is denied (os error 5)" and the service sat STOPPED: the SCM's restart-on-failure policy never fires for a clean exit with an error code. A later manual start worked, because by then the grant existed. Found on the phase 18 walk (step 4, one Rust instance under the SCM), from the sidecar's own log. The code is shared, so the released ServUO installer has the same first-start failure; the org lead chose to fix both here (D157). Registration no longer starts the service; a new `start_registered` runs after the grant and refreshes the reported state. A no-op on Linux, where the service user exists before the config and registration starts the unit itself. Walked (Windows, elevated): install -> START_PENDING/running, /health on the first probe, clean stop and restart, doctor clean, uninstall clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
914 lines
38 KiB
Rust
914 lines
38 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::{backup, 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);
|
|
|
|
// ── Backup ───────────────────────────────────────────────────────────────
|
|
// Created before the first write and handed to every stage that overwrites, so each copy is
|
|
// taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily:
|
|
// a run that displaces nothing leaves nothing behind.
|
|
let mut backup = backup::Session::new(
|
|
&layout,
|
|
&root.path,
|
|
if mode.is_update() {
|
|
"update"
|
|
} else {
|
|
"install"
|
|
},
|
|
prior.as_ref().map(|p| p.bundle.tag.clone()),
|
|
bundle.bundle.clone(),
|
|
!cli.verify && !cli.no_backup,
|
|
);
|
|
|
|
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 {
|
|
// `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what
|
|
// would replace it, and `KeptOperatorModified` is not written at all — copying those three
|
|
// would bury the files that are actually being displaced.
|
|
for file in planned
|
|
.iter()
|
|
.filter(|f| f.action == overlay::Action::Change)
|
|
{
|
|
backup.capture(&file.dst, backup::Reason::OverlayChange)?;
|
|
}
|
|
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(),
|
|
&mut backup,
|
|
)?;
|
|
|
|
// The config joins a backup that is already being taken; it is never the reason for one. The
|
|
// installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that
|
|
// a restored set of files comes with the token that matches them, rather than an operator
|
|
// restoring a tree and then finding the website pointed at a token that has moved on.
|
|
if backup.has_entries() {
|
|
backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?;
|
|
}
|
|
let backup_dir = backup.finish()?;
|
|
|
|
// ── 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());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Named after the record rather than at the moment it was taken, because that is where an
|
|
// operator looks when a run has finished and something is wrong. Restoring is theirs to do:
|
|
// the installer cannot know what has changed since, and putting an old `.cs` file back over a
|
|
// newer overlay eats work rather than saving it.
|
|
if let Some(dir) = &backup_dir {
|
|
println!("\n Backed up {}", dir.display());
|
|
println!(
|
|
" the files this run replaced, with a manifest naming each one.\n\
|
|
\x20 The newest {} backups are kept; `uninstall --purge` removes them.",
|
|
backup::KEEP
|
|
);
|
|
}
|
|
|
|
// ── 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 mut outcome = service::register(&prepared, layout, action.writes())?;
|
|
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
|
|
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
|
|
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.
|
|
pub(crate) 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());
|
|
}
|
|
}
|