All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m8s
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>
388 lines
16 KiB
Rust
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}");
|
|
}
|
|
}
|