Files
installer/src/record.rs
wtclaude 265911a58f
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m8s
docs(installer): drop phase references that are now this build's behaviour
Five comments described the patch tier as work a later phase would do. It is
this phase, so they read as stale the moment the code landed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 20:02:52 -05:00

388 lines
16 KiB
Rust

//! `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>,
/// The sidecar: binary, config, database, service (Phase 2).
///
/// Held as raw JSON rather than as a [`LinkRecord`] so that a record written by a *newer*
/// installer — with fields this build has no name for — survives a re-run here intact. Phase 1
/// carried this section through without understanding it at all; the same tolerance now applies
/// in the other direction. Read it with [`InstallRecord::link_record`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub link: Option<serde_json::Value>,
/// The patch tier: one entry per feature actually in place, with the rung that applied each of
/// its patches.
///
/// Raw JSON for the same reason as [`InstallRecord::link`] — a record written by a newer
/// installer survives a re-run here intact. Read it with [`InstallRecord::patch_records`].
/// Only features that are *applied* appear: a declined or refused one left no trace in the
/// tree, and recording it would make `doctor` and `uninstall` report work nobody did.
#[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,
}
/// The sidecar half of a deployment, as `install.json` records it.
///
/// **The auth token is not here and must never be.** It lives in `sidecar.toml` and is printed once
/// to the operator's terminal (PLAN.md §6); `install.json` is a support artifact that gets pasted
/// into bug reports.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkRecord {
pub repo: String,
pub tag: String,
/// What the installed binary reports, not what the bundle claimed — the two agree, and if they
/// ever did not, the binary is the one that will actually run.
pub version: String,
pub protocol: u32,
pub binary: BinaryRef,
pub config_path: String,
/// Absolute, as the sidecar itself resolved it. On Windows this is anchored to the config's
/// directory rather than pinned by the service, which is why it is recorded rather than derived.
pub db_path: String,
/// `None` when no service was registered — a relocated test run, or a host with no service
/// manager the installer can drive. `doctor` reports that as an unfinished install rather than
/// as a healthy one.
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BinaryRef {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServiceRecord {
/// `systemd` or `windows-scm`.
pub kind: String,
/// `runicgateway-link.service` or `RunicGatewayLink`.
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit_path: Option<String>,
/// The account the service runs as.
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
/// The installer created that account. `uninstall` (Phase 4) removes only what it created —
/// deleting a user that was already on the host is not this tool's business.
pub user_created: bool,
}
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)
}
/// The sidecar section, when it is one this build understands.
///
/// A section it cannot parse yields `None` rather than an error: the raw value is still carried
/// through on save, so the worst case is that this run re-derives what it needs instead of
/// reading it — never that an older installer refuses to run on a newer host.
pub fn link_record(&self) -> Option<LinkRecord> {
serde_json::from_value(self.link.clone()?).ok()
}
/// The patch-tier entries this build understands.
///
/// An entry it cannot parse is dropped from the returned list but still carried through on
/// save, exactly as with [`Self::link_record`]. The consequence of a dropped entry is that this
/// run re-derives that feature's state from the tree — which the rung ladder answers correctly
/// on its own — rather than an older installer refusing to run on a newer host.
pub fn patch_records(&self) -> Vec<crate::patch::FeatureRecord> {
self.patches
.iter()
.filter_map(|v| serde_json::from_value(v.clone()).ok())
.collect()
}
}
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 the_link_section_round_trips_and_holds_no_secret() {
let link = 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: "27d491ef".repeat(8),
},
config_path: "/etc/runicgateway/sidecar.toml".into(),
db_path: "/var/lib/runicgateway/uo-link.db".into(),
service: Some(ServiceRecord {
kind: "systemd".into(),
name: "runicgateway-link.service".into(),
unit_path: Some("/etc/systemd/system/runicgateway-link.service".into()),
user: Some("runicgateway".into()),
user_created: true,
}),
};
let mut record = sample();
record.link = Some(serde_json::to_value(&link).unwrap());
assert_eq!(record.link_record().unwrap(), link);
// install.json is pasted into bug reports. The token lives in sidecar.toml and on the
// operator's terminal; there is no field here for it to arrive in.
let text = serde_json::to_string(&record).unwrap();
assert!(!text.contains("auth_token"), "{text}");
assert!(!text.contains("token"), "{text}");
}
#[test]
fn an_unreadable_link_section_is_ignored_rather_than_fatal() {
// A record written by a future installer must not stop this one from running.
let mut record = sample();
record.link = Some(serde_json::json!({ "shape": "from a newer installer" }));
assert!(record.link_record().is_none());
assert!(InstallRecord::load(Path::new("rg-no-such-record.json")).is_ok());
}
#[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}");
}
}