feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s

Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-25 23:40:11 -05:00
parent 2285fff759
commit 7027a78a23
24 changed files with 3410 additions and 94 deletions

View File

@@ -14,6 +14,7 @@
//! half-understood.
use anyhow::{bail, Context, Result};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
@@ -27,9 +28,6 @@ use std::collections::BTreeMap;
const BUNDLE_BASE: &str =
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/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,
@@ -126,7 +124,10 @@ pub fn platform_key() -> Result<&'static str> {
}
}
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle <tag>` pins it.
///
/// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md
/// §34.4). This build reads it only as a fallback — see [`fetch`].
pub fn url_for(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
@@ -134,33 +135,137 @@ pub fn url_for(tag: Option<&str>) -> String {
}
}
/// Fetches and validates a bundle.
/// Which game a schema-2 bundle describes, and so which stream it is read from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
ServUo,
Rust,
}
impl Game {
/// The `game` discriminant in the document, and the directory under `v2/`.
pub fn as_str(self) -> &'static str {
match self {
Self::ServUo => "servuo",
Self::Rust => "rust",
}
}
}
/// The Gitea **contents** API for the `bundles` branch.
///
/// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with
/// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the
/// branch — an `install` run right after a release would install the release before it. The
/// contents API is `private, must-revalidate` and always answers with the branch as it is. It
/// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps.
const CONTENTS_BASE: &str =
"https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents";
/// The file name of a bundle in its stream.
fn file_name(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("bundle-{tag}.json"),
None => "current.json".to_string(),
}
}
/// The address a person opens to read a schema-2 bundle — what `install.json` records. The same
/// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens.
pub fn v2_url(game: Game, tag: Option<&str>) -> String {
format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag))
}
/// Fetches a schema-2 document's text from its game's stream.
fn fetch_v2(game: Game, tag: Option<&str>) -> Result<String> {
let url = format!(
"{CONTENTS_BASE}/v2/{}/{}?ref=bundles",
game.as_str(),
file_name(tag)
);
let envelope = crate::net::get_text(&url)?;
#[derive(Deserialize)]
struct Contents {
content: String,
}
let contents: Contents = serde_json::from_str(&envelope)
.context("the bundles branch answered with something that is not a file")?;
// Gitea wraps the base64 at 76 columns; the decoder wants it whole.
let packed: String = contents
.content
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(packed)
.context("the bundle file is not valid base64")?;
String::from_utf8(bytes).context("the bundle file is not UTF-8")
}
/// Fetches and validates a ServUO bundle.
///
/// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any
/// pin published since schema 2 are read from `v2/servuo/`; a `--bundle <tag>` from before it exists
/// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody
/// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing
/// while both are composed and means this build never depends on the new stream alone.
///
/// The returned URL is the document that was actually used.
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 v2 = fetch_v2(Game::ServUo, tag);
let (body, url) = match v2 {
Ok(body) => (body, v2_url(Game::ServUo, tag)),
Err(v2_error) => {
let url = url_for(tag);
let body = crate::net::get_text(&url)
.map_err(|_| v2_error)
.with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so \
check the tag against {BUNDLE_BASE}/v2/servuo/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
(body, url)
}
};
let bundle = parse(&body)?;
Ok((bundle, url))
}
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
/// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is
/// downloaded.
///
/// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO
/// code path, and `install.json`, already speaks it, and the two documents carry exactly the same
/// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO
/// deployment therefore records the same thing whichever schema it was resolved from.
pub fn parse(body: &str) -> Result<Bundle> {
let bundle: Bundle = serde_json::from_str(body)
let head: SchemaHead = 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}. \
let bundle: Bundle = match head.schema {
1 => serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?,
2 => {
if head.game.as_deref() != Some(Game::ServUo.as_str()) {
bail!(
"bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \
Rust bundle.",
head.bundle,
head.game.unwrap_or_default()
);
}
let v2: ServUoBundleV2 = serde_json::from_str(body).context(
"the schema-2 ServUO bundle is not in the shape this installer understands",
)?;
v2.lower()
}
other => bail!(
"bundle {} declares schema {other} and this installer understands 1 and 2. \
Update the installer — the bundle format changed.",
bundle.bundle,
bundle.schema
);
}
head.bundle,
),
};
// 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
@@ -187,6 +292,202 @@ pub fn parse(body: &str) -> Result<Bundle> {
Ok(bundle)
}
/// Just enough of any bundle to decide how to read the rest of it.
#[derive(Deserialize)]
struct SchemaHead {
schema: u32,
#[serde(default)]
game: Option<String>,
#[serde(default)]
bundle: String,
}
/// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower).
#[derive(Deserialize)]
struct ServUoBundleV2 {
bundle: String,
generated: String,
protocol: u32,
sidecar: LinkComponent,
payload: OverlayPayloadV2,
}
#[derive(Deserialize)]
struct OverlayPayloadV2 {
kind: String,
repo: String,
tag: String,
version: String,
commit: String,
protocol: u32,
compat: ServUoCompat,
asset: Asset,
}
impl ServUoBundleV2 {
fn lower(self) -> Bundle {
// `kind` is informational here: `game` already said ServUO, and a ServUO payload is an
// overlay by definition. Kept in the struct so a document missing it fails to parse.
let _ = self.payload.kind;
Bundle {
schema: 2,
bundle: self.bundle,
generated: self.generated,
protocol: self.protocol,
link: self.sidecar,
overlay: OverlayComponent {
repo: self.payload.repo,
tag: self.payload.tag,
version: self.payload.version,
commit: self.payload.commit,
protocol: self.payload.protocol,
servuo: self.payload.compat,
asset: self.payload.asset,
},
}
}
}
// ── Rust ─────────────────────────────────────────────────────────────────────
/// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI
/// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustBundle {
pub schema: u32,
pub game: String,
pub bundle: String,
pub generated: String,
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustSidecar {
pub repo: String,
pub tag: String,
pub version: String,
pub protocol: u32,
/// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149).
pub assets: BTreeMap<String, Asset>,
/// The egg's launcher. The installer does not use it — a host runs the sidecar as a service —
/// but it is part of the pair CI checked, so it is carried rather than dropped.
pub launcher: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginPayload {
pub kind: String,
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
pub compat: PluginCompat,
/// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginCompat {
/// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by
/// `doctor` beside a failure; never measured from a DLL (§34.2.3).
pub frameworks: BTreeMap<String, FrameworkFloor>,
/// Third-party plugins the features expect. Reported, never installed (D153).
pub requires_plugins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FrameworkFloor {
pub min_version: String,
}
impl RustBundle {
/// The sidecar binary for this host. Resolved before anything is written, like ServUO's.
pub fn sidecar_asset(&self) -> Result<&Asset> {
let key = platform_key()?;
self.sidecar.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \
itself runs on linux-x86_64 and windows-x86_64 only.",
self.bundle,
self.sidecar
.assets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> {
let body = fetch_v2(Game::Rust, tag).with_context(|| match tag {
Some(tag) => format!(
"cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \
tag against {BUNDLE_BASE}/v2/rust/"
),
None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \
released, there is none yet."
.to_string(),
})?;
Ok((parse_rust(&body)?, v2_url(Game::Rust, tag)))
}
/// Parses a Rust bundle and applies the checks that must hold before anything is downloaded.
pub fn parse_rust(body: &str) -> Result<RustBundle> {
let head: SchemaHead = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
if head.schema != 2 {
bail!(
"Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2",
head.bundle,
head.schema
);
}
if head.game.as_deref() != Some(Game::Rust.as_str()) {
bail!(
"bundle {} describes game {:?}, not Rust",
head.bundle,
head.game.unwrap_or_default()
);
}
let bundle: RustBundle = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
// Gate 1 again, for a document that never went through CI. It matters more here than for
// ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being
// refused.
if bundle.sidecar.protocol != bundle.payload.protocol
|| bundle.protocol != bundle.sidecar.protocol
{
bail!(
"Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}",
bundle.bundle,
bundle.protocol,
bundle.sidecar.protocol,
bundle.payload.protocol
);
}
if bundle.payload.kind != "plugin" {
bail!(
"Rust bundle {} carries a {:?} payload; this installer deploys a plugin",
bundle.bundle,
bundle.payload.kind
);
}
if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a plugin asset with no URL or checksum",
bundle.bundle
);
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -262,9 +563,78 @@ mod tests {
#[test]
fn a_newer_schema_is_refused_rather_than_guessed_at() {
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3");
let err = parse(&body).unwrap_err().to_string();
assert!(err.contains("schema 2"), "{err}");
assert!(err.contains("schema 3"), "{err}");
}
/// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as
/// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag.
const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json");
const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json");
/// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with
/// the mock host's URLs pointed back at Gitea.
const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json");
#[test]
fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() {
// The lowering is the whole claim that a ServUO host records the same deployment whichever
// stream it read. Only `schema` and `generated` describe the document rather than the pair.
let mut v2 = parse(V2_SERVUO).unwrap();
let mut v1 = parse(V1_SAME).unwrap();
assert_eq!(v2.schema, 2);
v2.schema = 1;
v2.generated.clear();
v1.generated.clear();
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();
assert!(err.contains("--game rust"), "{err}");
}
#[test]
fn the_rust_bundle_parses() {
let bundle = parse_rust(RUST).unwrap();
assert_eq!(bundle.game, "rust");
assert_eq!(bundle.protocol, 12);
assert_eq!(bundle.payload.kind, "plugin");
assert_eq!(
bundle.payload.compat.frameworks["carbon"].min_version,
"2.0.259"
);
assert_eq!(
bundle.payload.compat.requires_plugins,
["Kits", "ZoneManager"]
);
assert!(!bundle.sidecar.assets.contains_key("linux-aarch64"));
assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh");
}
#[test]
fn a_servuo_bundle_is_refused_by_the_rust_reader() {
let err = parse_rust(V2_SERVUO).unwrap_err().to_string();
assert!(err.contains("not Rust"), "{err}");
let err = parse_rust(V1_SAME).unwrap_err().to_string();
assert!(err.contains("schema 1"), "{err}");
}
#[test]
fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() {
let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap();
doc["payload"]["protocol"] = 11.into();
let err = parse_rust(&doc.to_string()).unwrap_err().to_string();
assert!(err.contains("internally inconsistent"), "{err}");
}
#[test]
fn schema_2_urls_name_their_game() {
assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json"));
assert!(
v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json")
);
}
#[test]