feat(installer): implement Phase 1 — the installer core
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
Adds the Rust crate at the repo root and implements `install` end to end for the overlay half of a deployment: resolve the published bundle, find and validate the ServUO root, refuse to deploy under a running shard, sync the plugin overlay, and record what was deployed in install.json. `doctor`, `update` and `uninstall` parse and answer with the phase they arrive in rather than "unrecognized command", and the run states plainly that the uo-link sidecar (Phase 2) and the patch tier (Phase 3) were not installed — `--patches` in particular reports REQUESTED BUT NOT APPLIED, since a quiet completion would be read as a patched shard. Landing on `edge` rather than `main`: release.yml publishes a binary on every push to main, and an installer that deploys the overlay but cannot install the sidecar is not something to hand an operator. pr-checks.yml now gates PRs into edge on the same rules, so the branch the work happens on is not the ungated one. Notable decisions, all documented in docs/installer/PLAN.md §5 Phase 1: - The code lives in a library called `rgdeploy` with a thin binary that keeps the published name. Windows' UAC installer detection refuses to launch an unsigned executable whose file name contains "install" (os error 740), and Cargo names test harnesses after their target — so a target under that name makes `cargo test` unrunnable on Windows. - The running-shard check matches processes by path, not by process name: on Linux a live shard is `mono`/`dotnet` with ServUO.exe as an argument, and a name match would report "not running" for a shard that is running. - install.json records a state (`deployed` / `kept-operator-modified`), not the run's verb, so an unchanged re-run produces an identical record and writes nothing. - The Bridge.cfg keep rule compares against the hash the installer last deployed, not the last hash it saw — otherwise a kept file is overwritten on the very next run. - Downloads are verified against the bundle's SHA256 while being written, then every extracted file is re-hashed against the release's own manifest.json, whose protocol and version are cross-checked against the bundle. Verified against a real ServUO 57.4 tree and end to end into a scratch tree: 24 files deployed, an unchanged re-run that writes nothing, an edited Bridge.cfg kept across repeated runs while code files are overwritten, bundle pinning, and a refusal with a shard running out of the tree. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
430
src/install.rs
Normal file
430
src/install.rs
Normal file
@@ -0,0 +1,430 @@
|
||||
//! The `install` command.
|
||||
//!
|
||||
//! Phase 1 of `docs/installer/PLAN.md` — the installer core: resolve the bundle, validate the
|
||||
//! ServUO root, sync the overlay, record what was deployed. The sidecar and its service (Phase 2)
|
||||
//! and the patch tier (Phase 3) are not in this build, and the run says so in as many words rather
|
||||
//! than ending on a success line that would read as a finished install. An operator who cannot tell
|
||||
//! which half ran is the failure this whole tool exists to remove.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use crate::cli::{Cli, PatchChoice};
|
||||
use crate::record::{
|
||||
now_rfc3339, BundleRef, InstallRecord, InstallerInfo, OverlayRecord, ServUoRef, SCHEMA,
|
||||
};
|
||||
use crate::servuo::ServUoRoot;
|
||||
use crate::util::TempDir;
|
||||
use crate::{bundle, net, overlay, paths, servuo, ui};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<()> {
|
||||
let layout = paths::layout();
|
||||
|
||||
// ── 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 {} — bundle {} (protocol {}){}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
bundle.bundle,
|
||||
bundle.protocol,
|
||||
if cli.verify {
|
||||
" [--verify: nothing will be written]"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
println!();
|
||||
|
||||
// ── Where to install it ──────────────────────────────────────────────────
|
||||
let root = resolve_root(cli)?;
|
||||
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 {} (Phase 2 — not installed by this build)",
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// ── 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 record_path = layout.install_record();
|
||||
let prior = InstallRecord::load(&record_path)?;
|
||||
let prior_files = prior_overlay_files(prior.as_ref(), &root);
|
||||
|
||||
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||
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
|
||||
));
|
||||
}
|
||||
|
||||
// ── What this build does not do ──────────────────────────────────────────
|
||||
report_patch_tier(cli);
|
||||
report_sidecar(&bundle, &sidecar_asset, &layout);
|
||||
|
||||
// ── Record ───────────────────────────────────────────────────────────────
|
||||
let record = build_record(
|
||||
prior.as_ref(),
|
||||
&bundle,
|
||||
&bundle_url,
|
||||
&root,
|
||||
&manifest,
|
||||
&planned,
|
||||
);
|
||||
|
||||
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 summary.writes_anything() && !cli.verify {
|
||||
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 if cli.verify {
|
||||
println!("Nothing was written. Re-run without --verify to deploy.");
|
||||
} else {
|
||||
println!("Nothing to do — this tree already has this overlay.");
|
||||
}
|
||||
|
||||
if cli.host.is_some() || cli.site_url.is_some() {
|
||||
println!(
|
||||
"\nNote: --host/--site-url are used by the token handoff, which arrives with the \
|
||||
sidecar in Phase 2. They had no effect on this run."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves the ServUO root: `--servuo`, else detection (confirmed), else a prompt.
|
||||
fn resolve_root(cli: &Cli) -> Result<ServUoRoot> {
|
||||
if let Some(path) = &cli.servuo {
|
||||
return servuo::open_stopped(&PathBuf::from(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()
|
||||
}
|
||||
|
||||
fn report_patch_tier(cli: &Cli) {
|
||||
println!();
|
||||
match cli.patches {
|
||||
// --patches must never pass silently: an operator who asked for the tier and got a clean
|
||||
// run would reasonably conclude that EventSink.cs had been patched.
|
||||
PatchChoice::Yes => {
|
||||
ui::warn(
|
||||
"Patch tier REQUESTED BUT NOT APPLIED — it is not implemented in this \
|
||||
build (Phase 3).\n \
|
||||
No stock ServUO file has been touched. Apply the patches by hand if you need \
|
||||
them: INSTALL.md Appendix A2.",
|
||||
);
|
||||
}
|
||||
PatchChoice::No => {
|
||||
ui::row("Patch tier", "skipped (--no-patches)");
|
||||
}
|
||||
PatchChoice::Ask => {
|
||||
ui::row(
|
||||
"Patch tier",
|
||||
"skipped (not implemented in this build — Phase 3)",
|
||||
);
|
||||
}
|
||||
}
|
||||
println!(" Without it: no vendor.sale events, no in-game moderation audit forwarding.");
|
||||
}
|
||||
|
||||
fn report_sidecar(bundle: &bundle::Bundle, asset: &bundle::Asset, layout: &paths::Layout) {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"uo-link NOT INSTALLED — the sidecar and its service arrive in Phase 2.\n \
|
||||
Without it the shard has nothing to dial out to and your website stays offline.\n \
|
||||
Install it by hand for now — INSTALL.md Appendix A3 and A4:\n \
|
||||
binary {}\n \
|
||||
config {}\n \
|
||||
database {}\n \
|
||||
download {}\n \
|
||||
sha256 {}\n \
|
||||
Then provision and read the token back with:\n \
|
||||
<binary> --print-config --config {}\n \
|
||||
The bundle pairs it with overlay {} at protocol {}; keep the two in step.",
|
||||
layout.sidecar_bin.display(),
|
||||
layout.sidecar_config().display(),
|
||||
layout.sidecar_db().display(),
|
||||
asset.url,
|
||||
asset.sha256,
|
||||
layout.sidecar_config().display(),
|
||||
bundle.overlay.tag,
|
||||
bundle.protocol,
|
||||
));
|
||||
}
|
||||
|
||||
fn build_record(
|
||||
prior: Option<&InstallRecord>,
|
||||
bundle: &bundle::Bundle,
|
||||
bundle_url: &str,
|
||||
root: &ServUoRoot,
|
||||
manifest: &overlay::Manifest,
|
||||
planned: &[overlay::PlannedFile],
|
||||
) -> InstallRecord {
|
||||
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),
|
||||
}),
|
||||
// Sections this build does not own are carried through verbatim, so a Phase 1 binary
|
||||
// re-running on a fully installed host cannot make a service or a set of applied patch
|
||||
// hunks disappear from the record that documents them.
|
||||
link: prior.and_then(|p| p.link.clone()),
|
||||
patches: prior.map(|p| p.patches.clone()).unwrap_or_default(),
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user