feat(rust): install the helpers a plugin release ships beside the bridge (D182)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m37s

Rust-Plugins now releases a ZoneManager helper, RunicGatewayZones.cs, beside
RunicGateway.cs (docs/modules/rust/PLAN_FIXES.md D181, D182), and the org lead
decided it is installed by default. The tarball reader took only the bridge,
so the helper would have been downloaded and dropped.

- plugin::read_tarball reads every other .cs the release's manifest lists in
  `files`, and refuses the release when one is missing, does not match its own
  sha256, or is not a plain `<Name>.cs` (the name is written into a plugins
  directory, so nothing in a tarball may reach outside it). A .cs the manifest
  does not list is ignored, so a release older than helpers has none.
- install/update place each helper before the bridge, in place like the
  bridge (Oxide and Carbon reload on a change, not on a rename), put back one
  that was deleted or edited, and remove one a later release stops shipping.
- The record gains per-instance `helpers` (path + sha256). It is absent from
  every record written before this and reads back as none.
- doctor warns — never fails — on a missing or edited helper and says what
  the bridge loses without it; uninstall removes helpers before the bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:24:33 -05:00
parent 3a3676a22d
commit 9d9ee3344b
5 changed files with 384 additions and 14 deletions

View File

@@ -19,7 +19,7 @@ use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA};
use super::record::{ComponentRecord, DeployedFile, Instance, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
@@ -42,6 +42,10 @@ struct Planned {
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>,
game_port: u16,
web_port: u16,
config_path: PathBuf,
@@ -179,7 +183,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle);
print_plan(&layout, &planned, binary_action, &bundle, &released);
if cli.verify {
println!(
@@ -326,7 +330,14 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
Some(b) if b.tag == bundle.bundle => {
// The same bundle can still have written something: a plugin edited by hand is
// put back, which is what `doctor` tells an operator to run `update` for.
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count();
let restored = planned
.iter()
.filter(|p| {
p.plugin_action.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 {
@@ -431,6 +442,30 @@ fn plan_instance(
}
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();
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
@@ -441,6 +476,8 @@ fn plan_instance(
server,
plugin_config,
plugin_action,
helper_actions,
retired_helpers,
game_port,
web_port,
})
@@ -451,6 +488,7 @@ fn print_plan(
planned: &[Planned],
binary: BinaryAction,
bundle: &RustBundle,
released: &plugin::Released,
) {
ui::row(
"binary",
@@ -479,6 +517,25 @@ fn print_plan(
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()
),
);
}
ui::row(
"plugin config",
&match &p.plugin_config {
@@ -662,6 +719,45 @@ fn deploy_instance(
}
};
// 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())),
}
}
// 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() {
@@ -694,6 +790,7 @@ fn deploy_instance(
framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
helpers,
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,