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
220 lines
7.9 KiB
Rust
220 lines
7.9 KiB
Rust
//! A Rust server root: that it is one, and which modding framework it runs.
|
|
//!
|
|
//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one
|
|
//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes
|
|
//! in, and the server itself already says which framework it has. Asking would be one more answer an
|
|
//! operator could get wrong.
|
|
//!
|
|
//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code
|
|
//! relied on them:
|
|
//!
|
|
//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`.
|
|
//! - **Carbon:** `carbon/managed/Carbon.dll`.
|
|
//!
|
|
//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both
|
|
//! is one mid-migration, and guessing would put the plugin where one of them never looks.
|
|
//!
|
|
//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be
|
|
//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no
|
|
//! folder keys at all — Carbon's directories move only by a launch argument, which an installer
|
|
//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
|
|
/// Which framework loads the plugin.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Framework {
|
|
Oxide,
|
|
Carbon,
|
|
}
|
|
|
|
impl Framework {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Oxide => "oxide",
|
|
Self::Carbon => "carbon",
|
|
}
|
|
}
|
|
|
|
/// The file whose presence says this framework is installed.
|
|
fn marker(self, root: &Path) -> PathBuf {
|
|
match self {
|
|
Self::Oxide => root
|
|
.join("RustDedicated_Data")
|
|
.join("Managed")
|
|
.join("Oxide.Rust.dll"),
|
|
Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"),
|
|
}
|
|
}
|
|
|
|
/// Where the framework loads plugins from.
|
|
pub fn plugins_dir(self, root: &Path) -> PathBuf {
|
|
match self {
|
|
Self::Oxide => root.join("oxide").join("plugins"),
|
|
Self::Carbon => root.join("carbon").join("plugins"),
|
|
}
|
|
}
|
|
|
|
/// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`.
|
|
pub fn config_dir(self, root: &Path) -> PathBuf {
|
|
match self {
|
|
Self::Oxide => root.join("oxide").join("config"),
|
|
Self::Carbon => root.join("carbon").join("configs"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A validated Rust server root.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RustServer {
|
|
pub path: PathBuf,
|
|
pub framework: Framework,
|
|
}
|
|
|
|
/// The file this installer owns in a server root: the plugin.
|
|
pub const PLUGIN_FILE: &str = "RunicGateway.cs";
|
|
/// The plugin's config, which is the website's once written (§34.2.3).
|
|
pub const PLUGIN_CONFIG: &str = "RunicGateway.json";
|
|
|
|
impl RustServer {
|
|
pub fn plugins_dir(&self) -> PathBuf {
|
|
self.framework.plugins_dir(&self.path)
|
|
}
|
|
|
|
pub fn plugin_path(&self) -> PathBuf {
|
|
self.plugins_dir().join(PLUGIN_FILE)
|
|
}
|
|
|
|
pub fn plugin_config_path(&self) -> PathBuf {
|
|
self.framework.config_dir(&self.path).join(PLUGIN_CONFIG)
|
|
}
|
|
}
|
|
|
|
/// Validates `path` as a Rust server root and detects its framework.
|
|
pub fn open(path: &Path) -> Result<RustServer> {
|
|
let path = std::fs::canonicalize(path)
|
|
.map(crate::servuo::strip_extended_prefix)
|
|
.with_context(|| format!("{} does not exist", path.display()))?;
|
|
if !path.is_dir() {
|
|
bail!("{} is not a directory", path.display());
|
|
}
|
|
if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() {
|
|
bail!(
|
|
"{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe",
|
|
path.display()
|
|
);
|
|
}
|
|
|
|
let oxide = Framework::Oxide.marker(&path).is_file();
|
|
let carbon = Framework::Carbon.marker(&path).is_file();
|
|
let framework = match (oxide, carbon) {
|
|
(true, false) => Framework::Oxide,
|
|
(false, true) => Framework::Carbon,
|
|
(true, true) => bail!(
|
|
"{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \
|
|
is part-way through a migration. Remove the one you are leaving, then run again.",
|
|
path.display(),
|
|
Framework::Oxide.marker(&path).display(),
|
|
Framework::Carbon.marker(&path).display()
|
|
),
|
|
(false, false) => bail!(
|
|
"{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \
|
|
them. Install one (looked for {} and {}), start the server once so it lays out its \
|
|
directories, and run this again.",
|
|
path.display(),
|
|
Framework::Oxide.marker(&path).display(),
|
|
Framework::Carbon.marker(&path).display()
|
|
),
|
|
};
|
|
Ok(RustServer { path, framework })
|
|
}
|
|
|
|
/// Whether a RustDedicated is running out of this root.
|
|
///
|
|
/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the
|
|
/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3).
|
|
/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent.
|
|
pub fn is_running(root: &Path) -> bool {
|
|
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
|
|
|
let system = System::new_with_specifics(
|
|
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
|
);
|
|
let root = normalize(&root.to_string_lossy());
|
|
system.processes().values().any(|process| {
|
|
let exe = process
|
|
.exe()
|
|
.map(|p| normalize(&p.to_string_lossy()))
|
|
.unwrap_or_default();
|
|
exe.starts_with(&root) && exe.contains("rustdedicated")
|
|
})
|
|
}
|
|
|
|
fn normalize(s: &str) -> String {
|
|
s.to_lowercase().replace('\\', "/")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::util::TempDir;
|
|
|
|
fn server(dir: &Path, frameworks: &[Framework]) {
|
|
std::fs::write(dir.join("RustDedicated"), b"").unwrap();
|
|
for f in frameworks {
|
|
let marker = f.marker(dir);
|
|
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
|
|
std::fs::write(marker, b"").unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_framework_is_detected_and_places_the_plugin_its_own_way() {
|
|
for (f, plugins, config) in [
|
|
(Framework::Oxide, "oxide/plugins", "oxide/config"),
|
|
(Framework::Carbon, "carbon/plugins", "carbon/configs"),
|
|
] {
|
|
let dir = TempDir::new("rg-rust-root").unwrap();
|
|
server(dir.path(), &[f]);
|
|
let root = open(dir.path()).unwrap();
|
|
assert_eq!(root.framework, f);
|
|
let rel = |p: PathBuf| {
|
|
p.strip_prefix(&root.path)
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
};
|
|
assert_eq!(rel(root.plugins_dir()), plugins);
|
|
assert_eq!(
|
|
rel(root.plugin_config_path()),
|
|
format!("{config}/RunicGateway.json")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn both_frameworks_are_refused_and_so_is_neither() {
|
|
let dir = TempDir::new("rg-rust-both").unwrap();
|
|
server(dir.path(), &[Framework::Oxide, Framework::Carbon]);
|
|
let err = open(dir.path()).unwrap_err().to_string();
|
|
assert!(
|
|
err.contains("both Oxide") && err.contains("Carbon"),
|
|
"{err}"
|
|
);
|
|
|
|
let dir = TempDir::new("rg-rust-none").unwrap();
|
|
server(dir.path(), &[]);
|
|
let err = open(dir.path()).unwrap_err().to_string();
|
|
assert!(err.contains("neither Oxide nor Carbon"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_directory_without_the_game_is_not_a_server_root() {
|
|
let dir = TempDir::new("rg-rust-empty").unwrap();
|
|
let err = open(dir.path()).unwrap_err().to_string();
|
|
assert!(err.contains("RustDedicated"), "{err}");
|
|
}
|
|
}
|