All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
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>
276 lines
11 KiB
Rust
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"));
|
|
}
|
|
}
|