feat(installer): implement Phase 1 — the installer core
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:
2026-08-04 14:58:17 -05:00
parent 0e7d5f3bee
commit dff4ad41c9
17 changed files with 4106 additions and 21 deletions

113
src/paths.rs Normal file
View File

@@ -0,0 +1,113 @@
//! Where the installer's own files live.
//!
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
//! working directory, and a service manager's working directory is not somewhere to put a database.
//! The installer therefore owns the layout and (from Phase 2) pins `UOLINK_CONFIG` and
//! `UOLINK_DB_PATH` into the service definition.
//!
//! Phase 1 only needs the state directory — `install.json` and the cached patch set — but the whole
//! layout is declared here so Phase 2 and 3 add nothing new to argue about.
use std::env;
use std::path::PathBuf;
/// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than
/// hidden: an undocumented environment variable that moves where a tool writes is worse than a
/// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote.
pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
pub state_dir: PathBuf,
/// `/var/lib/runicgateway` — the sidecar's SQLite store. Phase 2.
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary. Phase 2.
pub sidecar_bin: PathBuf,
}
impl Layout {
pub fn install_record(&self) -> PathBuf {
self.state_dir.join("install.json")
}
pub fn sidecar_config(&self) -> PathBuf {
self.state_dir.join("sidecar.toml")
}
pub fn sidecar_db(&self) -> PathBuf {
self.data_dir.join("uo-link.db")
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
///
/// The override moves the *state* and *data* directories together. Splitting them under an override
/// would make a test run write half its files into the real system location, which is exactly the
/// accident the override exists to avoid. The binary path is left alone: nothing in Phase 1 writes
/// it, and a relocated binary would not be what the service definition names.
pub fn layout() -> Layout {
let mut layout = platform_layout();
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
let root = PathBuf::from(dir);
layout.data_dir = root.join("data");
layout.state_dir = root;
}
layout
}
#[cfg(windows)]
fn platform_layout() -> Layout {
// %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to
// C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic.
let program_data = env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
let program_files = env::var_os("ProgramFiles")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
// Data lives under ProgramData, never under ProgramFiles: a service writing beneath
// C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3).
Layout {
state_dir: program_data.join("RunicGateway"),
data_dir: program_data.join("RunicGateway"),
sidecar_bin: program_files
.join("RunicGateway")
.join("uo-link-sidecar.exe"),
}
}
#[cfg(not(windows))]
fn platform_layout() -> Layout {
Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_installers_own_files_sit_in_the_state_dir() {
// Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to
// clean and `doctor` has one place to read. The cached patch set joins them in Phase 3.
let l = platform_layout();
assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path()));
assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path()));
}
#[test]
fn the_default_layout_is_absolute() {
// A relative state directory would reintroduce exactly the working-directory trap this
// layout exists to close.
let l = platform_layout();
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
}
}