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
288 lines
13 KiB
Rust
288 lines
13 KiB
Rust
//! Where the installer's own files live.
|
|
//!
|
|
//! These paths are fixed by `docs/installer/INSTALL.md` §3 and are the installer's side of the
|
|
//! working-directory trap described in PLAN.md §2.3: the sidecar's own defaults are relative to its
|
|
//! working directory, and a service manager's working directory is not somewhere to put a database.
|
|
//! The installer therefore owns the layout and pins the config path into the service definition.
|
|
//!
|
|
//! **How the database path is pinned differs by platform, and that is not an inconsistency.** The
|
|
//! sidecar resolves a relative `[store].path` against the directory holding `sidecar.toml`
|
|
//! (Phase 0.2), so on Windows — where config and data are both `%ProgramData%\RunicGateway` — the
|
|
//! shipped default already lands exactly where §3 says, and nothing needs to be set. On Linux the
|
|
//! two directories are deliberately different (`/etc` vs `/var/lib`), so the unit carries
|
|
//! `UOLINK_DB_PATH`. Setting it on Windows would mean a machine-wide environment variable, which
|
|
//! every process on the host inherits and which outlives an uninstall.
|
|
|
|
use std::env;
|
|
use std::path::PathBuf;
|
|
|
|
/// Escape hatch for testing a run without root/Administrator. Documented in `--help` rather than
|
|
/// hidden: an undocumented environment variable that moves where a tool writes is worse than a
|
|
/// documented one, and `doctor` in Phase 4 must honour the same value to find what `install` wrote.
|
|
pub const STATE_DIR_ENV: &str = "RUNICGATEWAY_STATE_DIR";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Layout {
|
|
/// `/etc/runicgateway` — `install.json`, `sidecar.toml`, `patches/`.
|
|
pub state_dir: PathBuf,
|
|
/// `/var/lib/runicgateway` — the sidecar's SQLite store.
|
|
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,
|
|
}
|
|
|
|
impl Layout {
|
|
pub fn install_record(&self) -> PathBuf {
|
|
self.state_dir.join("install.json")
|
|
}
|
|
|
|
pub fn sidecar_config(&self) -> PathBuf {
|
|
self.state_dir.join("sidecar.toml")
|
|
}
|
|
|
|
pub fn sidecar_db(&self) -> PathBuf {
|
|
self.data_dir.join("uo-link.db")
|
|
}
|
|
|
|
/// `/etc/runicgateway/patches` — the cached patch set (INSTALL.md §3).
|
|
///
|
|
/// Every patch the tier *evaluated* is cached here, not only the ones that applied. `uninstall`
|
|
/// needs the applied ones to print the exact hunks to revert long after the release tarball is
|
|
/// gone (PLAN.md §5), and a refused one is the file the run just told the operator to apply by
|
|
/// hand — pointing them at a path that only exists on success would be the less useful half.
|
|
pub fn patches_dir(&self) -> PathBuf {
|
|
self.state_dir.join("patches")
|
|
}
|
|
|
|
/// `/etc/runicgateway/patches/originals` — each patched file exactly as it was before the tier
|
|
/// first touched it, mirroring its path in the ServUO tree.
|
|
///
|
|
/// The tier edits files the operator owns, so the pre-image is what turns "here are the hunks
|
|
/// we added" into a revert anyone can verify. It lives here rather than beside the file it
|
|
/// copies, because an installer-owned file inside the ServUO tree is one `uninstall` has
|
|
/// promised never to clean up.
|
|
pub fn patch_originals_dir(&self) -> PathBuf {
|
|
self.patches_dir().join("originals")
|
|
}
|
|
|
|
/// `/etc/runicgateway/backups` — one dated directory per run that overwrote something
|
|
/// (PLAN.md §5.3).
|
|
///
|
|
/// Beside the cached patch set rather than inside it: both survive an uninstall and both go
|
|
/// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy
|
|
/// of what the *release* shipped.
|
|
pub fn backups_dir(&self) -> PathBuf {
|
|
self.state_dir.join("backups")
|
|
}
|
|
|
|
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
|
|
/// layout, where no service is registered at all.
|
|
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`].
|
|
///
|
|
/// The override moves **everything the installer would write**: state, data, and the sidecar
|
|
/// binary. Phase 1 left the binary alone because nothing wrote it; Phase 2 does, and a run that
|
|
/// relocated its config while still dropping a binary into `/usr/bin` would be exactly the
|
|
/// half-in-the-real-system accident this variable exists to avoid.
|
|
///
|
|
/// A relocated layout also **suppresses service registration** (see `service::ensure`). There is
|
|
/// no such thing as a relocated systemd unit or a relocated Windows service — both are
|
|
/// system-global — so the honest behaviour is to install the files, say plainly that no service was
|
|
/// registered, and print what a real run would have done.
|
|
pub fn layout() -> Layout {
|
|
let mut layout = platform_layout();
|
|
if let Some(dir) = env::var_os(STATE_DIR_ENV).filter(|v| !v.is_empty()) {
|
|
let root = PathBuf::from(dir);
|
|
// The file name is kept so a relocated run installs the same binary name a real one would,
|
|
// which is what makes `--print-config` and `--version` output comparable between the two.
|
|
let bin_name = layout
|
|
.sidecar_bin
|
|
.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;
|
|
}
|
|
layout
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn platform_layout() -> Layout {
|
|
// %ProgramData% and %ProgramFiles% are read from the environment rather than hardcoded to
|
|
// C:\: a Windows install on another drive, or a redirected ProgramData, is not exotic.
|
|
let program_data = env::var_os("ProgramData")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
|
|
let program_files = env::var_os("ProgramFiles")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| PathBuf::from(r"C:\Program Files"));
|
|
|
|
// Data lives under ProgramData, never under ProgramFiles: a service writing beneath
|
|
// C:\Program Files either fails or lands silently in a per-user VirtualStore copy (PLAN §2.3).
|
|
Layout {
|
|
state_dir: program_data.join("RunicGateway"),
|
|
data_dir: program_data.join("RunicGateway"),
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn platform_layout() -> 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,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_installers_own_files_sit_in_the_state_dir() {
|
|
// Everything the installer owns lives together, so `uninstall` (Phase 4) has one place to
|
|
// clean and `doctor` has one place to read. The cached patch set and the pre-image
|
|
// copies of every patched file live there too.
|
|
let l = platform_layout();
|
|
assert_eq!(l.install_record().parent(), Some(l.state_dir.as_path()));
|
|
assert_eq!(l.sidecar_config().parent(), Some(l.state_dir.as_path()));
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_layout_is_absolute() {
|
|
// A relative state directory would reintroduce exactly the working-directory trap this
|
|
// layout exists to close.
|
|
let l = platform_layout();
|
|
assert!(l.state_dir.is_absolute(), "{:?}", l.state_dir);
|
|
assert!(l.data_dir.is_absolute(), "{:?}", l.data_dir);
|
|
assert!(l.sidecar_bin.is_absolute(), "{:?}", l.sidecar_bin);
|
|
assert!(!l.relocated);
|
|
}
|
|
|
|
#[test]
|
|
fn the_windows_database_lands_beside_its_config_by_default() {
|
|
// The Windows service pins only the config path; the database follows because the sidecar
|
|
// anchors a relative [store].path to the config's directory. That only holds while these
|
|
// two directories are the same one, so it is asserted rather than assumed.
|
|
#[cfg(windows)]
|
|
{
|
|
let l = platform_layout();
|
|
assert_eq!(l.sidecar_config().parent(), l.sidecar_db().parent());
|
|
}
|
|
}
|
|
|
|
#[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
|
|
// override while still dropping a binary into /usr/bin or %ProgramFiles%.
|
|
let mut l = platform_layout();
|
|
let real_bin = l.sidecar_bin.clone();
|
|
let root = std::env::temp_dir().join("rg-layout-test");
|
|
let bin_name = l.sidecar_bin.file_name().map(PathBuf::from).unwrap();
|
|
l.data_dir = root.join("data");
|
|
l.sidecar_bin = root.join("bin").join(&bin_name);
|
|
l.state_dir = root.clone();
|
|
l.relocated = true;
|
|
|
|
assert!(l.install_record().starts_with(&root));
|
|
assert!(l.sidecar_config().starts_with(&root));
|
|
assert!(l.sidecar_db().starts_with(&root));
|
|
assert!(l.sidecar_bin.starts_with(&root));
|
|
assert_ne!(l.sidecar_bin, real_bin);
|
|
assert_eq!(l.sidecar_bin.file_name(), real_bin.file_name());
|
|
}
|
|
}
|