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

@@ -29,6 +29,9 @@ pub struct Layout {
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
pub sidecar_bin: PathBuf,
/// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust
/// instance on the host (docs/modules/rust/PLAN.md §34.2.3).
pub rust_sidecar_bin: PathBuf,
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
/// deployment. Service registration is skipped when it is set — see [`layout`].
pub relocated: bool,
@@ -83,6 +86,53 @@ impl Layout {
pub fn systemd_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
}
// ── Rust ─────────────────────────────────────────────────────────────────
//
// Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a
// host running both games has two records that cannot corrupt each other (§34.4) and an
// uninstall of one never reaches the other.
/// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance.
pub fn rust_record(&self) -> PathBuf {
self.state_dir.join("rust").join("install.json")
}
/// Where instance configs live on Linux — the directory the template unit names with `%i`.
pub fn rust_config_dir(&self) -> PathBuf {
self.state_dir.join("rust")
}
/// One instance's `sidecar.toml`.
///
/// **Linux:** `/etc/runicgateway/rust/<id>.toml`, beside its siblings, because the template
/// unit derives the path from the instance name. **Windows:** inside the instance's own
/// directory, because a Windows service logs beside its config — two instances sharing a
/// directory would share, and fight over, one log file.
pub fn rust_config(&self, server_id: &str) -> PathBuf {
if cfg!(windows) {
self.rust_data_dir(server_id).join("sidecar.toml")
} else {
self.rust_config_dir().join(format!("{server_id}.toml"))
}
}
/// `/var/lib/runicgateway/rust/<id>` — one instance's store (and, on Windows, its config and
/// logs).
pub fn rust_data_dir(&self, server_id: &str) -> PathBuf {
self.data_dir.join("rust").join(server_id)
}
/// One instance's SQLite store. Written into its config as an absolute path, so the store never
/// depends on anybody's working directory or on a shared default name.
pub fn rust_db(&self, server_id: &str) -> PathBuf {
self.rust_data_dir(server_id).join("rust-link.db")
}
/// The shared systemd template for every Rust instance.
pub fn rust_template_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT)
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
@@ -107,8 +157,14 @@ pub fn layout() -> Layout {
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
let rust_bin_name = layout
.rust_sidecar_bin
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("rust-link-sidecar"));
layout.data_dir = root.join("data");
layout.sidecar_bin = root.join("bin").join(bin_name);
layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name);
layout.state_dir = root;
layout.relocated = true;
}
@@ -134,6 +190,9 @@ fn platform_layout() -> Layout {
sidecar_bin: program_files
.join("RunicGateway")
.join("uo-link-sidecar.exe"),
rust_sidecar_bin: program_files
.join("RunicGateway")
.join("rust-link-sidecar.exe"),
relocated: false,
}
}
@@ -144,6 +203,7 @@ fn platform_layout() -> Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: false,
}
}
@@ -185,6 +245,25 @@ mod tests {
}
}
#[test]
fn two_rust_instances_share_nothing_but_the_binary() {
// The failure this prevents: two instances on one database, one token file, or (on
// Windows) one log file.
let l = platform_layout();
assert_ne!(l.rust_config("alpha"), l.rust_config("beta"));
assert_ne!(l.rust_db("alpha"), l.rust_db("beta"));
// A Windows service logs beside its config, so there each instance needs its own directory.
if cfg!(windows) {
assert_ne!(
l.rust_config("alpha").parent(),
l.rust_config("beta").parent()
);
}
assert_ne!(l.rust_record(), l.install_record());
assert_ne!(l.rust_sidecar_bin, l.sidecar_bin);
assert!(l.rust_db("alpha").is_absolute());
}
#[test]
fn a_relocated_layout_moves_the_binary_too() {
// The failure this prevents: a test run that writes its config and database under the