Files
installer/src/rustgame/install.rs
wtclaude 04bab67778
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
feat(rust): RunicNPC is required in the Rust bundle (runicnpc stage 9, D310)
- install/update refuse a Rust bundle that carries no RunicNPC, before anything
  is written, and say how to get one that does.
- doctor fails when RunicNPC is missing or was never installed; a file edited
  by hand stays a warning, since it still loads.
- compose-bundles.sh composes no Rust bundle without a RunicNPC release that
  answers the bridge's runicnpc_api (the published one stays), and fails a
  bridge that declares none; every Rust bundle now has `npc`.
- A RunicNPC API-mismatch message lost its line continuation; restored.

The bundle type still parses a bundle without `npc`, so doctor and an old pin
can name it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-06 16:58:31 -05:00

996 lines
38 KiB
Rust

//! `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<ConfigView>,
plugin_action: BinaryAction,
/// Each helper in the release and what happens to it, in the release's (name) order.
helper_actions: Vec<BinaryAction>,
/// Helpers this instance's record holds that the release no longer ships: removed.
retired_helpers: Vec<String>,
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
npc_action: Option<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 <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 <path>: 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<String> = 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<Planned> {
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(())
}