//! `install --game rust` and `update --game rust` — one pipeline, as for ServUO. //! //! docs/modules/rust/PLAN.md §34.2.3. What an instance is: one Rust server root, its plugin, and a //! sidecar of its own — service, config, database, game port and web port — named by `--server-id` //! (D148). What instances share: the one sidecar binary and the one bundle, so `update` moves every //! instance on the host together (§34.4). //! //! The order follows ServUO's, for the same reasons: //! //! 1. **Resolve everything that can fail cheaply first** — the bundle, this host's binary, the //! server root and its framework, the plugin's config and the ports — so a run that cannot //! finish ends before anything is written. //! 2. **The sidecar before the plugin.** Both frameworks load a plugin file the moment it lands, //! even on a running server (§34.2.3), so the sidecar it dials is registered first. //! 3. **Provision the config before registering the service**, record last, handoff after that. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use super::plugin::{self, ConfigView}; use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA}; use super::server::{self, RustServer}; use super::sidecar; use crate::bundle::{self, RustBundle}; use crate::cli::Cli; use crate::install::Mode; use crate::record::{now_rfc3339, BinaryRef, BundleRef, InstallerInfo, ServiceRecord}; use crate::sidecar::BinaryAction; use crate::util::{write_atomic, TempDir}; use crate::{paths, service, ui}; /// The plugin's own default game port, and where the search for a free one starts. const GAME_PORT_START: u16 = 7799; /// Where the search for a free web port starts. Clear of ServUO's uo-link default (8080). const WEB_PORT_START: u16 = 8090; /// One instance this run is about, with every decision made and nothing written. struct Planned { id: String, server: RustServer, running: bool, plugin_config: Option, plugin_action: BinaryAction, game_port: u16, web_port: u16, config_path: PathBuf, db_path: PathBuf, config_exists: bool, } pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> { let layout = paths::layout(); let record_path = layout.rust_record(); let prior = RustRecord::load(&record_path)?; // ── Which instances ────────────────────────────────────────────────────── let targets: Vec<(String, PathBuf)> = match mode { Mode::Install => { let id = cli.server_id.clone().ok_or_else(|| { anyhow::anyhow!( "install --game rust needs --server-id : the id the website will know this \ server by, which also names its sidecar. For example:\n \ runicgateway-installer install --game rust --rust /srv/rust --server-id main" ) })?; let recorded = prior .as_ref() .and_then(|p| p.instances.get(&id)) .map(|i| PathBuf::from(&i.server_root)); let root = match (&cli.rust, recorded) { (Some(path), _) => PathBuf::from(path), (None, Some(root)) => root, (None, None) => bail!( "install --game rust needs --rust : the Rust server root, the directory \ holding RustDedicated" ), }; vec![(id, root)] } Mode::Update => { if cli.server_id.is_some() || cli.rust.is_some() { bail!( "update --game rust moves every Rust instance on this host together — they \ share one sidecar binary, and a host whose instances spoke two protocols is one \ no bundle describes. Run it without --server-id or --rust." ); } let Some(prior) = prior.as_ref() else { bail!( "nothing to update — no Rust deployment is recorded on this host (looked for {}). \ Run `install --game rust` first.", record_path.display() ); }; prior .instances .iter() .map(|(id, i)| (id.clone(), PathBuf::from(&i.server_root))) .collect() } }; // ── What to install ────────────────────────────────────────────────────── let (bundle, bundle_url) = bundle::fetch_rust(cli.bundle.as_deref())?; let asset = bundle.sidecar_asset()?.clone(); println!( "\nRunic Gateway installer {} — Rust {} to bundle {} (protocol {}){}", env!("CARGO_PKG_VERSION"), if mode.is_update() { "update" } else { "install" }, bundle.bundle, bundle.protocol, if cli.verify { " [--verify: nothing will be written]" } else { "" } ); println!(); ui::row( "Sidecar", &format!( "{:<24} protocol {}", format!("rust-link {}", bundle.sidecar.tag), bundle.sidecar.protocol ), ); ui::row( "Plugin", &format!( "{:<24} protocol {}", format!("Rust-Plugins {}", bundle.payload.tag), bundle.payload.protocol ), ); // The plugin is fetched before anything is planned: its manifest is the last statement of the // protocol to check, and a pair that disagrees must stop the run before any file moves. let scratch = TempDir::new("runicgateway-rust")?; let tarball = scratch.path().join(&bundle.payload.asset.name); crate::net::download_verified( &bundle.payload.asset.url, &tarball, &bundle.payload.asset.sha256, )?; let released = plugin::read_tarball(&tarball)?; if released.manifest.version != bundle.payload.version { ui::warn(&format!( "the plugin tarball says version {} but bundle {} names {}. The checksum matched, so \ this is a labelling mismatch in the release rather than a wrong download.", released.manifest.version, bundle.bundle, bundle.payload.version )); } if released.manifest.protocol != bundle.protocol { bail!( "the plugin in bundle {} declares protocol {}, and the bundle {}. The Rust game link \ has no version handshake, so a mismatched plugin mis-parses rather than being \ refused — refusing the pair here.", bundle.bundle, released.manifest.protocol, bundle.protocol ); } // ── Plan every instance ────────────────────────────────────────────────── let mut planned = Vec::new(); for (id, root) in &targets { planned.push(plan_instance( cli, &layout, prior.as_ref(), id, root, &released, )?); } let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?; print_plan(&layout, &planned, binary_action, &bundle); if cli.verify { println!( "\nNothing was written. Re-run without --verify to {}.", if mode.is_update() { "update" } else { "deploy" } ); return Ok(()); } preflight_writable(&layout)?; // ── The sidecar binary, shared by every instance ───────────────────────── let prepared = service::prepare(layout.relocated); let binary_sha256 = if binary_action.writes() { // Every instance runs this one file: on Windows it is locked while any of them runs, and on // Linux replacing it leaves each one serving old code until restarted. let mut running: Vec = prior .as_ref() .map(|p| p.instances.keys().cloned().collect()) .unwrap_or_default(); running.extend(planned.iter().map(|p| p.id.clone())); running.sort(); running.dedup(); service::stop_rust_instances(&prepared.manager, &running)?; let sha = crate::sidecar::place(&asset, &layout.rust_sidecar_bin, scratch.path())?; ui::ok(&format!( "rust-link binary verified sha256 {}…", &asset.sha256[..8.min(asset.sha256.len())] )); sha } else { asset.sha256.trim().to_ascii_lowercase() }; // ── Each instance ──────────────────────────────────────────────────────── let mut record = prior .clone() .unwrap_or_else(|| empty_record(&bundle, &bundle_url)); let mut handoffs = Vec::new(); for plan in &planned { let (instance, doc, registered) = deploy_instance( &layout, &prepared, &bundle, &released, plan, binary_action.writes(), prior .as_ref() .and_then(|p| p.instances.get(&plan.id)) .map(|i| i.plugin_config_written) .unwrap_or(false), )?; record.instances.insert(plan.id.clone(), instance); handoffs.push((plan.id.clone(), doc, registered, plan.running)); } // Instances this run did not touch still run the binary it just replaced. if binary_action.writes() { if let Some(prior) = &prior { for (id, instance) in &prior.instances { if planned.iter().any(|p| &p.id == id) || instance.service.is_none() { continue; } match service::restart_rust_instance(id) { Ok(()) => ui::ok(&format!( "restarted {} on the new binary", service::rust_service_name(id) )), Err(e) => ui::warn(&format!("could not restart {id} on the new binary: {e}")), } } } } // ── Record ─────────────────────────────────────────────────────────────── record.schema = SCHEMA; record.installer = InstallerInfo { version: env!("CARGO_PKG_VERSION").to_string(), }; record.updated = now_rfc3339(); record.bundle = BundleRef { tag: bundle.bundle.clone(), protocol: bundle.protocol, url: bundle_url.clone(), }; record.sidecar = component( &bundle.sidecar.repo, &bundle.sidecar.tag, &bundle.sidecar.version, "", bundle.sidecar.protocol, ); record.binary = BinaryRef { path: layout.rust_sidecar_bin.display().to_string(), sha256: binary_sha256, }; record.plugin = component( &bundle.payload.repo, &bundle.payload.tag, &bundle.payload.version, &bundle.payload.commit, bundle.payload.protocol, ); record.service_user_created |= prepared.user_created; match prior.as_ref() { Some(previous) if previous.same_deployment_as(&record) => { println!("\n {} unchanged", record_path.display()); } _ => { record.save(&record_path).with_context(|| { format!( "cannot write {} — run as root/Administrator, or set {} for a test run", record_path.display(), paths::STATE_DIR_ENV ) })?; println!("\n Recorded {}", record_path.display()); } } // ── What is left for the operator ──────────────────────────────────────── match mode { Mode::Install => { let host = crate::install::resolve_host(cli); for (id, doc, registered, running) in &handoffs { println!( "{}", sidecar::handoff( id, doc, &host, cli.site_url.as_deref(), *registered, *running ) ); } } Mode::Update => { let before = prior.as_ref().map(|p| p.bundle.clone()); match before { Some(b) if b.tag == bundle.bundle => { // The same bundle can still have written something: a plugin edited by hand is // put back, which is what `doctor` tells an operator to run `update` for. let restored = planned.iter().filter(|p| p.plugin_action.writes()).count(); if restored == 0 && !binary_action.writes() { println!("\nAlready on bundle {} — nothing moved.", bundle.bundle) } else { println!( "\nAlready on bundle {} — put back what no longer matched it (see above).", bundle.bundle ) } } Some(b) => { println!( "\nMoved {} instance(s) from bundle {} to {}.", planned.len(), b.tag, bundle.bundle ); if b.protocol != bundle.protocol { ui::warn(&format!( "The protocol moved from {} to {}. The website's Rust module must speak \ {} too, or it will be refused with 409 — update the site first.", b.protocol, bundle.protocol, bundle.protocol )); } } None => {} } } } Ok(()) } /// Every decision for one instance, made without writing anything. fn plan_instance( cli: &Cli, layout: &paths::Layout, prior: Option<&RustRecord>, id: &str, root: &Path, released: &plugin::Released, ) -> Result { let server = server::open(root) .with_context(|| format!("cannot use {} as a Rust server root", root.display()))?; let recorded = prior.and_then(|p| p.instances.get(id)); if let Some(instance) = recorded { if Path::new(&instance.server_root) != server.path { bail!( "instance {id:?} is recorded for {}, not {}. An id names one server; uninstall it \ first (`uninstall --game rust --server-id {id}`) to move it.", instance.server_root, server.path.display() ); } } if let Some((other, _)) = prior.and_then(|p| { p.instances .iter() .find(|(other, i)| other.as_str() != id && Path::new(&i.server_root) == server.path) }) { bail!( "{} is already installed as instance {other:?}. One server root has one plugin and one \ ServerId; run with --server-id {other}.", server.path.display() ); } let plugin_config = plugin::read_config(&server.plugin_config_path())?; let held_game = prior.map(|p| p.game_ports_except(id)).unwrap_or_default(); let game_port = match &plugin_config { Some(view) => { plugin::check_server_id(view, id, &server.plugin_config_path())?; // A config without a Port gets the plugin's default. let port = view.port.unwrap_or(GAME_PORT_START); if held_game.contains(&port) { bail!( "{} says the plugin dials port {port}, which another Rust instance on this host \ already listens on. Give it a free Port and run again.", server.plugin_config_path().display() ); } port } None => sidecar::choose_port(GAME_PORT_START, &held_game, recorded.map(|i| i.game_port))?, }; let held_web = prior.map(|p| p.web_ports_except(id)).unwrap_or_default(); if let Some(port) = cli.web_port { if held_web.contains(&port) { bail!("--web-port {port} is already another Rust instance's web port"); } } let web_port = match (cli.web_port, recorded) { (Some(port), _) => port, (None, Some(instance)) => instance.web_port, (None, None) => sidecar::choose_port(WEB_PORT_START, &held_web, None)?, }; let plugin_action = match std::fs::read(server.plugin_path()) { Err(_) => BinaryAction::Install, Ok(bytes) if crate::util::sha256_bytes(&bytes) == released.sha256 => { BinaryAction::Unchanged } Ok(_) => BinaryAction::Replace, }; let config_path = layout.rust_config(id); Ok(Planned { id: id.to_string(), running: server::is_running(&server.path), config_exists: config_path.exists(), config_path, db_path: layout.rust_db(id), server, plugin_config, plugin_action, game_port, web_port, }) } fn print_plan( layout: &paths::Layout, planned: &[Planned], binary: BinaryAction, bundle: &RustBundle, ) { ui::row( "binary", &format!("{} {}", layout.rust_sidecar_bin.display(), binary.label()), ); for p in planned { ui::heading(&format!("Instance {}", p.id)); ui::row( "server", &format!( "{} ({}, {})", p.server.path.display(), p.server.framework.as_str(), if p.running { "running — the plugin loads at once" } else { "not running — the plugin loads at next boot" } ), ); ui::row( "plugin", &format!( "{} {}", p.server.plugin_path().display(), p.plugin_action.label() ), ); ui::row( "plugin config", &match &p.plugin_config { Some(_) => format!( "{} kept (the website's)", p.server.plugin_config_path().display() ), None => format!( "{} written: ServerId {}, Port {}", p.server.plugin_config_path().display(), p.id, p.game_port ), }, ); ui::row( "sidecar config", &format!( "{} {}", p.config_path.display(), if p.config_exists { "kept" } else { "written" } ), ); ui::row( "ports", &format!( "game 127.0.0.1:{} web 127.0.0.1:{}", p.game_port, p.web_port ), ); let missing = plugin::missing_plugins( &p.server.plugins_dir(), &bundle.payload.compat.requires_plugins, ); if !missing.is_empty() { ui::warn(&format!( "{} not in {} — the features that use {} stay off until you install {} from uMod. \ The installer does not fetch third-party plugins (D153).", missing.join(", "), p.server.plugins_dir().display(), if missing.len() == 1 { "it" } else { "them" }, if missing.len() == 1 { "it" } else { "them" }, )); } } } /// Writes one instance: plugin config, sidecar config, service, plugin — in that order. fn deploy_instance( layout: &paths::Layout, prepared: &service::Prepared, bundle: &RustBundle, released: &plugin::Released, plan: &Planned, binary_changed: bool, plugin_config_written_before: bool, ) -> Result<(Instance, sidecar::ConfigDoc, bool)> { ui::heading(&format!("Instance {}", plan.id)); let data_dir = layout.rust_data_dir(&plan.id); std::fs::create_dir_all(&data_dir) .with_context(|| format!("cannot create {}", data_dir.display()))?; // The plugin's config, once, and only if it does not exist (§34.2.3). let plugin_config_path = plan.server.plugin_config_path(); let wrote_plugin_config = plan.plugin_config.is_none(); if wrote_plugin_config { if let Some(parent) = plugin_config_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; } write_atomic( &plugin_config_path, plugin::initial_config(&plan.id, plan.game_port).as_bytes(), )?; ui::ok(&format!("wrote {}", plugin_config_path.display())); } // The sidecar's config, once; the token is the sidecar's to generate. if !plan.config_exists { if let Some(parent) = plan.config_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; } write_atomic( &plan.config_path, sidecar::instance_config(&plan.id, plan.game_port, plan.web_port, &plan.db_path) .as_bytes(), )?; } let doc = sidecar::print_config(&layout.rust_sidecar_bin, &plan.config_path)?; if doc.protocol != bundle.protocol { bail!( "the installed rust-link sidecar reports protocol {} but bundle {} was composed at {}. \ Refusing to register a service for a pair that was never checked together.", doc.protocol, bundle.bundle, bundle.protocol ); } if doc.version != bundle.sidecar.version { ui::warn(&format!( "the installed binary reports version {} but bundle {} names {}. The checksum matched, \ so this is a labelling mismatch in the release rather than a wrong download.", doc.version, bundle.bundle, bundle.sidecar.version )); } // The sidecar's server_id is a cross-check against the plugin's hello. An edited config that // names another server makes every hello log a disagreement, so it is said here, once. if !doc.game.server_id.is_empty() && doc.game.server_id != plan.id { ui::warn(&format!( "{} cross-checks server id {:?}, but this instance is {:?}; the sidecar will log a \ disagreement on every connect.", doc.config_path, doc.game.server_id, plan.id )); } // An existing sidecar config is the operator's; what it says is what runs. Say so when it // disagrees with the plugin, because that pair never meets. if let Some(port) = sidecar::port_of(&doc.game.bind) { if port != plan.game_port { ui::warn(&format!( "{} listens for the plugin on {port}, but the plugin dials {}. Make them agree, or \ the plugin never connects.", doc.config_path, plan.game_port )); } } ui::row( "sidecar config", &format!( "{} {}", doc.config_path, if doc.token_generated { "token generated" } else { "token kept" } ), ); service::protect_config( &plan.config_path, &data_dir, prepared.user.as_deref(), layout.relocated, )?; let mut outcome = service::register_rust( prepared, layout, &plan.id, &plan.config_path, binary_changed, )?; service::grant_service_access(&plan.config_path, &data_dir, &outcome)?; service::start_registered(&mut outcome, &layout.rust_sidecar_bin, &plan.config_path)?; let service_record = match &outcome { service::Outcome::Registered { kind, name, user, state, .. } => { ui::row("service", &format!("{name} {state}")); Some(ServiceRecord { kind: (*kind).to_string(), name: name.clone(), unit_path: None, user: user.clone(), // The account is shared with every instance and possibly with ServUO's sidecar, so // no single instance's removal may delete it (see `RustRecord`). user_created: false, }) } service::Outcome::Skipped { reason, manual } => { ui::warn(&format!( "service NOT REGISTERED — {reason}.\n The binary and config are in place; nothing is \ running them. Do this by hand:" )); print!("{manual}"); None } }; // The plugin last: it loads the moment it lands, and its sidecar is now there to dial. let plugin_path = plan.server.plugin_path(); if plan.plugin_action.writes() { std::fs::create_dir_all(plan.server.plugins_dir()) .with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?; // Overwritten in place, NOT `write_atomic`. Oxide and Carbon watch the plugins directory // and reload on a CHANGE; `write_atomic` removes the old file and renames a `.tmp` over it, // which both frameworks see as a delete — they unload the bridge — and a rename they // ignore, so the new file is never loaded. On a running server that was an `update` that // silently took the bridge down until the next boot (the phase 18 walk, on all three // instances and both frameworks). A write in place is what an operator's `cp` does, and // it reloads. A crash mid-write leaves a file that fails to compile; `doctor` reports it // as not the deployed file and `update` writes it again. std::fs::write(&plugin_path, &released.source) .with_context(|| format!("cannot write {}", plugin_path.display()))?; ui::ok(&format!( "plugin {} {}", if plan.plugin_action == BinaryAction::Replace { "replaced" } else { "installed" }, plugin_path.display() )); } Ok(( Instance { server_root: plan.server.path.display().to_string(), framework: plan.server.framework.as_str().to_string(), plugin_path: plugin_path.display().to_string(), plugin_sha256: released.sha256.clone(), plugin_config: plugin_config_path.display().to_string(), plugin_config_written: wrote_plugin_config || plugin_config_written_before, game_port: plan.game_port, web_port: sidecar::port_of(&doc.web.bind).unwrap_or(plan.web_port), config_path: doc.config_path.clone(), db_path: doc.store.path.clone(), service: service_record, }, doc, outcome.registered(), )) } fn component(repo: &str, tag: &str, version: &str, commit: &str, protocol: u32) -> ComponentRecord { ComponentRecord { repo: repo.to_string(), tag: tag.to_string(), version: version.to_string(), commit: commit.to_string(), protocol, } } fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord { RustRecord { schema: SCHEMA, installer: InstallerInfo { version: env!("CARGO_PKG_VERSION").to_string(), }, updated: String::new(), bundle: BundleRef { tag: bundle.bundle.clone(), protocol: bundle.protocol, url: url.to_string(), }, sidecar: component("", "", "", "", 0), binary: BinaryRef { path: String::new(), sha256: String::new(), }, plugin: component("", "", "", "", 0), service_user_created: false, instances: Default::default(), extra: Default::default(), } } /// Fails before anything is written if this process cannot write where it must. fn preflight_writable(layout: &paths::Layout) -> Result<()> { let bin_dir = layout .rust_sidecar_bin .parent() .unwrap_or(&layout.rust_sidecar_bin) .to_path_buf(); for dir in [ layout.rust_config_dir(), layout.data_dir.join("rust"), bin_dir, ] { std::fs::create_dir_all(&dir) .and_then(|_| { let probe = dir.join(".runicgateway-write-test"); std::fs::write(&probe, b"")?; std::fs::remove_file(&probe) }) .with_context(|| { format!( "cannot write to {}. Run as {}, or set {} for a test run (no service is \ registered then).", dir.display(), if cfg!(windows) { "Administrator" } else { "root (sudo)" }, paths::STATE_DIR_ENV ) })?; } Ok(()) }