Files
installer/src/bundle.rs
wtclaude dff4ad41c9
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m31s
feat(installer): implement Phase 1 — the installer core
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>
2026-08-04 14:58:17 -05:00

229 lines
8.8 KiB
Rust

//! The bundle manifest — "what to install", resolved at run time.
//!
//! PLAN.md §7.1: **the bundle is the compat matrix.** CI names one exact, protocol-checked pair of
//! sidecar + overlay versions and commits it to this repo under `bundles/`; the installer fetches
//! it anonymously and installs *that pair*, rather than hardcoding versions or taking each repo's
//! newest release and hoping the two agree.
//!
//! Two consequences show up directly in this module:
//!
//! - **No protocol version is hardcoded anywhere** (§7.4). The number is read from the bundle and
//! cross-checked against the overlay's own `manifest.json` at deploy time.
//! - **`schema` is not `protocol`.** It versions the shape of this document and moves
//! independently of both components' versions; a bundle from a newer CI is refused rather than
//! half-understood.
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
const BUNDLE_BASE: &str =
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
/// The only `schema` this build understands.
const SUPPORTED_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bundle {
pub schema: u32,
/// The bundle tag, a UTC date, possibly suffixed (`2026.08.04.2`) when a day has two.
pub bundle: String,
pub generated: String,
/// The wire protocol both halves were checked to agree on.
pub protocol: u32,
pub link: LinkComponent,
pub overlay: OverlayComponent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LinkComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub protocol: u32,
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
/// the installer runs on both, so a single hash could only ever describe one of them.
pub assets: BTreeMap<String, Asset>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OverlayComponent {
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
pub servuo: ServUoCompat,
/// One tarball, platform-independent: the overlay is C# source that ServUO compiles at boot.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ServUoCompat {
/// The oldest ServUO the *base* overlay is known good on. It only adds files.
pub min_version: String,
/// The single ServUO version the *patch tier* was written and verified against (§2.2).
pub patches_verified_against: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Asset {
pub name: String,
pub url: String,
pub sha256: String,
}
impl Bundle {
/// The bundle's own asset for the platform this binary is running on.
///
/// Installing the sidecar is Phase 2, but the lookup lives here so that a run on a platform the
/// bundle has no binary for fails while resolving — before anything has been written into a
/// ServUO tree — rather than after the overlay is already deployed.
pub fn sidecar_asset(&self) -> Result<&Asset> {
let key = platform_key()?;
self.link.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"bundle {} has no uo-link binary for {key} (it has: {})",
self.bundle,
self.link
.assets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
/// The platform key used by `link.assets`, matching the names the bundle CI assigns.
pub fn platform_key() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Ok("linux-x86_64"),
("windows", "x86_64") => Ok("windows-x86_64"),
// arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats
// failing later with a missing-key error that reads like a corrupt bundle.
(os, arch) => bail!(
"no Runic Gateway build exists for {os}/{arch}. \
The released components target linux-x86_64 and windows-x86_64."
),
}
}
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
pub fn url_for(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
None => format!("{BUNDLE_BASE}/current.json"),
}
}
/// Fetches and validates a bundle.
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
let url = url_for(tag);
let body = crate::net::get_text(&url).with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
against {BUNDLE_BASE}/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
let bundle = parse(&body)?;
Ok((bundle, url))
}
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
pub fn parse(body: &str) -> Result<Bundle> {
let bundle: Bundle = serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?;
if bundle.schema != SUPPORTED_SCHEMA {
bail!(
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
Update the installer — the bundle format changed.",
bundle.bundle,
bundle.schema
);
}
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
// manifest that never went through the compose job.
if bundle.link.protocol != bundle.overlay.protocol || bundle.protocol != bundle.link.protocol {
bail!(
"bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, overlay {}. \
A mismatched pair is rejected by the sidecar with 409 rather than mis-parsed, so this \
is refused here.",
bundle.bundle,
bundle.protocol,
bundle.link.protocol,
bundle.overlay.protocol
);
}
if bundle.overlay.asset.url.is_empty() || bundle.overlay.asset.sha256.is_empty() {
bail!(
"bundle {} names an overlay asset with no URL or checksum",
bundle.bundle
);
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
const CURRENT: &str = include_str!("../bundles/current.json");
#[test]
fn the_published_bundle_parses() {
let bundle = parse(CURRENT).unwrap();
assert_eq!(bundle.schema, 1);
assert_eq!(bundle.bundle, "2026.08.04");
assert_eq!(bundle.protocol, 3);
assert_eq!(bundle.link.version, "1.1.0");
assert_eq!(bundle.overlay.version, "0.1.1");
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
assert_eq!(bundle.link.assets.len(), 2);
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
}
#[test]
fn both_platforms_have_a_sidecar_binary() {
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
// host's binary would fail an install after the overlay had already been deployed.
let bundle = parse(CURRENT).unwrap();
let asset = bundle.sidecar_asset().unwrap();
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
#[test]
fn a_newer_schema_is_refused_rather_than_guessed_at() {
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
let err = parse(&body).unwrap_err().to_string();
assert!(err.contains("schema 2"), "{err}");
}
#[test]
fn a_protocol_disagreement_inside_one_bundle_is_refused() {
// Exactly what CI's gate 1 exists to prevent; re-checked here for documents that never
// went through it.
let body = CURRENT.replacen("\"protocol\": 3", "\"protocol\": 4", 2);
let err = parse(&body).unwrap_err().to_string();
assert!(err.contains("internally inconsistent"), "{err}");
}
#[test]
fn the_pinned_and_current_urls_differ() {
assert!(url_for(None).ends_with("/current.json"));
assert!(url_for(Some("2026.08.04")).ends_with("/bundle-2026.08.04.json"));
}
}