//! `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::npc; use super::plugin::{self, ConfigView}; use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, 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, /// Each helper in the release and what happens to it, in the release's (name) order. helper_actions: Vec, /// Helpers this instance's record holds that the release no longer ships: removed. retired_helpers: Vec, /// RunicNPC, when the bundle carries it (D224): absent, identical or replaced. npc_action: Option, 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 ), ); if let Some(npc) = &bundle.npc { ui::row( "RunicNPC", &format!( "{:<24} API {}", format!("runicnpc-rust {}", npc.tag), npc.api ), ); } // 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 ); } // RunicNPC (D224), fetched and checked beside the plugin, before anything is planned. It is // required since RunicNPC's stage 9 (D310): the bridge refuses every NPC an event places // without it, so a bundle that cannot place it is refused before anything is written. let npc_released = match &bundle.npc { Some(component) => { let path = scratch.path().join(&component.asset.name); crate::net::download_verified(&component.asset.url, &path, &component.asset.sha256)?; let released = npc::read_tarball(&path)?; if released.manifest.api != component.api { bail!( "RunicNPC in bundle {} answers API {}, but the bundle says {} — refusing a \ component the bundle does not describe.", bundle.bundle, released.manifest.api, component.api ); } if released.manifest.version != component.version { ui::warn(&format!( "the RunicNPC tarball says version {} but bundle {} names {}. The checksum matched, \n so this is a labelling mismatch in the release rather than a wrong download.", released.manifest.version, bundle.bundle, component.version )); } Some(released) } None => bail!( "bundle {} carries no RunicNPC, and Runic Gateway's Rust events need it: without it \ the bridge refuses every NPC an event places. Install the current bundle (leave \ --bundle off), or pin one published with RunicNPC v1.0.0 or later.", bundle.bundle ), }; // ── 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, npc_released.as_ref(), )?); } let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?; print_plan(&layout, &planned, binary_action, &bundle, &released); 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, npc_released.as_ref(), 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, ); // A bundle without RunicNPC leaves a recorded one where it is: it may be an operator's. if let Some(component) = &bundle.npc { record.npc = Some(NpcRecord { repo: component.repo.clone(), tag: component.tag.clone(), version: component.version.clone(), commit: component.commit.clone(), api: component.api, }); } 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() || p.npc_action.is_some_and(|a| a.writes()) || p.helper_actions.iter().any(|a| a.writes()) || !p.retired_helpers.is_empty() }) .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, npc_released: Option<&npc::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, }; // Helpers are decided the same way as the plugin: absent, identical or replaced. One the // operator deleted is put back — the installer owns it, and `doctor` says what its absence costs. let helper_actions = released .helpers .iter() .map( |helper| match std::fs::read(server.plugins_dir().join(&helper.name)) { Err(_) => BinaryAction::Install, Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => { BinaryAction::Unchanged } Ok(_) => BinaryAction::Replace, }, ) .collect(); let retired_helpers = recorded .map(|i| { i.helpers .keys() .filter(|name| !released.helpers.iter().any(|h| &h.name == *name)) .cloned() .collect() }) .unwrap_or_default(); // RunicNPC as the plugin is: put back when it was deleted or edited (D224). let npc_action = npc_released.map( |r| match std::fs::read(server.plugins_dir().join(npc::FILE)) { Err(_) => BinaryAction::Install, Ok(bytes) if crate::util::sha256_bytes(&bytes) == r.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, helper_actions, retired_helpers, npc_action, game_port, web_port, }) } fn print_plan( layout: &paths::Layout, planned: &[Planned], binary: BinaryAction, bundle: &RustBundle, released: &plugin::Released, ) { 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() ), ); for (helper, action) in released.helpers.iter().zip(&p.helper_actions) { ui::row( "helper", &format!( "{} {}", p.server.plugins_dir().join(&helper.name).display(), action.label() ), ); } for name in &p.retired_helpers { ui::row( "helper", &format!( "{} removed (no longer released)", p.server.plugins_dir().join(name).display() ), ); } if let Some(action) = p.npc_action { ui::row( "RunicNPC", &format!( "{} {}", p.server.plugins_dir().join(npc::FILE).display(), 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 ), ); // RunicNPC's own requirements join the bridge's: it will not load without Kits (D217). let mut required = bundle.payload.compat.requires_plugins.clone(); if let Some(component) = &bundle.npc { for name in &component.compat.requires_plugins { if !required.contains(name) { required.push(name.clone()); } } } let missing = plugin::missing_plugins(&p.server.plugins_dir(), &required); 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. #[allow(clippy::too_many_arguments)] fn deploy_instance( layout: &paths::Layout, prepared: &service::Prepared, bundle: &RustBundle, released: &plugin::Released, npc_released: Option<&npc::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 helpers before the plugin (D182): each loads the moment it lands, and the bridge reads a // helper's state at hello — a helper already there is one the bridge's first hello reports. // Written in place for the plugin's reason below. let mut helpers = std::collections::BTreeMap::new(); for (helper, action) in released.helpers.iter().zip(&plan.helper_actions) { let path = plan.server.plugins_dir().join(&helper.name); if action.writes() { std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| { format!("cannot create {}", plan.server.plugins_dir().display()) })?; std::fs::write(&path, &helper.source) .with_context(|| format!("cannot write {}", path.display()))?; ui::ok(&format!( "helper {} {}", if *action == BinaryAction::Replace { "replaced" } else { "installed" }, path.display() )); } helpers.insert( helper.name.clone(), DeployedFile { path: path.display().to_string(), sha256: helper.sha256.clone(), }, ); } for name in &plan.retired_helpers { let path = plan.server.plugins_dir().join(name); match std::fs::remove_file(&path) { Ok(()) => ui::ok(&format!("helper removed {}", path.display())), Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => ui::warn(&format!("could not remove {}: {e}", path.display())), } } // RunicNPC before the bridge (D224): the bridge reports it at hello, and one already loaded is // one its first hello names. Written in place, for the plugin's reason below. let npc_file = match (npc_released, plan.npc_action) { (Some(r), Some(action)) => { let path = plan.server.plugins_dir().join(npc::FILE); if action.writes() { std::fs::create_dir_all(plan.server.plugins_dir()).with_context(|| { format!("cannot create {}", plan.server.plugins_dir().display()) })?; std::fs::write(&path, &r.source) .with_context(|| format!("cannot write {}", path.display()))?; ui::ok(&format!( "RunicNPC {} {}", if action == BinaryAction::Replace { "replaced" } else { "installed" }, path.display() )); } Some(DeployedFile { path: path.display().to_string(), sha256: r.sha256.clone(), }) } _ => 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(), helpers, npc: npc_file, 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), npc: None, 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(()) }