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

265
src/record.rs Normal file
View File

@@ -0,0 +1,265 @@
//! `install.json` — what this host has deployed.
//!
//! PLAN.md §2.3 gives this file one owner (the installer) and one job: be the thing every later
//! command reasons from. Two of its properties are load-bearing rather than informational:
//!
//! - **Per-file hashes make drift diagnosable.** A file whose content differs from *both* the
//! record and the release manifest means the overlay moved on; differing from the record alone
//! means the operator edited a deployed file (§7.0). `doctor` (Phase 4) is that comparison, and
//! `Bridge.cfg`'s "reported, not overwritten" rule (Phase 1) is the same comparison acted on.
//! - **What this build does not understand, it does not destroy.** A Phase 1 binary that re-runs on
//! a host where Phase 2 and 3 have written sidecar and patch records must give them back
//! untouched, so those sections are carried as raw JSON and unknown top-level keys are preserved
//! verbatim. A future field that silently vanished on a re-run would be worse than one that was
//! never written.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::util::write_atomic;
/// The shape of this document. Independent of the bundle's `schema` and of any protocol version.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallRecord {
pub schema: u32,
pub installer: InstallerInfo,
/// RFC 3339, UTC. The only field expected to change on every write, and therefore the only one
/// excluded when deciding whether a re-run has anything to record.
pub updated: String,
pub bundle: BundleRef,
pub servuo: ServUoRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub overlay: Option<OverlayRecord>,
/// Phase 2 (uo-link binary, config and service). Carried through untouched by this build.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<serde_json::Value>,
/// Phase 3 (applied patches, with the rung that applied each). Carried through untouched.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patches: Vec<serde_json::Value>,
/// Anything a newer installer wrote that this one has no name for.
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InstallerInfo {
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BundleRef {
pub tag: String,
pub protocol: u32,
/// The exact document this install resolved, so a re-install can be reproduced and a support
/// question about "which bundle?" is answered by the file rather than by memory.
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServUoRef {
pub path: String,
/// The detected version, or `null`. Recorded even when unknown: the patch tier's support story
/// follows the install (§2.2.2), and a later `doctor` must be able to show it without
/// re-deriving it.
pub version: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OverlayRecord {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
/// Keyed by ServUO-tree-relative path, always with `/` separators so a record written on
/// Windows is readable on Linux and vice versa.
pub files: BTreeMap<String, FileRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileRecord {
/// What the release shipped for this path.
pub overlay_sha256: String,
/// What is on disk in the ServUO tree after this run. Equal to `overlay_sha256` except for a
/// file the installer deliberately left alone (`Config/Bridge.cfg`, once edited).
pub on_disk_sha256: String,
/// `deployed` (the tree holds the release's copy) or `kept-operator-modified` (it holds the
/// operator's).
///
/// Deliberately a **state, not a verb**: recording `add` on the first run and `unchanged` on
/// the next would make every re-run rewrite this file, which is exactly the "a second run
/// writes nothing" promise in PLAN.md Phase 1. What later commands need to know is whose copy
/// is in the tree, and that does not change just because time passed.
pub state: String,
}
impl InstallRecord {
/// Whether a re-run would record anything new.
///
/// Everything except `updated` is compared: PLAN.md Phase 1 requires that a second run with no
/// upstream change reports "unchanged" and **writes nothing**, and rewriting the file purely to
/// move a timestamp would break that promise in the least visible way possible — by touching a
/// file whose mtime an operator may be watching.
pub fn same_deployment_as(&self, other: &Self) -> bool {
let mut a = self.clone();
let mut b = other.clone();
a.updated.clear();
b.updated.clear();
a == b
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let record: Self = serde_json::from_str(&body).with_context(|| {
format!(
"{} exists but is not a record this installer understands. \
Move it aside to start over, or install a newer installer.",
path.display()
)
})?;
Ok(Some(record))
}
pub fn save(&self, path: &Path) -> Result<()> {
// Pretty-printed with a trailing newline: this file is read by humans during support, and
// diffed by anyone who keeps /etc under version control.
let mut body =
serde_json::to_string_pretty(self).context("cannot serialize install.json")?;
body.push('\n');
write_atomic(path, body.as_bytes())
}
/// Files this installer previously deployed, for the `Bridge.cfg` comparison in `overlay::plan`.
pub fn overlay_files(&self) -> Option<&BTreeMap<String, FileRecord>> {
self.overlay.as_ref().map(|o| &o.files)
}
}
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn sample() -> InstallRecord {
InstallRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.1.0".into(),
},
updated: "2026-08-04T18:00:00Z".into(),
bundle: BundleRef {
tag: "2026.08.04".into(),
protocol: 3,
url: "https://example/bundles/current.json".into(),
},
servuo: ServUoRef {
path: "/opt/ServUO".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: "aa".into(),
state: "deployed".into(),
},
)]),
}),
link: None,
patches: Vec::new(),
extra: BTreeMap::new(),
}
}
#[test]
fn a_record_round_trips() {
let dir = TempDir::new("rg-test-record").unwrap();
let path = dir.path().join("install.json");
let record = sample();
record.save(&path).unwrap();
assert_eq!(InstallRecord::load(&path).unwrap().unwrap(), record);
}
#[test]
fn a_missing_record_is_not_an_error() {
let dir = TempDir::new("rg-test-record-missing").unwrap();
assert!(InstallRecord::load(&dir.path().join("nope.json"))
.unwrap()
.is_none());
}
#[test]
fn later_phases_survive_a_phase_one_rewrite() {
// The scenario: Phase 2 and 3 have written sidecar and patch sections (and some future
// field this build has never heard of), then an older installer re-runs. Dropping any of
// it would make `doctor` and `uninstall` forget a service and a set of applied hunks.
let dir = TempDir::new("rg-test-record-forward").unwrap();
let path = dir.path().join("install.json");
let body = r#"{
"schema": 1,
"installer": { "version": "0.9.0" },
"updated": "2026-09-01T00:00:00Z",
"bundle": { "tag": "2026.09.01", "protocol": 3, "url": "https://example/current.json" },
"servuo": { "path": "/opt/ServUO", "version": "57.4" },
"overlay": null,
"link": { "version": "1.1.0", "service": "runicgateway-link.service" },
"patches": [ { "name": "commandlogging-event", "rung": "region-match" } ],
"future_section": { "kept": true }
}"#;
std::fs::write(&path, body).unwrap();
let loaded = InstallRecord::load(&path).unwrap().unwrap();
loaded.save(&path).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("runicgateway-link.service"), "{text}");
assert!(text.contains("region-match"), "{text}");
assert!(text.contains("future_section"), "{text}");
}
#[test]
fn only_the_timestamp_is_ignored_when_deciding_to_rewrite() {
let a = sample();
let mut b = a.clone();
b.updated = "2027-01-01T00:00:00Z".into();
assert!(a.same_deployment_as(&b));
// Anything that actually describes the deployment must count as a change.
let mut c = a.clone();
c.bundle.tag = "2026.09.01".into();
assert!(!a.same_deployment_as(&c));
let mut d = a.clone();
if let Some(overlay) = d.overlay.as_mut() {
overlay.files.get_mut("Config/Bridge.cfg").unwrap().state =
"kept-operator-modified".into();
}
assert!(!a.same_deployment_as(&d));
}
#[test]
fn timestamps_are_utc_rfc3339() {
let now = now_rfc3339();
assert!(now.ends_with('Z'), "{now}");
assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}");
}
}