feat(rust): RunicNPC as the Rust bundle's third artefact (runicnpc stage 4, D224)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m27s
The compose job carries the latest RunicNPC release that answers the API the bridge's manifest declares (runicnpc_api), verified against its SHA256SUMS like the other two; none when the bridge needs none, RunicNPC has not released, or its API is too old, each said in the summary. Walked against a mock Gitea in all three cases. The installer reads the optional npc component (older bundles still parse), fetches and checks RunicNPC's tarball against its manifest, places RunicNPC.cs before the bridge in each instance's plugins directory, records it, puts it back on update when edited or deleted, reports it in doctor, and removes it on uninstall. Its data directory is never touched. Kits joins the required plugins it reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
@@ -18,8 +18,9 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
use super::npc;
|
||||
use super::plugin::{self, ConfigView};
|
||||
use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
|
||||
use super::record::{ComponentRecord, DeployedFile, Instance, NpcRecord, RustRecord, SCHEMA};
|
||||
use super::server::{self, RustServer};
|
||||
use super::sidecar;
|
||||
use crate::bundle::{self, RustBundle};
|
||||
@@ -46,6 +47,8 @@ struct Planned {
|
||||
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,
|
||||
@@ -141,6 +144,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
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.
|
||||
@@ -170,6 +183,31 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
// RunicNPC (D224), fetched and checked beside the plugin, before anything is planned.
|
||||
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 => None,
|
||||
};
|
||||
|
||||
// ── Plan every instance ──────────────────────────────────────────────────
|
||||
let mut planned = Vec::new();
|
||||
for (id, root) in &targets {
|
||||
@@ -180,6 +218,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
id,
|
||||
root,
|
||||
&released,
|
||||
npc_released.as_ref(),
|
||||
)?);
|
||||
}
|
||||
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
|
||||
@@ -229,6 +268,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
&prepared,
|
||||
&bundle,
|
||||
&released,
|
||||
npc_released.as_ref(),
|
||||
plan,
|
||||
binary_action.writes(),
|
||||
prior
|
||||
@@ -288,6 +328,16 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
&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() {
|
||||
@@ -334,6 +384,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
.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()
|
||||
})
|
||||
@@ -377,6 +428,7 @@ fn plan_instance(
|
||||
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()))?;
|
||||
@@ -466,6 +518,17 @@ fn plan_instance(
|
||||
.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(),
|
||||
@@ -478,6 +541,7 @@ fn plan_instance(
|
||||
plugin_action,
|
||||
helper_actions,
|
||||
retired_helpers,
|
||||
npc_action,
|
||||
game_port,
|
||||
web_port,
|
||||
})
|
||||
@@ -536,6 +600,16 @@ fn print_plan(
|
||||
),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
@@ -566,10 +640,16 @@ fn print_plan(
|
||||
p.game_port, p.web_port
|
||||
),
|
||||
);
|
||||
let missing = plugin::missing_plugins(
|
||||
&p.server.plugins_dir(),
|
||||
&bundle.payload.compat.requires_plugins,
|
||||
);
|
||||
// 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. \
|
||||
@@ -584,11 +664,13 @@ fn print_plan(
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -758,6 +840,35 @@ fn deploy_instance(
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -791,6 +902,7 @@ fn deploy_instance(
|
||||
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,
|
||||
@@ -832,6 +944,7 @@ fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
|
||||
sha256: String::new(),
|
||||
},
|
||||
plugin: component("", "", "", "", 0),
|
||||
npc: None,
|
||||
service_user_created: false,
|
||||
instances: Default::default(),
|
||||
extra: Default::default(),
|
||||
|
||||
Reference in New Issue
Block a user