Compare commits
10 Commits
v0.3.0
...
feat/runic
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2efb62e4 | |||
| c68a7d9d32 | |||
| 939dd0465d | |||
| 331e30710e | |||
| 9d9ee3344b | |||
| 3a3676a22d | |||
| 9d73367e63 | |||
| d61f46ffb6 | |||
| dc3d360cf2 | |||
| 9f43260ee7 |
@@ -64,12 +64,14 @@ SERVUO_LINK_REPO="RunicGateway/link"
|
||||
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
|
||||
RUST_LINK_REPO="RunicGateway/Rust-Link"
|
||||
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
|
||||
RUNICNPC_REPO="RunicGateway/runicnpc-rust"
|
||||
|
||||
# Fixed top-level directories inside each payload tarball. Deliberately NOT
|
||||
# versioned — a versioned prefix would mean parsing the version out of a path in
|
||||
# order to read the manifest that declares the version.
|
||||
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
|
||||
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
|
||||
RUNICNPC_PREFIX="runicnpc"
|
||||
|
||||
mkdir -p "$WORK" "$PUBLISHED"
|
||||
: > "$WORK/summary.md"
|
||||
@@ -375,6 +377,45 @@ compose_rust() {
|
||||
[ "$link_protocol" = "$plugin_protocol" ] || fail \
|
||||
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
|
||||
|
||||
# RunicNPC (docs/runicnpc/PLAN.md D224): a third file, optional until its stage 9.
|
||||
# The bridge's manifest says which RunicNPC API its npc.* commands need
|
||||
# (`runicnpc_api`, from overlay.toml); the latest RunicNPC release is carried
|
||||
# only when it answers at least that. A bridge that needs none carries none: a
|
||||
# RunicNPC it cannot talk to would be an NPC plugin nobody manages.
|
||||
local npc='null' need npc_tag npc_tarball npc_manifest npc_api npc_carried=''
|
||||
need="$(jq -r '.runicnpc_api // 0' "$manifest")"
|
||||
if [ "$need" -gt 0 ]; then
|
||||
rc=0
|
||||
resolve_latest "$RUNICNPC_REPO" runicnpc || rc=$?
|
||||
if [ "$rc" -eq 2 ]; then
|
||||
note "- **Rust**: the bridge needs RunicNPC API ${need}, and RunicNPC has not released yet — the bundle carries none."
|
||||
else
|
||||
npc_tag="$(release_tag runicnpc)"
|
||||
verify_assets runicnpc
|
||||
npc_tarball="$(single_tarball runicnpc)"
|
||||
npc_manifest="$(payload_manifest runicnpc "$npc_tarball" "$RUNICNPC_PREFIX" "$npc_tag")"
|
||||
[ -f "$(dirname "$npc_manifest")/RunicNPC.cs" ] \
|
||||
|| fail "the RunicNPC tarball has no ${RUNICNPC_PREFIX}/RunicNPC.cs"
|
||||
npc_api="$(jq -r '.api' "$npc_manifest")"
|
||||
echo "==> rust: RunicNPC ${npc_tag} api=${npc_api} | the bridge needs ${need}"
|
||||
if [ "$npc_api" -lt "$need" ]; then
|
||||
note "- **Rust**: RunicNPC \`${npc_tag}\` answers API ${npc_api} and the bridge needs ${need} — the bundle carries none until RunicNPC releases one that does."
|
||||
else
|
||||
npc="$(jq -n --arg repo "$RUNICNPC_REPO" --arg tag "$npc_tag" --slurpfile m "$npc_manifest" \
|
||||
--argjson asset "$(asset_json runicnpc "$npc_tarball")" \
|
||||
'{ repo: $repo, tag: $tag, version: ($tag | ltrimstr("v")), commit: $m[0].commit, api: $m[0].api,
|
||||
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
|
||||
carbon: { min_version: $m[0].min_carbon_version } },
|
||||
requires_plugins: $m[0].requires_plugins },
|
||||
asset: $asset }')"
|
||||
jq -e '([.commit, .compat.frameworks[].min_version] | all(type == "string"))
|
||||
and (.api | type == "number") and (.compat.requires_plugins | type == "array")' <<<"$npc" >/dev/null \
|
||||
|| fail "the RunicNPC manifest is missing commit, api, a framework floor, or requires_plugins"
|
||||
npc_carried="$npc_tag"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
|
||||
--argjson assets "$assets" --argjson launcher "$launcher" \
|
||||
@@ -382,6 +423,7 @@ compose_rust() {
|
||||
--slurpfile m "$manifest" \
|
||||
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
|
||||
--argjson protocol "$link_protocol" \
|
||||
--argjson npc "$npc" \
|
||||
'{ schema: 2, game: "rust", protocol: $protocol,
|
||||
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
|
||||
protocol: $protocol, assets: $assets, launcher: $launcher },
|
||||
@@ -390,7 +432,8 @@ compose_rust() {
|
||||
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
|
||||
carbon: { min_version: $m[0].min_carbon_version } },
|
||||
requires_plugins: $m[0].requires_plugins },
|
||||
asset: $p_asset } }' > "$WORK/rust-s2.json"
|
||||
asset: $p_asset } }
|
||||
+ (if $npc == null then {} else { npc: $npc } end)' > "$WORK/rust-s2.json"
|
||||
# A manifest missing a key would compose a `null` the installer then trusts.
|
||||
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
|
||||
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
|
||||
@@ -403,7 +446,7 @@ compose_rust() {
|
||||
local tag
|
||||
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
|
||||
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
|
||||
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
|
||||
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}${npc_carried:+, RunicNPC \`${npc_tag}\`}."
|
||||
echo "rust ${tag}" >> "$WORK/published-games.txt"
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
#
|
||||
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
|
||||
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
|
||||
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
|
||||
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins,
|
||||
# and RunicNPC when the bridge needs it (D224)
|
||||
#
|
||||
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
|
||||
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
|
||||
@@ -56,8 +57,8 @@
|
||||
# the push.
|
||||
#
|
||||
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
||||
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
|
||||
# Rust-Plugins' release workflows as their final step,
|
||||
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's,
|
||||
# Rust-Plugins' and runicnpc-rust's release workflows as their final step,
|
||||
# so a new release recomposes the bundle immediately.
|
||||
# schedule (nightly) — recomputes from whatever the latest releases actually
|
||||
# are, so a missed or failed dispatch self-heals instead
|
||||
|
||||
@@ -64,11 +64,15 @@ env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/installer
|
||||
BIN: runicgateway-installer
|
||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||
# Both Linux binaries are STATIC (musl), D158. Linked against the runner's glibc
|
||||
# they needed glibc 2.39 and would not start on Debian 12 or Ubuntu 22.04 — the
|
||||
# hosts an operator is most likely to have (Rust phase 18 walk, step 7). The
|
||||
# sidecar the installer deploys is already static for the same reason (D149).
|
||||
LINUX_TARGET: x86_64-unknown-linux-musl
|
||||
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
||||
# The installer has to run wherever the sidecar it installs can run, and link
|
||||
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
|
||||
ARM64_TARGET: aarch64-unknown-linux-gnu
|
||||
ARM64_TARGET: aarch64-unknown-linux-musl
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -280,13 +284,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only
|
||||
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust
|
||||
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling
|
||||
# C, on a missing bits/libc-header-start.h.
|
||||
# `ring` (under ureq's rustls) compiles C, so each Linux target needs a C compiler that
|
||||
# targets musl. Ubuntu packages one for x86_64 only; zig (via cargo-zigbuild) is one
|
||||
# compiler for both, so both Linux builds go through it. python3-pip installs it;
|
||||
# `file` is for the static-link check after the build.
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
build-essential gcc-mingw-w64-x86-64 gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
|
||||
build-essential gcc-mingw-w64-x86-64 python3-pip file \
|
||||
curl ca-certificates git jq
|
||||
pip3 install --quiet --break-system-packages ziglang cargo-zigbuild
|
||||
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
@@ -295,6 +300,7 @@ jobs:
|
||||
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
rustup component add rustfmt
|
||||
rustup target add "${LINUX_TARGET}"
|
||||
rustup target add "${WINDOWS_TARGET}"
|
||||
rustup target add "${ARM64_TARGET}"
|
||||
|
||||
@@ -322,9 +328,9 @@ jobs:
|
||||
run: cargo test --locked
|
||||
|
||||
# ── RUST ADAPTER: build both targets ─────────────────────────────────
|
||||
- name: cargo build --release (Linux)
|
||||
- name: cargo build --release (Linux, static musl)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: cargo build --release --locked --target "${LINUX_TARGET}"
|
||||
run: cargo zigbuild --release --locked --target "${LINUX_TARGET}"
|
||||
|
||||
- name: cargo build --release (Windows, cross via MinGW)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
@@ -338,13 +344,9 @@ jobs:
|
||||
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
|
||||
# artifact is packaged, but nothing ever builds it — which is exactly how the first release
|
||||
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
|
||||
- name: cargo build --release (Linux arm64, cross)
|
||||
- name: cargo build --release (Linux arm64, static musl)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
env:
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||
run: cargo build --release --locked --target "${ARM64_TARGET}"
|
||||
run: cargo zigbuild --release --locked --target "${ARM64_TARGET}"
|
||||
|
||||
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
||||
# SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3),
|
||||
@@ -356,6 +358,13 @@ jobs:
|
||||
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
||||
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
|
||||
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
||||
# A "static" binary that is quietly dynamic fails only on the operator's host, on the
|
||||
# first line, with a glibc version error — so it is refused here instead (D158).
|
||||
for f in "dist/${BIN}-linux-x86_64" "dist/${BIN}-linux-aarch64"; do
|
||||
if ! file "$f" | grep -q 'statically linked'; then
|
||||
echo "::error::$f is not statically linked: $(file -b "$f")"; exit 1
|
||||
fi
|
||||
done
|
||||
# Every artifact must be listed: `sha256sum -c` passes silently over a
|
||||
# file the sums do not mention, and an operator verifying a download
|
||||
# would get a pass on a binary nobody vouched for.
|
||||
|
||||
@@ -361,6 +361,26 @@ pub struct RustBundle {
|
||||
pub protocol: u32,
|
||||
pub sidecar: RustSidecar,
|
||||
pub payload: PluginPayload,
|
||||
/// RunicNPC, Runic Gateway's NPC plugin (docs/runicnpc/PLAN.md D224): a third file placed in
|
||||
/// the plugins directory beside the bridge. Optional until RunicNPC's stage 9 makes it
|
||||
/// required, so a bundle without it — every one before stage 4 — still parses and installs.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub npc: Option<NpcComponent>,
|
||||
}
|
||||
|
||||
/// One exact RunicNPC release, chosen by CI to answer at least the API the bridge's
|
||||
/// `runicnpc_api` asks for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NpcComponent {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
pub commit: String,
|
||||
/// The RunicNPC API it answers (`RunicNpc_ApiVersion`).
|
||||
pub api: u32,
|
||||
pub compat: PluginCompat,
|
||||
/// One tarball: `runicnpc/{RunicNPC.cs, manifest.json}`.
|
||||
pub asset: Asset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -485,6 +505,14 @@ pub fn parse_rust(body: &str) -> Result<RustBundle> {
|
||||
bundle.bundle
|
||||
);
|
||||
}
|
||||
if let Some(npc) = &bundle.npc {
|
||||
if npc.asset.url.is_empty() || npc.asset.sha256.is_empty() {
|
||||
bail!(
|
||||
"Rust bundle {} names a RunicNPC asset with no URL or checksum",
|
||||
bundle.bundle
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
@@ -589,6 +617,37 @@ mod tests {
|
||||
assert_eq!(v2, v1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_bundle_without_runicnpc_still_parses() {
|
||||
let bundle = parse_rust(RUST).unwrap();
|
||||
assert!(bundle.npc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_bundle_with_runicnpc_parses_and_is_checked() {
|
||||
let mut value: serde_json::Value = serde_json::from_str(RUST).unwrap();
|
||||
value["npc"] = serde_json::json!({
|
||||
"repo": "RunicGateway/runicnpc-rust",
|
||||
"tag": "v0.2.0",
|
||||
"version": "0.2.0",
|
||||
"commit": "abc",
|
||||
"api": 3,
|
||||
"compat": {
|
||||
"frameworks": { "oxide": { "min_version": "2.0.7726" }, "carbon": { "min_version": "2.0.259" } },
|
||||
"requires_plugins": ["Kits"]
|
||||
},
|
||||
"asset": { "name": "runicnpc-0.2.0.tar.gz", "url": "https://x/runicnpc-0.2.0.tar.gz", "sha256": "ab" }
|
||||
});
|
||||
let bundle = parse_rust(&value.to_string()).unwrap();
|
||||
let npc = bundle.npc.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(npc.api, npc.compat.requires_plugins.clone()),
|
||||
(3, vec!["Kits".to_string()])
|
||||
);
|
||||
value["npc"]["asset"]["sha256"] = serde_json::json!("");
|
||||
assert!(parse_rust(&value.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
|
||||
let err = parse(RUST).unwrap_err().to_string();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
|
||||
//!
|
||||
//! - the framework, and whether the plugin file is still the one deployed;
|
||||
//! - each helper deployed beside it (D182), as a warning when it is gone or edited — the bridge
|
||||
//! runs without one, and the row says what that costs;
|
||||
//! - that the plugin's config names this instance;
|
||||
//! - `requires_plugins` present, as a warning (D153);
|
||||
//! - the service registered and running;
|
||||
@@ -151,6 +153,52 @@ fn instance_rows(
|
||||
)),
|
||||
}
|
||||
|
||||
// ── The helpers: ours too, and optional to the bridge (D182) ─────────────
|
||||
// A warning rather than a failure: the bridge runs without a helper and falls back. What is
|
||||
// lost is said, so an operator who removed one on purpose knows what they chose.
|
||||
for (name, helper) in &instance.helpers {
|
||||
match std::fs::read(&helper.path) {
|
||||
Ok(bytes) if crate::util::sha256_bytes(&bytes) == helper.sha256 => {
|
||||
rows.push(Row::ok(&label("helper"), helper.path.clone()))
|
||||
}
|
||||
Ok(_) => rows.push(
|
||||
Row::warn(
|
||||
&label("helper"),
|
||||
format!("{} is not the file that was deployed", helper.path),
|
||||
)
|
||||
.note(
|
||||
"edited or replaced by hand; `update --game rust` puts the released one back",
|
||||
),
|
||||
),
|
||||
Err(_) => rows.push(
|
||||
Row::warn(&label("helper"), format!("{} is missing", helper.path))
|
||||
.note(helper_absence(name))
|
||||
.note("`update --game rust` puts it back"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── RunicNPC: ours when the bundle carried it (D224) ──────────────────────
|
||||
// A warning, like a helper's: until RunicNPC's stage 9 the bridge runs without it, and what is
|
||||
// lost is the site's NPC profiles and placements and events' profile NPCs.
|
||||
if let Some(file) = &instance.npc {
|
||||
let tag = record.npc.as_ref().map(|n| n.tag.as_str()).unwrap_or("?");
|
||||
match std::fs::read(&file.path) {
|
||||
Ok(bytes) if crate::util::sha256_bytes(&bytes) == file.sha256 => {
|
||||
rows.push(Row::ok(&label("RunicNPC"), format!("{} (runicnpc-rust {tag})", file.path)))
|
||||
}
|
||||
Ok(_) => rows.push(
|
||||
Row::warn(&label("RunicNPC"), format!("{} is not the file that was deployed", file.path))
|
||||
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
|
||||
),
|
||||
Err(_) => rows.push(
|
||||
Row::warn(&label("RunicNPC"), format!("{} is missing", file.path))
|
||||
.note("without it the site's NPC profiles and placements, and events' profile NPCs, are off")
|
||||
.note("`update --game rust` puts it back"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ── The plugin's config: the website's ───────────────────────────────────
|
||||
match plugin::read_config(Path::new(&instance.plugin_config)) {
|
||||
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
|
||||
@@ -279,6 +327,23 @@ fn instance_rows(
|
||||
}
|
||||
|
||||
/// Whether a newer Rust bundle than the installed one is published.
|
||||
/// What a missing helper costs, in the operator's words. Unknown helpers get a generic line.
|
||||
fn helper_absence(name: &str) -> String {
|
||||
match name {
|
||||
"RunicGatewayZones.cs" => {
|
||||
"the bridge still runs and scores its zones by position, but ZoneManager's \
|
||||
own flags miss anybody already standing in a zone when it is created or restored"
|
||||
.to_string()
|
||||
}
|
||||
"RunicGatewayDomes.cs" => {
|
||||
"the bridge still runs and its zones still open, but the site offers no dome over \
|
||||
an event zone, because ZoneDomes cannot be called without it"
|
||||
.to_string()
|
||||
}
|
||||
_ => format!("the bridge still runs without {name}; what it helped with falls back"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bundle_row(record: &RustRecord) -> Row {
|
||||
match bundle::fetch_rust(None) {
|
||||
Ok((current, _)) if current.bundle == record.bundle.tag => {
|
||||
|
||||
@@ -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, 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};
|
||||
@@ -42,6 +43,12 @@ 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>,
|
||||
/// RunicNPC, when the bundle carries it (D224): absent, identical or replaced.
|
||||
npc_action: Option<BinaryAction>,
|
||||
game_port: u16,
|
||||
web_port: u16,
|
||||
config_path: PathBuf,
|
||||
@@ -137,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.
|
||||
@@ -166,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 {
|
||||
@@ -176,10 +218,11 @@ 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)?;
|
||||
print_plan(&layout, &planned, binary_action, &bundle);
|
||||
print_plan(&layout, &planned, binary_action, &bundle, &released);
|
||||
|
||||
if cli.verify {
|
||||
println!(
|
||||
@@ -225,6 +268,7 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
&prepared,
|
||||
&bundle,
|
||||
&released,
|
||||
npc_released.as_ref(),
|
||||
plan,
|
||||
binary_action.writes(),
|
||||
prior
|
||||
@@ -284,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() {
|
||||
@@ -326,7 +380,15 @@ 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.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 {
|
||||
@@ -366,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()))?;
|
||||
@@ -431,6 +494,41 @@ 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();
|
||||
// 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(),
|
||||
@@ -441,6 +539,9 @@ fn plan_instance(
|
||||
server,
|
||||
plugin_config,
|
||||
plugin_action,
|
||||
helper_actions,
|
||||
retired_helpers,
|
||||
npc_action,
|
||||
game_port,
|
||||
web_port,
|
||||
})
|
||||
@@ -451,6 +552,7 @@ fn print_plan(
|
||||
planned: &[Planned],
|
||||
binary: BinaryAction,
|
||||
bundle: &RustBundle,
|
||||
released: &plugin::Released,
|
||||
) {
|
||||
ui::row(
|
||||
"binary",
|
||||
@@ -479,6 +581,35 @@ 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()
|
||||
),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
@@ -509,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. \
|
||||
@@ -527,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,
|
||||
@@ -662,6 +801,74 @@ 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())),
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -694,6 +901,8 @@ fn deploy_instance(
|
||||
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,
|
||||
@@ -735,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(),
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
mod doctor;
|
||||
mod install;
|
||||
mod npc;
|
||||
mod plugin;
|
||||
mod record;
|
||||
mod server;
|
||||
|
||||
171
src/rustgame/npc.rs
Normal file
171
src/rustgame/npc.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
//! RunicNPC: Runic Gateway's NPC plugin, a third file beside the bridge (docs/runicnpc/PLAN.md
|
||||
//! D224, stage 4).
|
||||
//!
|
||||
//! **`RunicNPC.cs` is the installer's**, like the bridge and its helpers: it comes from the bundle,
|
||||
//! is replaced when the bundle moves, is put back by `update` when it was edited or deleted, and is
|
||||
//! removed by `uninstall`. Its DATA is not: `data/RunicNPC/` holds an admin's placements and
|
||||
//! routes, which RunicNPC writes and the site edits through the bridge, and nothing here touches
|
||||
//! it. RunicNPC makes that directory itself on first load, because one made from outside the game
|
||||
//! is not writable by it (runicnpc PLAN.md §1.5).
|
||||
//!
|
||||
//! It is optional until RunicNPC's stage 9: a bundle without it installs as before, and a host
|
||||
//! without it runs the bridge with Rust's own scientists only (D243).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::util::sha256_bytes;
|
||||
|
||||
/// The fixed top directory inside RunicNPC's release tarball (runicnpc-rust's release.yml).
|
||||
const PREFIX: &str = "runicnpc";
|
||||
|
||||
/// The one file placed, in the same plugins directory as the bridge.
|
||||
pub const FILE: &str = "RunicNPC.cs";
|
||||
|
||||
/// `runicnpc/manifest.json`, which RunicNPC's release folds from `plugin.toml`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub api: u32,
|
||||
#[serde(default)]
|
||||
pub files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// RunicNPC's released file, read out of its tarball.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Released {
|
||||
pub manifest: Manifest,
|
||||
pub source: Vec<u8>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Reads RunicNPC and its manifest out of a downloaded tarball, and checks the two agree.
|
||||
pub fn read_tarball(path: &Path) -> Result<Released> {
|
||||
let file =
|
||||
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
|
||||
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
|
||||
let mut manifest: Option<Vec<u8>> = None;
|
||||
let mut source: Option<Vec<u8>> = None;
|
||||
for entry in archive
|
||||
.entries()
|
||||
.context("the RunicNPC tarball is not a tar.gz")?
|
||||
{
|
||||
let mut entry = entry.context("the RunicNPC tarball is truncated")?;
|
||||
let name = entry.path()?.to_string_lossy().replace('\\', "/");
|
||||
if name == format!("{PREFIX}/manifest.json") {
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
manifest = Some(bytes);
|
||||
} else if name == format!("{PREFIX}/{FILE}") {
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
source = Some(bytes);
|
||||
}
|
||||
}
|
||||
let manifest = manifest
|
||||
.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/manifest.json"))?;
|
||||
let source =
|
||||
source.ok_or_else(|| anyhow::anyhow!("the RunicNPC tarball has no {PREFIX}/{FILE}"))?;
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&manifest).context("RunicNPC's manifest.json is unreadable")?;
|
||||
let sha256 = sha256_bytes(&source);
|
||||
match manifest.files.get(FILE) {
|
||||
Some(declared) if declared.eq_ignore_ascii_case(&sha256) => {}
|
||||
Some(declared) => bail!(
|
||||
"RunicNPC in the tarball does not match its own manifest (sha256 {sha256}, manifest \
|
||||
says {declared}). The tarball matched the bundle's checksum, so the release itself is \
|
||||
inconsistent — refusing it."
|
||||
),
|
||||
None => bail!("RunicNPC's manifest does not list {FILE} — refusing a release that does not say what it ships"),
|
||||
}
|
||||
Ok(Released {
|
||||
manifest,
|
||||
source,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
|
||||
let path = dir.join("runicnpc.tar.gz");
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
file,
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (name, bytes) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
|
||||
.unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn manifest(sha: &str) -> Vec<u8> {
|
||||
serde_json::json!({
|
||||
"component": "runicnpc", "version": "0.2.0", "commit": "abc", "api": 3,
|
||||
"requires_plugins": ["Kits"], "files": { FILE: sha }
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_is_read_and_its_file_checked_against_its_manifest() {
|
||||
let dir = TempDir::new("rg-npc").unwrap();
|
||||
let source = b"// Requires: Kits\nclass RunicNPC {}";
|
||||
let good = tarball(
|
||||
dir.path(),
|
||||
&[
|
||||
("manifest.json", &manifest(&sha256_bytes(source))),
|
||||
(FILE, source),
|
||||
],
|
||||
);
|
||||
let released = read_tarball(&good).unwrap();
|
||||
assert_eq!(
|
||||
(released.manifest.api, released.manifest.version.as_str()),
|
||||
(3, "0.2.0")
|
||||
);
|
||||
assert_eq!(released.source, source);
|
||||
|
||||
let bad = tarball(
|
||||
dir.path(),
|
||||
&[
|
||||
("manifest.json", &manifest(&"00".repeat(32))),
|
||||
(FILE, source),
|
||||
],
|
||||
);
|
||||
assert!(read_tarball(&bad)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("does not match its own manifest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_without_the_file_or_the_manifest_is_refused() {
|
||||
let dir = TempDir::new("rg-npc-missing").unwrap();
|
||||
let only_manifest = tarball(dir.path(), &[("manifest.json", &manifest("ab"))]);
|
||||
assert!(read_tarball(&only_manifest)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("has no runicnpc/RunicNPC.cs"));
|
||||
let only_file = tarball(dir.path(), &[(FILE, b"x")]);
|
||||
assert!(read_tarball(&only_file)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("manifest.json"));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
//! The plugin: its released tarball, and its config in the server root.
|
||||
//!
|
||||
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3):
|
||||
//! Two kinds of file, two owners (docs/modules/rust/PLAN.md §34.2.3):
|
||||
//!
|
||||
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
|
||||
//! moves, and is removed by `uninstall`.
|
||||
//! moves, and is removed by `uninstall`. So are the **helpers** released beside it
|
||||
//! (docs/modules/rust/PLAN_FIXES.md D168, D182 — today `RunicGatewayZones.cs`): every other `.cs`
|
||||
//! the release's manifest lists in `files`, each checked against its own sha256. A helper is
|
||||
//! optional to the bridge at runtime and installed by default.
|
||||
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
|
||||
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
|
||||
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
|
||||
@@ -37,6 +40,28 @@ pub struct Released {
|
||||
pub manifest: Manifest,
|
||||
pub source: Vec<u8>,
|
||||
pub sha256: String,
|
||||
/// The helpers the manifest lists, in name order. Empty for a release older than them.
|
||||
pub helpers: Vec<Helper>,
|
||||
}
|
||||
|
||||
/// One helper plugin shipped beside the bridge (D168, D182).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Helper {
|
||||
/// The file name, as the manifest and the plugins directory both spell it.
|
||||
pub name: String,
|
||||
pub source: Vec<u8>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// A helper's name is written into a plugins directory, so it must be a plain `<Name>.cs` and
|
||||
/// nothing a tarball could use to reach outside that directory.
|
||||
pub fn is_helper_name(name: &str) -> bool {
|
||||
let Some(stem) = name.strip_suffix(".cs") else {
|
||||
return false;
|
||||
};
|
||||
!stem.is_empty()
|
||||
&& name != super::server::PLUGIN_FILE
|
||||
&& stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
}
|
||||
|
||||
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
|
||||
@@ -46,22 +71,27 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
|
||||
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
|
||||
let mut manifest: Option<Vec<u8>> = None;
|
||||
let mut source: Option<Vec<u8>> = None;
|
||||
let mut others: BTreeMap<String, Vec<u8>> = BTreeMap::new();
|
||||
for entry in archive
|
||||
.entries()
|
||||
.context("the plugin tarball is not a tar.gz")?
|
||||
{
|
||||
let mut entry = entry.context("the plugin tarball is truncated")?;
|
||||
let name = entry.path()?.to_string_lossy().replace('\\', "/");
|
||||
let slot = if name == format!("{PREFIX}/manifest.json") {
|
||||
&mut manifest
|
||||
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
|
||||
&mut source
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
*slot = Some(bytes);
|
||||
if name == format!("{PREFIX}/manifest.json") {
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
manifest = Some(bytes);
|
||||
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
source = Some(bytes);
|
||||
} else if let Some(file) = name.strip_prefix(&format!("{PREFIX}/")) {
|
||||
// Kept only if it is a plain helper name; which of them count is the manifest's call.
|
||||
if is_helper_name(file) {
|
||||
entry.read_to_end(&mut bytes)?;
|
||||
others.insert(file.to_string(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = manifest
|
||||
@@ -84,13 +114,51 @@ pub fn read_tarball(path: &Path) -> Result<Released> {
|
||||
);
|
||||
}
|
||||
}
|
||||
let helpers = read_helpers(&manifest, others)?;
|
||||
Ok(Released {
|
||||
manifest,
|
||||
source,
|
||||
sha256,
|
||||
helpers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every file the manifest lists besides the bridge, each present and matching its sha256. A name
|
||||
/// the manifest lists that is not a plain helper name refuses the release: the installer would
|
||||
/// otherwise be asked to write it somewhere, and it will not guess where.
|
||||
fn read_helpers(manifest: &Manifest, mut found: BTreeMap<String, Vec<u8>>) -> Result<Vec<Helper>> {
|
||||
let mut helpers = Vec::new();
|
||||
for (name, declared) in &manifest.files {
|
||||
if name == super::server::PLUGIN_FILE {
|
||||
continue;
|
||||
}
|
||||
if !is_helper_name(name) {
|
||||
bail!(
|
||||
"the plugin's manifest lists {name:?}, which is not a plugin file name this \
|
||||
installer will write into a plugins directory — refusing the release."
|
||||
);
|
||||
}
|
||||
let source = found.remove(name).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"the plugin's manifest lists {name} but the tarball has no {PREFIX}/{name}"
|
||||
)
|
||||
})?;
|
||||
let sha256 = sha256_bytes(&source);
|
||||
if !declared.eq_ignore_ascii_case(&sha256) {
|
||||
bail!(
|
||||
"{name} in the plugin tarball does not match its own manifest (sha256 {sha256}, \
|
||||
manifest says {declared}) — refusing the release."
|
||||
);
|
||||
}
|
||||
helpers.push(Helper {
|
||||
name: name.clone(),
|
||||
source,
|
||||
sha256,
|
||||
});
|
||||
}
|
||||
Ok(helpers)
|
||||
}
|
||||
|
||||
/// What the plugin's config says, as far as the installer cares.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigView {
|
||||
@@ -228,6 +296,133 @@ mod tests {
|
||||
assert_eq!(view.port, Some(7799));
|
||||
}
|
||||
|
||||
/// A plugin tarball shaped like Rust-Plugins' release: `runicgateway-rust-plugin/<name>` entries.
|
||||
fn tarball(dir: &Path, entries: &[(&str, &[u8])]) -> std::path::PathBuf {
|
||||
let path = dir.join("plugin.tar.gz");
|
||||
let file = std::fs::File::create(&path).unwrap();
|
||||
let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
|
||||
file,
|
||||
flate2::Compression::default(),
|
||||
));
|
||||
for (name, bytes) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(bytes.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, format!("{PREFIX}/{name}"), *bytes)
|
||||
.unwrap();
|
||||
}
|
||||
builder.into_inner().unwrap().finish().unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn manifest(files: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let files: serde_json::Map<String, serde_json::Value> = files
|
||||
.iter()
|
||||
.map(|(name, bytes)| ((*name).to_string(), sha256_bytes(bytes).into()))
|
||||
.collect();
|
||||
serde_json::to_vec(
|
||||
&serde_json::json!({ "version": "0.2.0", "protocol": 13, "files": files }),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
const BRIDGE: &[u8] = b"// the bridge";
|
||||
const ZONES: &[u8] = b"// the zone helper";
|
||||
|
||||
#[test]
|
||||
fn a_release_with_a_helper_carries_it_checked() {
|
||||
let dir = TempDir::new("rg-rust-helper").unwrap();
|
||||
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
|
||||
let path = tarball(
|
||||
dir.path(),
|
||||
&[
|
||||
("manifest.json", &m),
|
||||
("RunicGateway.cs", BRIDGE),
|
||||
("RunicGatewayZones.cs", ZONES),
|
||||
],
|
||||
);
|
||||
let released = read_tarball(&path).unwrap();
|
||||
assert_eq!(released.source, BRIDGE);
|
||||
assert_eq!(released.helpers.len(), 1);
|
||||
assert_eq!(released.helpers[0].name, "RunicGatewayZones.cs");
|
||||
assert_eq!(released.helpers[0].source, ZONES);
|
||||
assert_eq!(released.helpers[0].sha256, sha256_bytes(ZONES));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_older_than_helpers_has_none_even_with_a_stray_file() {
|
||||
// Only what the manifest lists is a helper: a `.cs` it does not name is not installed.
|
||||
let dir = TempDir::new("rg-rust-helper-old").unwrap();
|
||||
let m = manifest(&[("RunicGateway.cs", BRIDGE)]);
|
||||
let path = tarball(
|
||||
dir.path(),
|
||||
&[
|
||||
("manifest.json", &m),
|
||||
("RunicGateway.cs", BRIDGE),
|
||||
("RunicGatewayZones.cs", ZONES),
|
||||
],
|
||||
);
|
||||
assert!(read_tarball(&path).unwrap().helpers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_helper_the_manifest_lists_must_be_there_and_match() {
|
||||
let dir = TempDir::new("rg-rust-helper-bad").unwrap();
|
||||
let m = manifest(&[("RunicGateway.cs", BRIDGE), ("RunicGatewayZones.cs", ZONES)]);
|
||||
|
||||
let missing = tarball(
|
||||
dir.path(),
|
||||
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
|
||||
);
|
||||
let err = read_tarball(&missing).unwrap_err().to_string();
|
||||
assert!(err.contains("RunicGatewayZones.cs"), "{err}");
|
||||
|
||||
let tampered = tarball(
|
||||
dir.path(),
|
||||
&[
|
||||
("manifest.json", &m),
|
||||
("RunicGateway.cs", BRIDGE),
|
||||
("RunicGatewayZones.cs", b"// something else"),
|
||||
],
|
||||
);
|
||||
let err = read_tarball(&tampered).unwrap_err().to_string();
|
||||
assert!(err.contains("does not match its own manifest"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_naming_a_path_is_refused() {
|
||||
let dir = TempDir::new("rg-rust-helper-path").unwrap();
|
||||
for name in ["../Evil.cs", "sub/Evil.cs", "Evil.dll", ".cs"] {
|
||||
let m = manifest(&[("RunicGateway.cs", BRIDGE), (name, ZONES)]);
|
||||
let path = tarball(
|
||||
dir.path(),
|
||||
&[("manifest.json", &m), ("RunicGateway.cs", BRIDGE)],
|
||||
);
|
||||
let err = read_tarball(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("refusing the release"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_names_are_plain_plugin_files() {
|
||||
assert!(is_helper_name("RunicGatewayZones.cs"));
|
||||
assert!(is_helper_name("Helper_2.cs"));
|
||||
for bad in [
|
||||
"RunicGateway.cs",
|
||||
"../X.cs",
|
||||
"a/X.cs",
|
||||
"a\\X.cs",
|
||||
"X.cs.bak",
|
||||
".cs",
|
||||
"X .cs",
|
||||
"X-y.cs",
|
||||
] {
|
||||
assert!(!is_helper_name(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_required_plugin_is_named() {
|
||||
let dir = TempDir::new("rg-rust-req").unwrap();
|
||||
|
||||
@@ -30,6 +30,9 @@ pub struct RustRecord {
|
||||
pub sidecar: ComponentRecord,
|
||||
pub binary: BinaryRef,
|
||||
pub plugin: ComponentRecord,
|
||||
/// RunicNPC's release, when the bundle carried one (docs/runicnpc/PLAN.md D224).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub npc: Option<NpcRecord>,
|
||||
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
|
||||
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
|
||||
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
|
||||
@@ -51,6 +54,17 @@ pub struct ComponentRecord {
|
||||
pub protocol: u32,
|
||||
}
|
||||
|
||||
/// Which RunicNPC release is deployed, and the API it answers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct NpcRecord {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub commit: String,
|
||||
pub api: u32,
|
||||
}
|
||||
|
||||
/// One Rust server and its sidecar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Instance {
|
||||
@@ -60,6 +74,13 @@ pub struct Instance {
|
||||
pub plugin_path: String,
|
||||
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
|
||||
pub plugin_sha256: String,
|
||||
/// The helpers deployed beside the plugin (D168, D182), by file name. Installer-owned like the
|
||||
/// plugin. Absent from a record written before helpers existed, which reads back as none.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub helpers: BTreeMap<String, DeployedFile>,
|
||||
/// `RunicNPC.cs`, when the bundle carried RunicNPC. Its data directory is not the installer's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub npc: Option<DeployedFile>,
|
||||
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
|
||||
pub plugin_config: String,
|
||||
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
|
||||
@@ -75,6 +96,13 @@ pub struct Instance {
|
||||
pub service: Option<ServiceRecord>,
|
||||
}
|
||||
|
||||
/// One installer-owned file in a server root, as deployed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DeployedFile {
|
||||
pub path: String,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
impl RustRecord {
|
||||
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
|
||||
pub fn same_deployment_as(&self, other: &Self) -> bool {
|
||||
@@ -142,6 +170,8 @@ mod tests {
|
||||
framework: "oxide".into(),
|
||||
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
|
||||
plugin_sha256: "ab".repeat(32),
|
||||
helpers: BTreeMap::new(),
|
||||
npc: None,
|
||||
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
|
||||
plugin_config_written: true,
|
||||
game_port: game,
|
||||
@@ -179,6 +209,7 @@ mod tests {
|
||||
commit: "abc".into(),
|
||||
protocol: 12,
|
||||
},
|
||||
npc: None,
|
||||
service_user_created: true,
|
||||
instances: BTreeMap::from([
|
||||
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
|
||||
@@ -199,6 +230,53 @@ mod tests {
|
||||
assert!(!text.contains("auth_token") && !text.contains("token\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_with_runicnpc_round_trips_and_one_without_loads_as_none() {
|
||||
let dir = TempDir::new("rg-rust-record-npc").unwrap();
|
||||
let path = dir.path().join("install.json");
|
||||
let mut record = sample();
|
||||
record.npc = Some(NpcRecord {
|
||||
repo: "RunicGateway/runicnpc-rust".into(),
|
||||
tag: "v0.2.0".into(),
|
||||
version: "0.2.0".into(),
|
||||
commit: "abc".into(),
|
||||
api: 3,
|
||||
});
|
||||
record.instances.get_mut("alpha").unwrap().npc = Some(DeployedFile {
|
||||
path: "/srv/a/oxide/plugins/RunicNPC.cs".into(),
|
||||
sha256: "ef".repeat(32),
|
||||
});
|
||||
record.save(&path).unwrap();
|
||||
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
|
||||
|
||||
// A record from before stage 4 has neither key, and reads back as none.
|
||||
let old = sample();
|
||||
old.save(&path).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!text.contains("\"npc\""));
|
||||
let back = RustRecord::load(&path).unwrap().unwrap();
|
||||
assert!(back.npc.is_none() && back.instances.values().all(|i| i.npc.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_written_before_helpers_loads_with_none() {
|
||||
// Every host installed before D182 has one of these: no `helpers` key at all.
|
||||
let dir = TempDir::new("rg-rust-record-old").unwrap();
|
||||
let path = dir.path().join("install.json");
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
for instance in value["instances"].as_object_mut().unwrap().values_mut() {
|
||||
assert!(instance
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("helpers")
|
||||
.is_none());
|
||||
}
|
||||
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
let record = RustRecord::load(&path).unwrap().unwrap();
|
||||
assert!(record.instances.values().all(|i| i.helpers.is_empty()));
|
||||
assert_eq!(record, sample());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
|
||||
let record = sample();
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn handoff(
|
||||
protocol = doc.protocol,
|
||||
when = if running {
|
||||
format!(
|
||||
"The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected."
|
||||
"The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected."
|
||||
)
|
||||
} else {
|
||||
"The plugin connects when the server next starts.".to_string()
|
||||
@@ -265,6 +265,16 @@ mod tests {
|
||||
running.contains("doctor --game rust --server-id alpha"),
|
||||
"{running}"
|
||||
);
|
||||
// The whole first line, so a wrapped source line cannot leave its indent in the sentence
|
||||
// again (installer#34: "confirms it connected.").
|
||||
assert_eq!(
|
||||
running.lines().find(|l| !l.is_empty()),
|
||||
Some(
|
||||
"Rust server \"alpha\" is set up. The plugin loads now; \
|
||||
`doctor --game rust --server-id alpha` confirms it connected."
|
||||
),
|
||||
"{running}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -59,6 +59,15 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
println!(" · remove the service {}", svc.name);
|
||||
}
|
||||
println!(" · remove {}", i.plugin_path);
|
||||
for helper in i.helpers.values() {
|
||||
println!(" · remove {}", helper.path);
|
||||
}
|
||||
if let Some(npc) = &i.npc {
|
||||
println!(
|
||||
" · remove {} (RunicNPC; its placements in data/RunicNPC/ are kept)",
|
||||
npc.path
|
||||
);
|
||||
}
|
||||
println!(
|
||||
" · keep {} (the website's; it names this server)",
|
||||
i.plugin_config
|
||||
@@ -106,6 +115,15 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
done.extend(removal.done);
|
||||
problems.extend(removal.problems);
|
||||
}
|
||||
// The helpers first: without the bridge they have nothing to do, and a helper left behind
|
||||
// would keep patching ZoneManager for a bridge that is gone.
|
||||
for helper in instance.helpers.values() {
|
||||
remove_file(Path::new(&helper.path), &mut done, &mut problems);
|
||||
}
|
||||
// RunicNPC is the installer's file (D224); its data directory is the admins', and stays.
|
||||
if let Some(npc) = &instance.npc {
|
||||
remove_file(Path::new(&npc.path), &mut done, &mut problems);
|
||||
}
|
||||
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
|
||||
if cli.purge {
|
||||
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
|
||||
|
||||
Reference in New Issue
Block a user