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
209 lines
7.6 KiB
Rust
209 lines
7.6 KiB
Rust
//! `rust/install.json` — what this host has deployed for Rust.
|
|
//!
|
|
//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md
|
|
//! §34.4): a host running both games has two records that cannot corrupt each other, and an
|
|
//! installer that predates Rust never sees this one at all.
|
|
//!
|
|
//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every
|
|
//! instance together because they share the binary (§34.4), which is why the bundle is recorded once
|
|
//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in
|
|
//! each instance's `sidecar.toml` — and fields this build has no name for are carried through.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
|
|
use crate::util::write_atomic;
|
|
|
|
/// The shape of this document.
|
|
pub const SCHEMA: u32 = 1;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct RustRecord {
|
|
pub schema: u32,
|
|
pub installer: InstallerInfo,
|
|
pub updated: String,
|
|
pub bundle: BundleRef,
|
|
pub sidecar: ComponentRecord,
|
|
pub binary: BinaryRef,
|
|
pub plugin: ComponentRecord,
|
|
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
|
|
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
|
|
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
|
|
#[serde(default)]
|
|
pub service_user_created: bool,
|
|
pub instances: BTreeMap<String, Instance>,
|
|
#[serde(flatten)]
|
|
pub extra: BTreeMap<String, serde_json::Value>,
|
|
}
|
|
|
|
/// A released component, as installed.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct ComponentRecord {
|
|
pub repo: String,
|
|
pub tag: String,
|
|
pub version: String,
|
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
pub commit: String,
|
|
pub protocol: u32,
|
|
}
|
|
|
|
/// One Rust server and its sidecar.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct Instance {
|
|
pub server_root: String,
|
|
/// `oxide` or `carbon`, as detected when this was recorded.
|
|
pub framework: String,
|
|
pub plugin_path: String,
|
|
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
|
|
pub plugin_sha256: String,
|
|
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
|
|
pub plugin_config: String,
|
|
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
|
|
#[serde(default)]
|
|
pub plugin_config_written: bool,
|
|
/// The loopback port the plugin dials — the plugin config's `Port`.
|
|
pub game_port: u16,
|
|
/// The port the website reaches this instance's sidecar on.
|
|
pub web_port: u16,
|
|
pub config_path: String,
|
|
pub db_path: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub service: Option<ServiceRecord>,
|
|
}
|
|
|
|
impl RustRecord {
|
|
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
|
|
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 = 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<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
|
}
|
|
let mut body =
|
|
serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?;
|
|
body.push('\n');
|
|
write_atomic(path, body.as_bytes())
|
|
}
|
|
|
|
/// Game ports held by instances other than `except`.
|
|
pub fn game_ports_except(&self, except: &str) -> Vec<u16> {
|
|
self.instances
|
|
.iter()
|
|
.filter(|(id, _)| id.as_str() != except)
|
|
.map(|(_, i)| i.game_port)
|
|
.collect()
|
|
}
|
|
|
|
/// Web ports held by instances other than `except`.
|
|
pub fn web_ports_except(&self, except: &str) -> Vec<u16> {
|
|
self.instances
|
|
.iter()
|
|
.filter(|(id, _)| id.as_str() != except)
|
|
.map(|(_, i)| i.web_port)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::util::TempDir;
|
|
|
|
pub fn sample() -> RustRecord {
|
|
let instance = |root: &str, game: u16, web: u16| Instance {
|
|
server_root: root.into(),
|
|
framework: "oxide".into(),
|
|
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
|
|
plugin_sha256: "ab".repeat(32),
|
|
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
|
|
plugin_config_written: true,
|
|
game_port: game,
|
|
web_port: web,
|
|
config_path: "/etc/runicgateway/rust/x.toml".into(),
|
|
db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(),
|
|
service: None,
|
|
};
|
|
RustRecord {
|
|
schema: SCHEMA,
|
|
installer: InstallerInfo {
|
|
version: "0.3.0".into(),
|
|
},
|
|
updated: "2026-09-26T00:00:00Z".into(),
|
|
bundle: BundleRef {
|
|
tag: "2026.09.26".into(),
|
|
protocol: 12,
|
|
url: "https://example/v2/rust/current.json".into(),
|
|
},
|
|
sidecar: ComponentRecord {
|
|
repo: "RunicGateway/Rust-Link".into(),
|
|
tag: "v0.1.0".into(),
|
|
version: "0.1.0".into(),
|
|
commit: String::new(),
|
|
protocol: 12,
|
|
},
|
|
binary: BinaryRef {
|
|
path: "/usr/bin/runicgateway-rust-link".into(),
|
|
sha256: "cd".repeat(32),
|
|
},
|
|
plugin: ComponentRecord {
|
|
repo: "RunicGateway/Rust-Plugins".into(),
|
|
tag: "v0.1.0".into(),
|
|
version: "0.1.0".into(),
|
|
commit: "abc".into(),
|
|
protocol: 12,
|
|
},
|
|
service_user_created: true,
|
|
instances: BTreeMap::from([
|
|
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
|
|
("beta".to_string(), instance("/srv/b", 7800, 8091)),
|
|
]),
|
|
extra: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_record_round_trips_and_holds_no_token() {
|
|
let dir = TempDir::new("rg-rust-record").unwrap();
|
|
let path = dir.path().join("rust").join("install.json");
|
|
let record = sample();
|
|
record.save(&path).unwrap();
|
|
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
|
|
let text = std::fs::read_to_string(&path).unwrap();
|
|
assert!(!text.contains("auth_token") && !text.contains("token\""));
|
|
}
|
|
|
|
#[test]
|
|
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
|
|
let record = sample();
|
|
assert_eq!(record.game_ports_except("alpha"), [7800]);
|
|
assert_eq!(record.web_ports_except("beta"), [8090]);
|
|
}
|
|
}
|