All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m13s
- 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
996 lines
38 KiB
Rust
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(())
|
|
}
|