Files
installer/src/bundle.rs
wtclaude 6941925fa5
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
feat(installer): build for and install on linux-aarch64
Step 4 of PLAN.md §5.2, and the half that faces the operator: the
release now cross-compiles aarch64-unknown-linux-gnu, and platform_key()
resolves ("linux","aarch64") to the bundle key link publishes under
instead of refusing the host by name.

Same toolchain shape as the Windows step -- a linker plus a CC/AR pair,
because ring (under ureq's rustls) compiles C and assembly. And the same
packaging trap named in the sums comment: an artifact missing from
SHA256SUMS is one `sha256sum -c` passes over silently, so the new binary
is added to both the sums and the upload list.

Two test changes fall out of the asset map growing a key:

- The exact `assets.len() == 2` assertion is replaced by a check that
  each key CI requires is present and well-formed. An exact count would
  fail on the first bundle that adds arm64 -- reporting correct
  behaviour as a regression.
- The host-binary lookup now accepts either outcome, and says why.
  Bundles are kept unchanged forever so `--bundle` stays reproducible,
  which means one published before arm64 existed can never gain that
  key. On such a host the run must fail with the reason rather than
  something that reads like a corrupt document, so sidecar_asset()'s
  error now says so and the test asserts it.

Verified by cross-building this crate for aarch64 in a
rust:1-slim-bookworm container -- ELF 64-bit LSB pie executable, ARM
aarch64 -- and by running fmt, clippy -D warnings and the tests on both
Linux and the Windows host, since only half of service.rs compiles on
either.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:34:08 -05:00

276 lines
11 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`, `linux-aarch64`, `windows-x86_64`) — link publishes a
/// binary per target and the installer runs on each, so a single hash could only ever describe
/// one of them. The set grows over time, so a bundle is not expected to carry every key this
/// binary knows about: an older one pinned with `--bundle` predates arm64 entirely.
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: {}).\n\
Bundles published before uo-link built for this platform cannot gain one \
retroactively — they are kept unchanged so `--bundle` stays reproducible. \
Run without `--bundle` to take the current one.",
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"),
// Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the
// sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a
// Windows-on-arm box or a Mac.
("linux", "aarch64") => Ok("linux-aarch64"),
("windows", "x86_64") => Ok("windows-x86_64"),
// Naming the platforms that do exist 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, linux-aarch64 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!(bundle.overlay.asset.name.ends_with(".tar.gz"));
}
#[test]
fn every_platform_the_bundle_names_is_well_formed() {
// A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release
// (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it
// rather than on anything being wrong.
let bundle = parse(CURRENT).unwrap();
for required in ["linux-x86_64", "windows-x86_64"] {
let asset = bundle
.link
.assets
.get(required)
.unwrap_or_else(|| panic!("bundle carries no {required} binary"));
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
}
#[test]
fn the_hosts_binary_either_resolves_or_says_why_not() {
// On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise
// fail an install after the overlay had already been deployed. On a host whose platform
// postdates the bundle (an arm64 box reading the first published one), it must fail with
// the reason, since every bundle is kept unchanged forever so `--bundle` stays
// reproducible and therefore cannot gain a key retroactively.
let bundle = parse(CURRENT).unwrap();
match bundle.sidecar_asset() {
Ok(asset) => {
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
Err(e) => {
let msg = e.to_string();
assert!(msg.contains(platform_key().unwrap()), "{msg}");
assert!(msg.contains("--bundle"), "{msg}");
}
}
}
#[test]
fn the_host_is_a_platform_the_components_are_built_for() {
// `cargo test` running at all means the host is one the crate compiles on, so a refusal
// here is a build target the release workflows have not caught up with.
let key = platform_key().unwrap();
assert!(
["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key),
"unexpected platform key {key}"
);
}
#[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"));
}
}