Compare commits
34 Commits
84c1106d58
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a3676a22d | |||
| 9d73367e63 | |||
| d61f46ffb6 | |||
| dc3d360cf2 | |||
| 9f43260ee7 | |||
| 0710a3e33e | |||
| 0f19fd6413 | |||
| 70eb073826 | |||
| 285f7df17b | |||
| e5611a01eb | |||
| 397e0b92e9 | |||
| 0c07b8a967 | |||
| 3b2881eb9c | |||
| 0fad3e3b2d | |||
| 7027a78a23 | |||
| 8e4cbc6406 | |||
| 4f268f7ffb | |||
| f865660c7c | |||
| 2285fff759 | |||
| ead105d5bf | |||
| 1996a32153 | |||
| 65998692ae | |||
| 094da1776b | |||
| 188e6eb882 | |||
| 9cc109910c | |||
| 6da385425e | |||
| 5d4c68eaf5 | |||
| c3771d22f2 | |||
| 6c49217e9c | |||
| 7758724eb5 | |||
| 484f00ddee | |||
| f81cbcdd04 | |||
| 007791c4fc | |||
| 1173a10049 |
437
.gitea/scripts/compose-bundles.sh
Executable file
437
.gitea/scripts/compose-bundles.sh
Executable file
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env bash
|
||||
# Compose every bundle stream from the latest component releases.
|
||||
#
|
||||
# Called by .gitea/workflows/bundle.yml, which owns checkout, publishing and the
|
||||
# stale-component check; this script owns the gates and the documents. It lives
|
||||
# in a file rather than inline so it can be run by hand against the real release
|
||||
# API — which is how it is tested — without a runner.
|
||||
#
|
||||
# ── The streams (docs/modules/rust/PLAN.md §34.2.2, D146/D147) ───────────────
|
||||
#
|
||||
# current.json, bundle-<tag>.json schema 1, ServUO (until SCHEMA1_RETIRES)
|
||||
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
|
||||
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust"
|
||||
#
|
||||
# A schema-2 document names ONE game. The two games release on their own
|
||||
# schedules, and a document naming both would hand a ServUO host a new bundle
|
||||
# every time a Rust plugin shipped.
|
||||
#
|
||||
# Schema 1 is still composed because every installer already in the field reads
|
||||
# only `current.json` and refuses any schema but 1. After SCHEMA1_RETIRES it is
|
||||
# left FROZEN at its last bundle rather than deleted, so an old installer still
|
||||
# resolves something and every bundle-<tag>.json stays pinnable.
|
||||
#
|
||||
# ── One matrix, one tag ──────────────────────────────────────────────────────
|
||||
# A ServUO matrix published at both schemas carries the SAME tag in both, so a
|
||||
# `--bundle <tag>` an operator wrote down means one pair whichever installer
|
||||
# reads it. The first run after schema 2 lands is the case this is for: the
|
||||
# ServUO matrix has not changed, so v2/servuo takes the tag schema 1 already
|
||||
# gave it rather than inventing a second name for the same pair.
|
||||
#
|
||||
# ── Failure is per game ──────────────────────────────────────────────────────
|
||||
# A Rust release with a protocol mismatch must not stop a ServUO bundle from
|
||||
# publishing, and the reverse. Each game composes in its own subshell; what
|
||||
# succeeded is written, and the script exits 1 at the end if anything failed, so
|
||||
# the run is still red.
|
||||
#
|
||||
# A game none of whose components has released yet composes nothing, and that is
|
||||
# NOT a failure — it is the state Rust is in until its first cutover.
|
||||
#
|
||||
# ── Interface ────────────────────────────────────────────────────────────────
|
||||
# PUBLISHED the bundles-branch worktree (read, and written on change)
|
||||
# WORK scratch directory
|
||||
# GITEA_HOST default gitea.whitlocktech.com (GITEA_BASE overrides the whole URL)
|
||||
# TODAY YYYY-MM-DD, default today UTC (tests override it)
|
||||
# SCHEMA1_RETIRES YYYY-MM-DD, default 2027-01-01 (PLAN.md §34.4)
|
||||
#
|
||||
# Results, for the workflow:
|
||||
# $WORK/result.env changed=true|false, commit_subject=…
|
||||
# $WORK/summary.md the job summary's body
|
||||
# $WORK/stale.tsv <repo slug>\t<released tag>, one per resolved component
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${PUBLISHED:?PUBLISHED must name the bundles-branch worktree}"
|
||||
: "${WORK:?WORK must name a scratch directory}"
|
||||
GITEA_HOST="${GITEA_HOST:-gitea.whitlocktech.com}"
|
||||
# Overridable so the script can be pointed at a mock Gitea (how the Rust path was
|
||||
# tested before either Rust repository had released).
|
||||
GITEA_BASE="${GITEA_BASE:-https://${GITEA_HOST}}"
|
||||
TODAY="${TODAY:-$(date -u +%Y-%m-%d)}"
|
||||
SCHEMA1_RETIRES="${SCHEMA1_RETIRES:-2027-01-01}"
|
||||
|
||||
SERVUO_LINK_REPO="RunicGateway/link"
|
||||
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
|
||||
RUST_LINK_REPO="RunicGateway/Rust-Link"
|
||||
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
|
||||
|
||||
# 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"
|
||||
|
||||
mkdir -p "$WORK" "$PUBLISHED"
|
||||
: > "$WORK/summary.md"
|
||||
: > "$WORK/stale.tsv"
|
||||
: > "$WORK/published.txt"
|
||||
|
||||
fail() { echo "::error::$*" >&2; exit 1; }
|
||||
note() { printf -- '%s\n' "$*" >> "$WORK/summary.md"; }
|
||||
|
||||
# ── Release resolution ───────────────────────────────────────────────────────
|
||||
# Read ANONYMOUSLY, on purpose: these are exactly the requests the shipped
|
||||
# installer and the egg make, from a host with no Gitea credentials. A repo
|
||||
# flipped to private fails CI here instead of on an operator's machine.
|
||||
#
|
||||
# Returns 0 with $WORK/<key>-release.json written, or 2 when the repo has never
|
||||
# released (Gitea answers /releases/latest with 404). Anything else is fatal.
|
||||
resolve_latest() {
|
||||
local slug="$1" key="$2" code tag
|
||||
code="$(curl -sS -o "$WORK/${key}-release.json" -w '%{http_code}' \
|
||||
"${GITEA_BASE}/api/v1/repos/${slug}/releases/latest")" || fail "could not reach ${slug}'s releases"
|
||||
case "$code" in
|
||||
200) ;;
|
||||
404) return 2 ;;
|
||||
*) fail "${slug} /releases/latest answered HTTP ${code}" ;;
|
||||
esac
|
||||
tag="$(jq -r '.tag_name' "$WORK/${key}-release.json")"
|
||||
[ -n "$tag" ] && [ "$tag" != "null" ] || fail "${slug}'s latest release has no tag"
|
||||
printf '%s\t%s\n' "$slug" "$tag" >> "$WORK/stale.tsv"
|
||||
echo "==> ${slug} latest: ${tag}"
|
||||
}
|
||||
|
||||
release_tag() { jq -r '.tag_name' "$WORK/$1-release.json"; }
|
||||
|
||||
# ── GATE 2 (installer PLAN.md §7.1): assets exist, checksums match ───────────
|
||||
# Every asset a bundle will reference is downloaded and verified against the
|
||||
# SHA256SUMS published beside it. SHA256SUMS is the trust anchor for these
|
||||
# deliberately UNSIGNED artifacts, and the installer and the egg verify against
|
||||
# the hashes THIS job records; a hash copied from a file nobody checked would
|
||||
# make the chain decorative.
|
||||
#
|
||||
# `sha256sum -c` catches a SHA256SUMS entry with no asset. The reverse — an asset
|
||||
# with no entry — is checked by name, because -c would pass right over it. The
|
||||
# `\*?` matches sha256sum's binary-mode marker.
|
||||
verify_assets() {
|
||||
local key="$1" dir="$WORK/$1" sums_url name url
|
||||
rm -rf "$dir"; mkdir -p "$dir"
|
||||
sums_url="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "$WORK/${key}-release.json")"
|
||||
[ -n "$sums_url" ] && [ "$sums_url" != "null" ] \
|
||||
|| fail "${key} release has no SHA256SUMS asset — nothing to verify against"
|
||||
curl -sSfL -o "${dir}/SHA256SUMS" "$sums_url"
|
||||
|
||||
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
|
||||
"$WORK/${key}-release.json" > "${dir}/asset-list.tsv"
|
||||
[ -s "${dir}/asset-list.tsv" ] || fail "${key} release carries no assets besides SHA256SUMS"
|
||||
|
||||
while IFS=$'\t' read -r name url; do
|
||||
[ -n "$name" ] || continue
|
||||
echo " fetching ${key}/${name}"
|
||||
curl -sSfL -o "${dir}/${name}" "$url"
|
||||
grep -qE "[ \t]\*?${name}\$" "${dir}/SHA256SUMS" \
|
||||
|| fail "${key} asset ${name} has no entry in that release's SHA256SUMS"
|
||||
done < "${dir}/asset-list.tsv"
|
||||
|
||||
( cd "$dir" && sha256sum -c SHA256SUMS ) \
|
||||
|| fail "${key} assets do not match the SHA256SUMS published with them"
|
||||
echo "==> ${key}: all assets present and verified"
|
||||
}
|
||||
|
||||
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
|
||||
# One asset as a bundle `{name,url,sha256}` object.
|
||||
asset_json() {
|
||||
local key="$1" name="$2" url
|
||||
url="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' "$WORK/${key}/asset-list.tsv")"
|
||||
jq -n --arg name "$name" --arg url "$url" --arg sha "$(sha_of "$WORK/${key}/${name}")" \
|
||||
'{ name: $name, url: $url, sha256: $sha }'
|
||||
}
|
||||
|
||||
# The single tarball a payload release carries, by name. Exactly one: a second
|
||||
# .tar.gz would leave the installer guessing which one it deploys.
|
||||
single_tarball() {
|
||||
local key="$1" count
|
||||
count="$(awk -F'\t' '$1 ~ /\.tar\.gz$/' "$WORK/${key}/asset-list.tsv" | wc -l)"
|
||||
[ "$count" -eq 1 ] || fail "expected exactly 1 .tar.gz in the ${key} release, found ${count}"
|
||||
awk -F'\t' '$1 ~ /\.tar\.gz$/ { printf "%s", $1 }' "$WORK/${key}/asset-list.tsv"
|
||||
}
|
||||
|
||||
# ── GATE 1 (installer PLAN.md §7.1): both halves speak one protocol ──────────
|
||||
# The sidecar side is PROTOCOL_VERSION in sidecar/src/main.rs, read at the
|
||||
# RELEASE TAG — not from the binary, which would mean executing a downloaded
|
||||
# artifact (and, for older releases, one with no way to answer). Both link and
|
||||
# Rust-Link keep the constant at that path.
|
||||
sidecar_protocol() {
|
||||
local slug="$1" tag="$2" key="$3" p
|
||||
curl -sSfL -o "$WORK/${key}-main.rs" \
|
||||
"${GITEA_BASE}/${slug}/raw/tag/${tag}/sidecar/src/main.rs"
|
||||
p="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' "$WORK/${key}-main.rs" | head -1)"
|
||||
# Empty means the constant moved. "Could not read" must never read as "matches".
|
||||
[ -n "$p" ] || fail "could not read PROTOCOL_VERSION from ${slug}@${tag}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
|
||||
printf '%s' "$p"
|
||||
}
|
||||
|
||||
# Unpack a payload tarball and return the path of its manifest.json, having
|
||||
# checked the manifest names the version it was released under.
|
||||
payload_manifest() {
|
||||
local key="$1" tarball="$2" prefix="$3" tag="$4" m v
|
||||
rm -rf "$WORK/${key}-x"; mkdir -p "$WORK/${key}-x"
|
||||
tar -xzf "$WORK/${key}/${tarball}" -C "$WORK/${key}-x"
|
||||
m="$WORK/${key}-x/${prefix}/manifest.json"
|
||||
[ -f "$m" ] || fail "the ${key} tarball has no ${prefix}/manifest.json — the installer and the egg resolve it at that exact path"
|
||||
v="$(jq -r '.version' "$m")"
|
||||
# A manifest that disagrees with its tag means the release stamped one version
|
||||
# and tagged another; every record of "what is installed" would then be wrong.
|
||||
[ "$v" = "${tag#v}" ] || fail "${key} manifest says version ${v} but the release is tagged ${tag}"
|
||||
printf '%s' "$m"
|
||||
}
|
||||
|
||||
# ── Publishing helpers ───────────────────────────────────────────────────────
|
||||
# `bundle` and `generated` describe the RUN, not the matrix. Comparing them would
|
||||
# make every nightly cron look like a change and commit a dated duplicate of the
|
||||
# same matrix forever, so only what an installer acts on is compared.
|
||||
same_content() {
|
||||
local published="$1" content="$2"
|
||||
[ -f "$published" ] || return 1
|
||||
cmp -s <(jq -S 'del(.bundle, .generated)' "$published") <(jq -S '.' "$content")
|
||||
}
|
||||
|
||||
# A fresh date tag that names no bundle in any of the given directories. Two
|
||||
# bundles on one day get .2, .3, … so a tag always names exactly one matrix.
|
||||
fresh_tag() {
|
||||
local base tag n=1 d taken
|
||||
base="$(date -u -d "$TODAY" +%Y.%m.%d)"
|
||||
tag="$base"
|
||||
while :; do
|
||||
taken=false
|
||||
for d in "$@"; do [ -f "${d}/bundle-${tag}.json" ] && taken=true; done
|
||||
[ "$taken" = false ] && break
|
||||
n=$((n + 1)); tag="${base}.${n}"
|
||||
done
|
||||
printf '%s' "$tag"
|
||||
}
|
||||
|
||||
# Write <dir>/bundle-<tag>.json and <dir>/current.json from a content document.
|
||||
# The header keys go first so a person reading the file sees what it is.
|
||||
publish_doc() {
|
||||
local dir="$1" tag="$2" content="$3"
|
||||
mkdir -p "$dir"
|
||||
jq --arg bundle "$tag" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'(if has("game") then { schema: .schema, game: .game } else { schema: .schema } end)
|
||||
+ { bundle: $bundle, generated: $generated } + del(.schema, .game)' \
|
||||
"$content" > "${dir}/bundle-${tag}.json"
|
||||
cp "${dir}/bundle-${tag}.json" "${dir}/current.json"
|
||||
local rel="${dir#"$PUBLISHED"}"; rel="${rel#/}"
|
||||
echo "${rel:+${rel}/}bundle-${tag}.json" >> "$WORK/published.txt"
|
||||
}
|
||||
|
||||
# ── ServUO: schema 1 and v2/servuo ───────────────────────────────────────────
|
||||
compose_servuo() {
|
||||
resolve_latest "$SERVUO_LINK_REPO" servuo-link \
|
||||
|| fail "${SERVUO_LINK_REPO} has no published release"
|
||||
resolve_latest "$SERVUO_OVERLAY_REPO" servuo-overlay \
|
||||
|| fail "${SERVUO_OVERLAY_REPO} has no published release"
|
||||
local link_tag overlay_tag
|
||||
link_tag="$(release_tag servuo-link)"; overlay_tag="$(release_tag servuo-overlay)"
|
||||
|
||||
verify_assets servuo-link
|
||||
verify_assets servuo-overlay
|
||||
|
||||
# link's binaries onto platform keys. An unrecognized name is a hard failure,
|
||||
# so a new target in link's release.yml reddens this job instead of silently
|
||||
# vanishing from every bundle; a missing required key fails the same way.
|
||||
local assets='{}' name url plat
|
||||
while IFS=$'\t' read -r name url; do
|
||||
[ -n "$name" ] || continue
|
||||
case "$name" in
|
||||
*-linux-x86_64) plat=linux-x86_64 ;;
|
||||
*-linux-aarch64) plat=linux-aarch64 ;;
|
||||
*-windows-x86_64.exe) plat=windows-x86_64 ;;
|
||||
*) fail "unrecognized link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
|
||||
esac
|
||||
assets="$(jq --arg p "$plat" --argjson a "$(asset_json servuo-link "$name")" '. + { ($p): $a }' <<<"$assets")"
|
||||
done < "$WORK/servuo-link/asset-list.tsv"
|
||||
for plat in linux-x86_64 linux-aarch64 windows-x86_64; do
|
||||
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|
||||
|| fail "link release is missing a ${plat} binary; the installer ships for all three"
|
||||
done
|
||||
|
||||
local tarball manifest overlay_protocol link_protocol
|
||||
[ "$(wc -l < "$WORK/servuo-overlay/asset-list.tsv")" -eq 1 ] \
|
||||
|| fail "expected exactly 1 overlay asset besides SHA256SUMS"
|
||||
tarball="$(single_tarball servuo-overlay)"
|
||||
manifest="$(payload_manifest servuo-overlay "$tarball" "$SERVUO_OVERLAY_PREFIX" "$overlay_tag")"
|
||||
overlay_protocol="$(jq -r '.protocol' "$manifest")"
|
||||
link_protocol="$(sidecar_protocol "$SERVUO_LINK_REPO" "$link_tag" servuo-link)"
|
||||
echo "==> servuo: sidecar ${link_tag} protocol=${link_protocol} | overlay ${overlay_tag} protocol=${overlay_protocol}"
|
||||
[ "$link_protocol" = "$overlay_protocol" ] || fail \
|
||||
"PROTOCOL MISMATCH — sidecar ${link_tag} speaks ${link_protocol}, overlay ${overlay_tag} declares ${overlay_protocol}. Refusing a bundle whose shard the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
|
||||
|
||||
local common
|
||||
common="$(jq -n \
|
||||
--arg link_repo "$SERVUO_LINK_REPO" --arg link_tag "$link_tag" --argjson assets "$assets" \
|
||||
--arg ov_repo "$SERVUO_OVERLAY_REPO" --arg ov_tag "$overlay_tag" \
|
||||
--arg ov_commit "$(jq -r '.commit' "$manifest")" \
|
||||
--arg ov_min "$(jq -r '.servuo.min_version' "$manifest")" \
|
||||
--arg ov_patched "$(jq -r '.servuo.patches_verified_against' "$manifest")" \
|
||||
--argjson ov_asset "$(asset_json servuo-overlay "$tarball")" \
|
||||
--argjson protocol "$link_protocol" \
|
||||
'{ protocol: $protocol,
|
||||
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
|
||||
protocol: $protocol, assets: $assets },
|
||||
overlay: { repo: $ov_repo, tag: $ov_tag, version: ($ov_tag | ltrimstr("v")),
|
||||
commit: $ov_commit, protocol: $protocol,
|
||||
servuo: { min_version: $ov_min, patches_verified_against: $ov_patched },
|
||||
asset: $ov_asset } }')"
|
||||
|
||||
# Schema 1: byte-for-byte the shape every shipped installer parses.
|
||||
jq '{ schema: 1, protocol: .protocol, link: .sidecar, overlay: .overlay }' \
|
||||
<<<"$common" > "$WORK/servuo-s1.json"
|
||||
# Schema 2: the same pair, in the game-neutral shape (§34.2.2). The payload's
|
||||
# `compat` is schema 1's `servuo` block.
|
||||
jq '{ schema: 2, game: "servuo", protocol: .protocol, sidecar: .sidecar,
|
||||
payload: ({ kind: "overlay" } + (.overlay | del(.servuo)) + { compat: .overlay.servuo }) }' \
|
||||
<<<"$common" > "$WORK/servuo-s2.json"
|
||||
|
||||
local s1_active=false s1_changed=false s2_changed=false tag=""
|
||||
[[ "$TODAY" < "$SCHEMA1_RETIRES" ]] && s1_active=true
|
||||
if [ "$s1_active" = true ] && ! same_content "$PUBLISHED/current.json" "$WORK/servuo-s1.json"; then
|
||||
s1_changed=true
|
||||
fi
|
||||
same_content "$PUBLISHED/v2/servuo/current.json" "$WORK/servuo-s2.json" || s2_changed=true
|
||||
|
||||
if [ "$s1_changed" = false ] && [ "$s2_changed" = false ]; then
|
||||
note "- **ServUO**: no change (link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol})."
|
||||
[ "$s1_active" = true ] || note " Schema 1 retired on ${SCHEMA1_RETIRES}; its \`current.json\` stays frozen."
|
||||
return 0
|
||||
fi
|
||||
|
||||
# One matrix, one tag: when schema 1 already names this pair, v2 borrows its
|
||||
# tag — unless v2 somehow already holds that tag for different content, since
|
||||
# a published bundle-<tag>.json is never rewritten.
|
||||
if [ "$s1_active" = true ] && [ "$s1_changed" = false ] && [ -f "$PUBLISHED/current.json" ]; then
|
||||
tag="$(jq -r '.bundle' "$PUBLISHED/current.json")"
|
||||
if [ -f "$PUBLISHED/v2/servuo/bundle-${tag}.json" ]; then tag=""; fi
|
||||
fi
|
||||
[ -n "$tag" ] || tag="$(fresh_tag "$PUBLISHED" "$PUBLISHED/v2/servuo")"
|
||||
|
||||
[ "$s1_changed" = true ] && publish_doc "$PUBLISHED" "$tag" "$WORK/servuo-s1.json"
|
||||
[ "$s2_changed" = true ] && publish_doc "$PUBLISHED/v2/servuo" "$tag" "$WORK/servuo-s2.json"
|
||||
local which=""
|
||||
[ "$s1_changed" = true ] && which="schema 1"
|
||||
[ "$s2_changed" = true ] && which="${which:+${which} + }schema 2"
|
||||
note "- **ServUO**: published \`${tag}\` (${which}) — link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol}."
|
||||
echo "servuo ${tag}" >> "$WORK/published-games.txt"
|
||||
}
|
||||
|
||||
# ── Rust: v2/rust ────────────────────────────────────────────────────────────
|
||||
compose_rust() {
|
||||
local rc=0 missing=""
|
||||
resolve_latest "$RUST_LINK_REPO" rust-link || { rc=$?; [ "$rc" -eq 2 ] && missing="${RUST_LINK_REPO}"; }
|
||||
rc=0
|
||||
resolve_latest "$RUST_PLUGIN_REPO" rust-plugin || { rc=$?; [ "$rc" -eq 2 ] && missing="${missing:+${missing} and }${RUST_PLUGIN_REPO}"; }
|
||||
if [ -n "$missing" ]; then
|
||||
echo "==> rust: no release yet from ${missing}, so nothing to compose (not a failure)"
|
||||
note "- **Rust**: skipped — no release yet from ${missing}."
|
||||
return 0
|
||||
fi
|
||||
local link_tag plugin_tag
|
||||
link_tag="$(release_tag rust-link)"; plugin_tag="$(release_tag rust-plugin)"
|
||||
|
||||
verify_assets rust-link
|
||||
verify_assets rust-plugin
|
||||
|
||||
# Rust-Link's release: a binary per platform, the egg's launcher, and the egg
|
||||
# itself. The egg is verified (gate 2 covers every asset) but is not part of
|
||||
# the bundle — a panel admin imports it; nothing resolves it from here.
|
||||
local assets='{}' launcher="" name url plat
|
||||
while IFS=$'\t' read -r name url; do
|
||||
[ -n "$name" ] || continue
|
||||
case "$name" in
|
||||
*-linux-x86_64) plat=linux-x86_64 ;;
|
||||
*-windows-x86_64.exe) plat=windows-x86_64 ;;
|
||||
with-sidecar.sh) launcher="$(asset_json rust-link "$name")"; continue ;;
|
||||
egg-*.json) continue ;;
|
||||
*) fail "unrecognized Rust-Link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
|
||||
esac
|
||||
assets="$(jq --arg p "$plat" --argjson a "$(asset_json rust-link "$name")" '. + { ($p): $a }' <<<"$assets")"
|
||||
done < "$WORK/rust-link/asset-list.tsv"
|
||||
# No linux-aarch64: RustDedicated has no arm64 build (D149).
|
||||
for plat in linux-x86_64 windows-x86_64; do
|
||||
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|
||||
|| fail "Rust-Link release is missing a ${plat} binary (D149)"
|
||||
done
|
||||
[ -n "$launcher" ] || fail "Rust-Link release has no with-sidecar.sh — the egg's startup runs it (§34.2.6)"
|
||||
|
||||
local tarball manifest plugin_protocol link_protocol
|
||||
tarball="$(single_tarball rust-plugin)"
|
||||
manifest="$(payload_manifest rust-plugin "$tarball" "$RUST_PLUGIN_PREFIX" "$plugin_tag")"
|
||||
[ -f "$(dirname "$manifest")/RunicGateway.cs" ] \
|
||||
|| fail "the Rust-Plugins tarball has no ${RUST_PLUGIN_PREFIX}/RunicGateway.cs"
|
||||
plugin_protocol="$(jq -r '.protocol' "$manifest")"
|
||||
link_protocol="$(sidecar_protocol "$RUST_LINK_REPO" "$link_tag" rust-link)"
|
||||
echo "==> rust: sidecar ${link_tag} protocol=${link_protocol} | plugin ${plugin_tag} protocol=${plugin_protocol}"
|
||||
[ "$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."
|
||||
|
||||
jq -n \
|
||||
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
|
||||
--argjson assets "$assets" --argjson launcher "$launcher" \
|
||||
--arg p_repo "$RUST_PLUGIN_REPO" --arg p_tag "$plugin_tag" \
|
||||
--slurpfile m "$manifest" \
|
||||
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
|
||||
--argjson protocol "$link_protocol" \
|
||||
'{ schema: 2, game: "rust", protocol: $protocol,
|
||||
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
|
||||
protocol: $protocol, assets: $assets, launcher: $launcher },
|
||||
payload: { kind: "plugin", repo: $p_repo, tag: $p_tag, version: ($p_tag | ltrimstr("v")),
|
||||
commit: $m[0].commit, protocol: $protocol,
|
||||
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"
|
||||
# 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 \
|
||||
|| fail "the Rust-Plugins manifest is missing commit, a framework floor, or requires_plugins"
|
||||
|
||||
if same_content "$PUBLISHED/v2/rust/current.json" "$WORK/rust-s2.json"; then
|
||||
note "- **Rust**: no change (Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol})."
|
||||
return 0
|
||||
fi
|
||||
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}."
|
||||
echo "rust ${tag}" >> "$WORK/published-games.txt"
|
||||
}
|
||||
|
||||
# ── Run both, independently ──────────────────────────────────────────────────
|
||||
: > "$WORK/published-games.txt"
|
||||
FAILED=""
|
||||
for game in servuo rust; do
|
||||
echo "────────── ${game} ──────────"
|
||||
# NOT `if ! ( … )`: a subshell in a condition runs with errexit OFF, so an
|
||||
# unchecked failed download inside it would sail on. Capture the status instead.
|
||||
set +e
|
||||
( set -e; "compose_${game}" )
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
FAILED="${FAILED:+${FAILED}, }${game}"
|
||||
note "- **${game}**: **FAILED** — see the log. Nothing was published for it."
|
||||
fi
|
||||
done
|
||||
|
||||
CHANGED=false
|
||||
[ -s "$WORK/published.txt" ] && CHANGED=true
|
||||
{
|
||||
echo "changed=${CHANGED}"
|
||||
echo "commit_subject=chore(bundle): publish $(paste -sd ',' "$WORK/published-games.txt" | sed 's/,/, /g') [skip ci]"
|
||||
} > "$WORK/result.env"
|
||||
|
||||
echo "────────── result ──────────"
|
||||
cat "$WORK/summary.md"
|
||||
[ "$CHANGED" = true ] && { echo "files:"; sed 's/^/ /' "$WORK/published.txt"; }
|
||||
[ -z "$FAILED" ] || fail "compose failed for: ${FAILED}"
|
||||
@@ -1,50 +1,64 @@
|
||||
# Compose and publish the bundle manifest.
|
||||
# Compose and publish the bundle manifests.
|
||||
#
|
||||
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3).
|
||||
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3), extended by
|
||||
# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.2) to a second game.
|
||||
#
|
||||
# ── What a bundle is ─────────────────────────────────────────────────────────
|
||||
# The bundle IS the compat matrix. The installer does not hardcode component
|
||||
# versions and does not resolve "latest" at run time; it fetches one small JSON
|
||||
# document naming an exact, protocol-checked combination of a uo-link release
|
||||
# and a servuo-plugins overlay release, and installs that. Because the bundle is
|
||||
# document naming an exact, protocol-checked combination of a sidecar release
|
||||
# and a game-side payload release, and installs that. Because the bundle is
|
||||
# data, a new sidecar release regenerates ~30 lines of JSON and leaves the
|
||||
# installer binary untouched: operators do not re-download the installer to pick
|
||||
# up a sidecar patch, and this repo does not accumulate releases whose code is
|
||||
# byte-identical.
|
||||
#
|
||||
# ── Where it is published, and why not as a release ──────────────────────────
|
||||
# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root:
|
||||
# ── The streams ──────────────────────────────────────────────────────────────
|
||||
# Bundles are COMMITTED to this repo, on their own `bundles` branch:
|
||||
#
|
||||
# current.json the bundle the installer uses by default
|
||||
# bundle-<tag>.json every bundle ever published, kept for --bundle
|
||||
# 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
|
||||
#
|
||||
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
|
||||
# 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
|
||||
# composed beside it because every installer in the field reads only the root
|
||||
# `current.json` and refuses any other schema; on 2027-01-01 it stops being
|
||||
# composed and is left frozen, never deleted, so old installers still resolve
|
||||
# and every bundle-<tag>.json stays pinnable. A ServUO matrix carries the same
|
||||
# tag at both schemas.
|
||||
#
|
||||
# The installer's fetches are plain anonymous raw URLs on a public repo:
|
||||
#
|
||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
|
||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
|
||||
#
|
||||
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
|
||||
# the pre-receive hook declines a push from CI, which is not a thing a nightly
|
||||
# cron can resolve. Publishing to a branch of its own keeps everything the
|
||||
# original choice was for — a reviewable diff, a git history of the compat
|
||||
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
|
||||
# exception. The alternative, whitelisting a scheduled job for pushes to the
|
||||
# default branch, buys nothing this does not.
|
||||
# exception.
|
||||
#
|
||||
# The obvious alternative — one Gitea release per bundle — was rejected because
|
||||
# it collides with this repo's own product. release.yml publishes the installer
|
||||
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
|
||||
# newest regardless of kind; interleaving bundle releases would make "latest"
|
||||
# intermittently resolve to a release containing no installer binary. Committing
|
||||
# also gets a reviewable diff and a git history of the compat matrix for free.
|
||||
# intermittently resolve to a release containing no installer binary.
|
||||
#
|
||||
# `main` is never pushed to by this workflow. (release.yml does not push to it
|
||||
# either — it tags and lets the release API do the rest.)
|
||||
#
|
||||
# ── Where the logic lives ────────────────────────────────────────────────────
|
||||
# .gitea/scripts/compose-bundles.sh holds the gates and the documents, so it can
|
||||
# be run by hand against the real release API (or a mock) without a runner. This
|
||||
# file owns what needs the runner: checkout, the stale-component dispatch, and
|
||||
# the push.
|
||||
#
|
||||
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
||||
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
|
||||
# as their final step, so a new release recomposes the
|
||||
# bundle immediately.
|
||||
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
|
||||
# Rust-Plugins' 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
|
||||
# of silently pinning operators to a stale sidecar.
|
||||
@@ -55,11 +69,11 @@
|
||||
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
|
||||
# REGISTRY_USER — Gitea username the token below belongs to
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
|
||||
# on THIS repo (to push the bundle commit) and on
|
||||
# RunicGateway/link + RunicGateway/servuo-plugins (to fire
|
||||
# their release workflows for the stale case below). A token
|
||||
# without the latter degrades to a warning, not a failure —
|
||||
# the bundle it composes is still valid.
|
||||
# on THIS repo (to push the bundle commit) and on the four
|
||||
# component repos (to fire their release workflows for the
|
||||
# stale case below). A token without the latter degrades to
|
||||
# a warning, not a failure — the bundle it composes is still
|
||||
# valid.
|
||||
#
|
||||
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
|
||||
|
||||
@@ -73,7 +87,7 @@ on:
|
||||
|
||||
# Two component releases landing together dispatch this twice. Serialize rather
|
||||
# than cancel: a cancelled run is a bundle that never got composed, and the
|
||||
# second run would otherwise race the first on the push to main.
|
||||
# second run would otherwise race the first on the push.
|
||||
concurrency:
|
||||
group: compose-bundle
|
||||
cancel-in-progress: false
|
||||
@@ -81,13 +95,6 @@ concurrency:
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
REPO: RunicGateway/installer
|
||||
LINK_REPO: RunicGateway/link
|
||||
OVERLAY_REPO: RunicGateway/servuo-plugins
|
||||
# Fixed top-level directory inside the overlay tarball. Deliberately NOT
|
||||
# versioned (servuo-plugins/.gitea/workflows/release.yml) — a versioned prefix
|
||||
# would mean parsing the version out of a path in order to read the manifest
|
||||
# that declares the version.
|
||||
OVERLAY_PREFIX: runicgateway-overlay
|
||||
|
||||
jobs:
|
||||
compose:
|
||||
@@ -104,9 +111,8 @@ jobs:
|
||||
|
||||
# The published bundles live on their own branch (see the header), so they
|
||||
# are materialized into a worktree rather than being part of the checkout.
|
||||
# Everything downstream reads and writes `published/`, which means the
|
||||
# ".2 suffix" scan and the idempotence check both see what is actually
|
||||
# published rather than a stale copy on main.
|
||||
# The compose script reads and writes `published/`, so the ".2 suffix"
|
||||
# scan and the idempotence check both see what is actually published.
|
||||
- name: Materialize the bundles branch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -121,7 +127,7 @@ jobs:
|
||||
if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then
|
||||
git fetch origin bundles
|
||||
git worktree add -B bundles published origin/bundles
|
||||
echo "==> bundles branch: $(ls published/*.json 2>/dev/null | wc -l) published bundle(s)"
|
||||
echo "==> bundles branch: $(find published -name 'bundle-*.json' | wc -l) published bundle(s)"
|
||||
else
|
||||
# First run. A root commit with an empty tree gives the worktree a
|
||||
# branch to sit on without inheriting main's history, which has
|
||||
@@ -140,276 +146,27 @@ jobs:
|
||||
$SUDO apt-get update -qq
|
||||
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
|
||||
|
||||
# ── Resolve the two component releases ───────────────────────────────
|
||||
# Read ANONYMOUSLY, on purpose. These are exactly the requests the shipped
|
||||
# installer makes on an operator's machine, which has no Gitea credentials
|
||||
# (PLAN.md §1: no git and no token on the shard host). Authenticating here
|
||||
# would hide a repo flipped to private until an operator hit it; this way
|
||||
# the visibility regression fails CI instead.
|
||||
- name: Resolve the latest release of each component
|
||||
id: resolve
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p work
|
||||
for pair in "link:${LINK_REPO}" "overlay:${OVERLAY_REPO}"; do
|
||||
KEY="${pair%%:*}"; SLUG="${pair#*:}"
|
||||
curl -sSfL -o "work/${KEY}-release.json" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/releases/latest"
|
||||
TAG="$(jq -r '.tag_name' "work/${KEY}-release.json")"
|
||||
[ -n "$TAG" ] && [ "$TAG" != "null" ] || { echo "::error::${SLUG} has no published release"; exit 1; }
|
||||
echo "${KEY}_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "${KEY}_version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> ${SLUG} latest: ${TAG}"
|
||||
done
|
||||
|
||||
# ── GATE 2 (PLAN.md §7.1): assets exist, checksums match ─────────────
|
||||
# Every asset the bundle will reference is downloaded and verified against
|
||||
# the SHA256SUMS the publishing repo shipped beside it. This is not
|
||||
# ceremony: SHA256SUMS is the trust anchor for these deliberately UNSIGNED
|
||||
# artifacts (PLAN.md §3), and the installer will verify against the hashes
|
||||
# THIS job records. A hash copied from a file nobody checked would make the
|
||||
# whole chain decorative.
|
||||
# ── Resolve, gate and compose every stream ───────────────────────────
|
||||
# Release resolution, gate 2 (every asset present and matching its
|
||||
# SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag
|
||||
# against the payload's declared protocol) and the documents all live in
|
||||
# the script. Each game composes independently: a Rust failure still lets
|
||||
# a ServUO bundle publish, and this step goes red afterwards. A game whose
|
||||
# repos have never released composes nothing, and that is not a failure.
|
||||
#
|
||||
# `sha256sum -c` without --ignore-missing fails when SHA256SUMS names a
|
||||
# file the release does not actually carry. The reverse — an asset with no
|
||||
# SHA256SUMS entry — is checked separately below, because -c would not
|
||||
# notice it.
|
||||
- name: 'Gate 2: download assets and verify checksums'
|
||||
id: assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail() { echo "::error::$*"; exit 1; }
|
||||
|
||||
for KEY in link overlay; do
|
||||
DIR="work/${KEY}"; mkdir -p "$DIR"
|
||||
SUMS_URL="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "work/${KEY}-release.json")"
|
||||
[ -n "$SUMS_URL" ] && [ "$SUMS_URL" != "null" ] \
|
||||
|| fail "${KEY} release has no SHA256SUMS asset — nothing to verify against"
|
||||
curl -sSfL -o "${DIR}/SHA256SUMS" "$SUMS_URL"
|
||||
|
||||
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
|
||||
"work/${KEY}-release.json" > "${DIR}/asset-list.tsv"
|
||||
[ -s "${DIR}/asset-list.tsv" ] || fail "${KEY} release carries no assets besides SHA256SUMS"
|
||||
|
||||
while IFS="$(printf '\t')" read -r NAME URL; do
|
||||
[ -n "$NAME" ] || continue
|
||||
echo " fetching ${KEY}/${NAME}"
|
||||
curl -sSfL -o "${DIR}/${NAME}" "$URL"
|
||||
# An asset absent from SHA256SUMS is unverifiable, and `-c` below
|
||||
# would pass right over it. The `\*?` is not paranoia: sha256sum
|
||||
# marks binary mode by prefixing the path with `*` instead of the
|
||||
# two-space text separator, so a naive match on " ${NAME}" would
|
||||
# miss every entry on a host that defaults to binary mode.
|
||||
grep -qE "[ \t]\*?${NAME}\$" "${DIR}/SHA256SUMS" \
|
||||
|| fail "${KEY} asset ${NAME} has no entry in that release's SHA256SUMS"
|
||||
done < "${DIR}/asset-list.tsv"
|
||||
|
||||
( cd "$DIR" && sha256sum -c SHA256SUMS ) \
|
||||
|| fail "${KEY} assets do not match the SHA256SUMS published with them"
|
||||
echo "==> ${KEY}: all assets present and verified"
|
||||
done
|
||||
|
||||
# Map link's binaries onto platform keys. The pattern is asserted, not
|
||||
# assumed: an unrecognized asset name is a hard failure so that adding
|
||||
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
|
||||
# as a red run, rather than being silently dropped from every bundle.
|
||||
#
|
||||
# linux-aarch64 was recognized here one merge BEFORE link published one
|
||||
# (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules
|
||||
# below being strict in opposite directions: an unknown name fails the
|
||||
# run, and a missing REQUIRED key fails it too. So the name had to be
|
||||
# taught before the release that carried it, and the key could only be
|
||||
# required after — requiring it first would have failed every bundle
|
||||
# for as long as the gap lasted. link v1.1.1 ships the binary, so the
|
||||
# key is now required: a dropped target reddens this job instead of
|
||||
# vanishing from every bundle.
|
||||
: > work/link-platforms.tsv
|
||||
while IFS="$(printf '\t')" read -r NAME URL; do
|
||||
[ -n "$NAME" ] || continue
|
||||
case "$NAME" in
|
||||
*-linux-x86_64) PLAT=linux-x86_64 ;;
|
||||
*-linux-aarch64) PLAT=linux-aarch64 ;;
|
||||
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
|
||||
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
|
||||
esac
|
||||
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
|
||||
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
|
||||
done < work/link/asset-list.tsv
|
||||
for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do
|
||||
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|
||||
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for all three"
|
||||
done
|
||||
|
||||
# The overlay release carries exactly one artifact: the tarball.
|
||||
OVERLAY_COUNT="$(wc -l < work/overlay/asset-list.tsv)"
|
||||
[ "$OVERLAY_COUNT" -eq 1 ] \
|
||||
|| fail "expected exactly 1 overlay asset besides SHA256SUMS, found ${OVERLAY_COUNT}"
|
||||
OVERLAY_NAME="$(cut -f1 work/overlay/asset-list.tsv)"
|
||||
case "$OVERLAY_NAME" in
|
||||
*.tar.gz) ;;
|
||||
*) fail "overlay asset '${OVERLAY_NAME}' is not the .tar.gz the installer expects" ;;
|
||||
esac
|
||||
{
|
||||
echo "overlay_name=${OVERLAY_NAME}"
|
||||
echo "overlay_url=$(cut -f2 work/overlay/asset-list.tsv)"
|
||||
echo "overlay_sha=$(sha256sum "work/overlay/${OVERLAY_NAME}" | cut -d' ' -f1)"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── GATE 1 (PLAN.md §7.1): the two halves speak the same protocol ────
|
||||
# This is the check the whole bundle exists for. The sidecar rejects a
|
||||
# protocol mismatch with 409 rather than mis-parsing, so a mismatched pair
|
||||
# is not a subtle bug — it is a shard that emits into a void. Catching it
|
||||
# here costs one HTTP GET; catching it on an operator's box costs them an
|
||||
# evening.
|
||||
#
|
||||
# The two sides are read from genuinely different places because they ARE
|
||||
# genuinely different:
|
||||
#
|
||||
# overlay — manifest.json inside the tarball. The C# plugin announces no
|
||||
# version on the wire and none is queryable before ServUO
|
||||
# boots (PLAN.md §2.6), so this hand-maintained declaration is
|
||||
# the only statement of it that exists.
|
||||
# sidecar — PROTOCOL_VERSION in sidecar/src/main.rs, read at the RELEASE
|
||||
# TAG. Not from the binary: `--print-config` would answer, but
|
||||
# only for releases from v1.1.0 on (it did not exist before
|
||||
# Phase 0.2), and `--bundle <tag>` has to be able to recompose
|
||||
# an older bundle. Reading the tag the release was built from
|
||||
# works uniformly, needs no execution of a downloaded
|
||||
# artifact, and does not provision a throwaway config and
|
||||
# print its auth token into a CI log.
|
||||
- name: 'Gate 1: sidecar and overlay protocol versions agree'
|
||||
id: protocol
|
||||
run: |
|
||||
set -euo pipefail
|
||||
fail() { echo "::error::$*"; exit 1; }
|
||||
|
||||
LINK_TAG="${{ steps.resolve.outputs.link_tag }}"
|
||||
OVERLAY_TAG="${{ steps.resolve.outputs.overlay_tag }}"
|
||||
|
||||
tar -xzf "work/overlay/${{ steps.assets.outputs.overlay_name }}" -C work
|
||||
MANIFEST="work/${OVERLAY_PREFIX}/manifest.json"
|
||||
[ -f "$MANIFEST" ] || fail "the overlay tarball has no ${OVERLAY_PREFIX}/manifest.json — the installer resolves it at that exact path"
|
||||
|
||||
OVERLAY_PROTOCOL="$(jq -r '.protocol' "$MANIFEST")"
|
||||
OVERLAY_COMMIT="$(jq -r '.commit' "$MANIFEST")"
|
||||
MANIFEST_VERSION="$(jq -r '.version' "$MANIFEST")"
|
||||
# A manifest that disagrees with the tag it shipped under means the
|
||||
# release workflow stamped one version and tagged another; every
|
||||
# downstream record of "what is installed" would then be wrong.
|
||||
[ "$MANIFEST_VERSION" = "${OVERLAY_TAG#v}" ] \
|
||||
|| fail "overlay manifest says version ${MANIFEST_VERSION} but the release is tagged ${OVERLAY_TAG}"
|
||||
|
||||
curl -sSfL -o work/main.rs \
|
||||
"https://${GITEA_HOST}/${LINK_REPO}/raw/tag/${LINK_TAG}/sidecar/src/main.rs"
|
||||
LINK_PROTOCOL="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' work/main.rs | head -1)"
|
||||
# Empty means the constant moved or was renamed. Fail loudly: silently
|
||||
# treating "could not read" as "matches" is how a mismatched pair ships.
|
||||
[ -n "$LINK_PROTOCOL" ] \
|
||||
|| fail "could not read PROTOCOL_VERSION from ${LINK_REPO}@${LINK_TAG}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
|
||||
|
||||
echo "==> sidecar ${LINK_TAG} protocol=${LINK_PROTOCOL} | overlay ${OVERLAY_TAG} protocol=${OVERLAY_PROTOCOL}"
|
||||
[ "$LINK_PROTOCOL" = "$OVERLAY_PROTOCOL" ] || fail \
|
||||
"PROTOCOL MISMATCH — sidecar ${LINK_TAG} speaks ${LINK_PROTOCOL}, overlay ${OVERLAY_TAG} declares ${OVERLAY_PROTOCOL}. Refusing to publish a bundle that would install a shard whose events the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
|
||||
|
||||
{
|
||||
echo "protocol=${LINK_PROTOCOL}"
|
||||
echo "overlay_commit=${OVERLAY_COMMIT}"
|
||||
echo "overlay_min_servuo=$(jq -r '.servuo.min_version' "$MANIFEST")"
|
||||
echo "overlay_patched_against=$(jq -r '.servuo.patches_verified_against' "$MANIFEST")"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ── Compose ──────────────────────────────────────────────────────────
|
||||
# Note the shape difference from PLAN.md §7.1's sketch: `link` carries a
|
||||
# per-platform asset map rather than one sha256. link publishes a Linux
|
||||
# binary and a Windows .exe, and the installer runs on both — a single
|
||||
# hash could only ever have described one of them.
|
||||
- name: Compose bundle.json
|
||||
# The outputs are written even when the script fails, so the publish step
|
||||
# (on `always()`) can still ship the game that did compose.
|
||||
- name: Compose the bundles
|
||||
id: compose
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LINK_ASSETS="$(jq -R -s '
|
||||
split("\n") | map(select(length > 0)) | map(split("\t"))
|
||||
| map({ (.[0]): { name: .[1], url: .[2], sha256: .[3] } }) | add
|
||||
' work/link-platforms.tsv)"
|
||||
|
||||
jq -n \
|
||||
--arg link_repo "${LINK_REPO}" \
|
||||
--arg link_tag "${{ steps.resolve.outputs.link_tag }}" \
|
||||
--arg link_version "${{ steps.resolve.outputs.link_version }}" \
|
||||
--argjson link_assets "${LINK_ASSETS}" \
|
||||
--arg ov_repo "${OVERLAY_REPO}" \
|
||||
--arg ov_tag "${{ steps.resolve.outputs.overlay_tag }}" \
|
||||
--arg ov_version "${{ steps.resolve.outputs.overlay_version }}" \
|
||||
--arg ov_commit "${{ steps.protocol.outputs.overlay_commit }}" \
|
||||
--arg ov_name "${{ steps.assets.outputs.overlay_name }}" \
|
||||
--arg ov_url "${{ steps.assets.outputs.overlay_url }}" \
|
||||
--arg ov_sha "${{ steps.assets.outputs.overlay_sha }}" \
|
||||
--arg ov_min "${{ steps.protocol.outputs.overlay_min_servuo }}" \
|
||||
--arg ov_patched "${{ steps.protocol.outputs.overlay_patched_against }}" \
|
||||
--argjson protocol "${{ steps.protocol.outputs.protocol }}" \
|
||||
'{
|
||||
schema: 1,
|
||||
protocol: $protocol,
|
||||
link: {
|
||||
repo: $link_repo,
|
||||
tag: $link_tag,
|
||||
version: $link_version,
|
||||
protocol: $protocol,
|
||||
assets: $link_assets
|
||||
},
|
||||
overlay: {
|
||||
repo: $ov_repo,
|
||||
tag: $ov_tag,
|
||||
version: $ov_version,
|
||||
commit: $ov_commit,
|
||||
protocol: $protocol,
|
||||
servuo: {
|
||||
min_version: $ov_min,
|
||||
patches_verified_against: $ov_patched
|
||||
},
|
||||
asset: { name: $ov_name, url: $ov_url, sha256: $ov_sha }
|
||||
}
|
||||
}' > work/content.json
|
||||
|
||||
echo "----- composed content -----"
|
||||
cat work/content.json
|
||||
|
||||
# Idempotence. `bundle` and `generated` are metadata ABOUT this run, so
|
||||
# comparing them would make every nightly cron look like a change and
|
||||
# commit a dated duplicate of the same matrix forever. Compare only
|
||||
# what the installer would actually act on.
|
||||
CHANGED=true
|
||||
if [ -f published/current.json ]; then
|
||||
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
|
||||
&& jq -S '.' work/content.json > work/new-content.json \
|
||||
&& cmp -s work/old-content.json work/new-content.json; then
|
||||
CHANGED=false
|
||||
fi
|
||||
rc=0
|
||||
PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \
|
||||
bash .gitea/scripts/compose-bundles.sh || rc=$?
|
||||
if [ -f work/result.env ]; then
|
||||
grep '^changed=' work/result.env >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$CHANGED" = false ]; then
|
||||
echo "==> identical to the published current.json — nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Bundle tags are dates (PLAN.md §7.1). Two bundles on one day — a
|
||||
# sidecar release in the morning and an overlay release in the
|
||||
# afternoon is the normal way that happens — get .2, .3, … so a tag
|
||||
# always names exactly one matrix and `--bundle` stays reproducible.
|
||||
BASE="$(date -u +%Y.%m.%d)"
|
||||
TAG="$BASE"; N=1
|
||||
while [ -f "published/bundle-${TAG}.json" ]; do
|
||||
N=$((N+1)); TAG="${BASE}.${N}"
|
||||
done
|
||||
|
||||
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
|
||||
work/content.json > "published/bundle-${TAG}.json"
|
||||
cp "published/bundle-${TAG}.json" published/current.json
|
||||
|
||||
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> composed bundle ${TAG}"
|
||||
exit "$rc"
|
||||
|
||||
# ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ───────
|
||||
# Each component self-releases on merge to its own main, so by the time
|
||||
@@ -424,10 +181,14 @@ jobs:
|
||||
# counts would report every README fix as a stuck release and re-dispatch
|
||||
# a workflow that correctly declines to run, every single night.
|
||||
#
|
||||
# This runs even when the bundle is unchanged: an unchanged bundle is the
|
||||
# exact symptom of a component release that never happened.
|
||||
# This runs even when the bundle is unchanged, or a game failed: an
|
||||
# unchanged bundle is the exact symptom of a component release that never
|
||||
# happened. work/stale.tsv names each component the compose resolved; a
|
||||
# repo that has never released is absent from it, and has no release to
|
||||
# be stale against.
|
||||
- name: Check for components with unreleased work, and dispatch them
|
||||
id: stale
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -440,11 +201,12 @@ jobs:
|
||||
# message change what that script does. (Do not write that token
|
||||
# literally in a comment: the runner parses it, fails, and silently
|
||||
# skips the whole step.)
|
||||
mkdir -p work
|
||||
: > work/stale-warnings.md
|
||||
[ -f work/stale.tsv ] || exit 0
|
||||
|
||||
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
|
||||
"${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do
|
||||
SLUG="${pair%:*}"; TAG="${pair##*:}"
|
||||
while IFS="$(printf '\t')" read -r SLUG TAG; do
|
||||
[ -n "$SLUG" ] || continue
|
||||
if ! curl -sSfL -o work/compare.json \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
|
||||
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
|
||||
@@ -483,7 +245,7 @@ jobs:
|
||||
else
|
||||
echo "==> ${SLUG}: nothing releasable after ${TAG}"
|
||||
fi
|
||||
done
|
||||
done < work/stale.tsv
|
||||
|
||||
# ── Publish ──────────────────────────────────────────────────────────
|
||||
# Preflighted for the same reason the release workflows are: actions/
|
||||
@@ -491,8 +253,11 @@ jobs:
|
||||
# config, so a push can succeed on that leftover even with the secrets
|
||||
# empty. That makes "the push worked" no evidence at all that the repo is
|
||||
# configured, and the failure surfaces somewhere less obvious later.
|
||||
#
|
||||
# `always()`: when one game failed and the other composed, the one that
|
||||
# composed still ships. The compose step has already made the run red.
|
||||
- name: Verify publish credentials are configured
|
||||
if: ${{ steps.compose.outputs.changed == 'true' }}
|
||||
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
@@ -502,19 +267,18 @@ jobs:
|
||||
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
|
||||
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit to main."
|
||||
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "Publish credentials present."
|
||||
|
||||
- name: Commit and push the bundle
|
||||
if: ${{ steps.compose.outputs.changed == 'true' }}
|
||||
- name: Commit and push the bundles
|
||||
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.compose.outputs.bundle_tag }}"
|
||||
# Secrets can arrive with a trailing newline depending on how they were
|
||||
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
|
||||
# cannot be parsed").
|
||||
@@ -522,9 +286,14 @@ jobs:
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||
|
||||
# The subject is the script's ("publish servuo 2026.09.27, rust
|
||||
# 2026.09.27 [skip ci]"), read from a file rather than interpolated
|
||||
# from a template value, so nothing in it can change this script.
|
||||
grep '^commit_subject=' work/result.env | cut -d= -f2- > work/commit-msg.txt
|
||||
|
||||
cd published
|
||||
git add -A
|
||||
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
|
||||
git commit -F ../work/commit-msg.txt
|
||||
|
||||
# Two runs can compose at once — a component release dispatches this
|
||||
# while the nightly cron is mid-flight — so losing the race is normal
|
||||
@@ -538,7 +307,8 @@ jobs:
|
||||
git rebase origin/bundles
|
||||
git push origin bundles
|
||||
fi
|
||||
echo "==> published bundle-${TAG}.json and current.json on the bundles branch"
|
||||
echo "==> published on the bundles branch:"
|
||||
sed 's/^/ /' ../work/published.txt
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
@@ -547,16 +317,17 @@ jobs:
|
||||
{
|
||||
echo "## Bundle compose"
|
||||
echo
|
||||
echo "| Component | Release | Protocol |"
|
||||
echo "|---|---|---|"
|
||||
echo "| uo-link sidecar | \`${{ steps.resolve.outputs.link_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
|
||||
echo "| servuo-plugins overlay | \`${{ steps.resolve.outputs.overlay_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
|
||||
echo
|
||||
case "${{ steps.compose.outputs.changed }}" in
|
||||
true) echo "**Published \`${{ steps.compose.outputs.bundle_tag }}\`** → \`bundles/current.json\`" ;;
|
||||
false) echo "No change — \`bundles/current.json\` already names this combination." ;;
|
||||
*) echo "Compose did not complete — see the failing step above." ;;
|
||||
esac
|
||||
if [ -s work/summary.md ]; then
|
||||
cat work/summary.md
|
||||
else
|
||||
echo "Compose did not complete — see the failing step above."
|
||||
fi
|
||||
if [ -s work/published.txt ]; then
|
||||
echo
|
||||
echo "Written to the bundles branch:"
|
||||
echo
|
||||
sed 's/^/- /' work/published.txt
|
||||
fi
|
||||
if [ -s work/stale-warnings.md ]; then
|
||||
echo
|
||||
echo "### ⚠ Components with unreleased work"
|
||||
|
||||
@@ -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:
|
||||
@@ -165,6 +169,39 @@ jobs:
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Orphan sweep ────────────────────────────────────────────────
|
||||
#
|
||||
# The check above is VERSION-SCOPED: it only ever asks about the one
|
||||
# version this run computed. That is enough to recover an orphan on
|
||||
# the very next run, and useless afterwards — once any releasable
|
||||
# commit lands, the next run computes a NEW version, never looks at
|
||||
# the old tag again, and the orphan becomes permanent and silent.
|
||||
#
|
||||
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
|
||||
# commit that ADDED the recovery above was itself typed
|
||||
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
|
||||
# v0.1.1 — and the run that introduced the recovery stepped straight
|
||||
# past the tag it was written to rescue. That tag is still orphaned.
|
||||
#
|
||||
# So every v* tag is checked, and anything missing a release is
|
||||
# WARNED about. Deliberately not recovered: publishing an old version
|
||||
# would mean building today's tree and shipping it under a tag whose
|
||||
# tree it is not, which is worse than the inconsistency it fixes.
|
||||
# A human decides whether to recover or drop it.
|
||||
#
|
||||
# Never fails the run. A sweep that can break a good release is a
|
||||
# sweep someone will delete.
|
||||
ORPHANS=""
|
||||
for T in $(git tag -l 'v*' --sort=-v:refname); do
|
||||
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
|
||||
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
|
||||
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
|
||||
done
|
||||
if [ -n "${ORPHANS}" ]; then
|
||||
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
|
||||
fi
|
||||
|
||||
# Changelog range. A recovery run has nothing after the tag, so
|
||||
# summarize what the tag itself contains rather than emitting an empty
|
||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||
@@ -247,8 +284,14 @@ jobs:
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# `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 curl ca-certificates git jq
|
||||
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 \
|
||||
@@ -257,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}"
|
||||
|
||||
@@ -284,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' }}
|
||||
@@ -296,6 +340,14 @@ jobs:
|
||||
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
||||
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
|
||||
|
||||
# The installer has to run wherever the sidecar it installs can run, and link publishes an
|
||||
# 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, static musl)
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
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),
|
||||
# so it ships with every release and the docs lead with the verify command.
|
||||
@@ -306,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.
|
||||
@@ -378,17 +437,74 @@ jobs:
|
||||
# corrupt the Authorization header.
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
|
||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
||||
| jq -r '.id')"
|
||||
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
|
||||
|
||||
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
|
||||
# second after the tag push and Gitea answered 500, having not finished
|
||||
# processing the pushed tag. Re-running the workflow published the same
|
||||
# four assets untouched, so the failure was a race, not a bad request.
|
||||
#
|
||||
# Two things went wrong there, and both are fixed here.
|
||||
#
|
||||
# 1. `curl -sSf` prints NO response body on an error status, so all the
|
||||
# log carried was "curl: (22) ... error: 500" and the cause had to be
|
||||
# inferred from timestamps. Capture the body and print it.
|
||||
# 2. Nothing retried, so a transient 5xx became a permanent orphan tag.
|
||||
# The plan step CAN recover one, but only on a run that reaches it --
|
||||
# and a later push with no releasable commits stands down before it
|
||||
# gets there, so in practice the tag sits until a human notices.
|
||||
#
|
||||
# 4xx is deliberately NOT retried: a bad token or a malformed body does
|
||||
# not improve by being sent again, and retrying only turns a clear
|
||||
# failure into a slow one.
|
||||
REL_ID=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" || echo 000)"
|
||||
|
||||
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
|
||||
REL_ID="$(jq -r '.id' /tmp/rel.json)"
|
||||
break
|
||||
fi
|
||||
|
||||
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
|
||||
echo "--- response body ---"
|
||||
cat /tmp/rel.json || true
|
||||
echo
|
||||
echo "---------------------"
|
||||
|
||||
case "$HTTP" in
|
||||
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "$attempt" = 5 ]; then
|
||||
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
|
||||
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
|
||||
exit 1
|
||||
fi
|
||||
sleep $(( attempt * 5 ))
|
||||
done
|
||||
|
||||
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
|
||||
echo "::error::Release created but no id came back; refusing to upload assets blind."
|
||||
exit 1
|
||||
fi
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
||||
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
# Same treatment. An upload that fails quietly leaves a release whose
|
||||
# SHA256SUMS does not cover every binary it advertises, which is worse
|
||||
# than no release at all -- that file IS the trust anchor.
|
||||
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
-F "attachment=@dist/${f}" || echo 000)"
|
||||
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
|
||||
echo "::error::uploading ${f} returned HTTP ${HTTP}"
|
||||
cat /tmp/asset.json || true
|
||||
exit 1
|
||||
fi
|
||||
echo " uploaded ${f}"
|
||||
done
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -402,6 +402,7 @@ name = "runicgateway-installer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"serde",
|
||||
|
||||
@@ -45,6 +45,10 @@ sha2 = "0.11"
|
||||
# host, which is the whole point of shipping the plugin as a release tarball.
|
||||
sha1 = "0.11"
|
||||
|
||||
# Schema-2 bundles are read through Gitea's contents API, which returns the file
|
||||
# base64-encoded — the /raw/ route is CDN-cached for hours (bundle.rs). Pure Rust.
|
||||
base64 = "0.22"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
|
||||
111
README.md
111
README.md
@@ -25,9 +25,48 @@ It also **does not replace ServUO startup behavior.** ServUO keeps running throu
|
||||
its existing release/start scripts; the installer never writes a launcher and
|
||||
never restarts the shard.
|
||||
|
||||
## Install a shard with it
|
||||
|
||||
Grab a binary and `SHA256SUMS` from the
|
||||
[releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
|
||||
verify the checksum, and run it as Administrator/root against a **stopped** shard:
|
||||
|
||||
```bash
|
||||
sha256sum -c SHA256SUMS --ignore-missing
|
||||
chmod +x runicgateway-installer-linux-x86_64
|
||||
sudo ./runicgateway-installer-linux-x86_64 install
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows, from an elevated PowerShell
|
||||
.\runicgateway-installer-windows-x86_64.exe install
|
||||
```
|
||||
|
||||
It ends by printing the four values to paste into **Admin → Shard** on your site.
|
||||
The full operator guide — what it asks, where it writes, the patch tier, day-two
|
||||
commands and troubleshooting — is
|
||||
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
|
||||
|
||||
Prefer to place everything yourself, or on a host that cannot run the binary?
|
||||
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||
is the same deployment done with `curl`, `tar` and `systemctl`, and stays
|
||||
supported.
|
||||
|
||||
## Status
|
||||
|
||||
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
|
||||
**Released.** All five phases are built and the `edge → main` cutover (#17) cut
|
||||
the first release, [`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
|
||||
publishing `linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` with
|
||||
`SHA256SUMS`.
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| 0 — prerequisites in the other repos | ✅ merged |
|
||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ released |
|
||||
| 2 — uo-link install + service registration | ✅ released |
|
||||
| 3 — the opt-in stock-file patch tier | ✅ released |
|
||||
| 4 — `doctor`, `update`, `uninstall` | ✅ released |
|
||||
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | ✅ released |
|
||||
|
||||
The binary does everything
|
||||
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
||||
@@ -40,39 +79,18 @@ The design of record is
|
||||
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
|
||||
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
|
||||
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
||||
sidecar + overlay combination, recomposed on every component release and nightly
|
||||
(see [`bundles/README.md`](bundles/README.md)).
|
||||
[`bundles/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json)
|
||||
names the current protocol-checked sidecar + overlay combination, recomposed on
|
||||
every component release and nightly (see [`bundles/README.md`](bundles/README.md)).
|
||||
|
||||
| Phase | State |
|
||||
|---|---|
|
||||
| 0 — prerequisites in the other repos | ✅ merged |
|
||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||
| 2 — uo-link install + service registration | ✅ on `edge` |
|
||||
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
|
||||
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
|
||||
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
|
||||
|
||||
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||
`main`, so nothing lands there until the whole tool is worth handing to an
|
||||
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
|
||||
the same gates as PRs into `main`.
|
||||
|
||||
**What the cutover is waiting on**, per PLAN.md §5:
|
||||
|
||||
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
|
||||
than after it, because it changes the release layout, and shipping first would
|
||||
mean a first release immediately superseded by the next. There is no `.deb`
|
||||
and no MSI: both would give the sidecar binary, its service unit and its
|
||||
service account a second owner beside this tool.
|
||||
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
|
||||
service account, the failure actions and the token-file ACL have never been
|
||||
executed anywhere. Running the *systemd* half for real is what turned up a bug
|
||||
no unit test had, so this is not a formality.
|
||||
|
||||
Until the cutover, the way to install is by hand —
|
||||
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
|
||||
is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
**`main` publishes.** `release.yml` cuts a release from every push to `main`, which
|
||||
is why the crate was integrated on `edge` until it was worth handing to an
|
||||
operator. Both cutover gates were met first: Phase 5 (its scope settled as **no
|
||||
`.deb` and no MSI** — either would give the sidecar binary, its service unit and
|
||||
its service account a second owner beside this tool), and the **Windows SCM half
|
||||
verified on a real host**. That second one earned its place: `sc start` failed
|
||||
with 1053 on its first real run and needed a sidecar fix (link#29) before it
|
||||
passed 13/13.
|
||||
|
||||
## Related repos
|
||||
|
||||
@@ -93,6 +111,33 @@ is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
| `update` | Resolve the current bundle manifest, then update the sidecar (replace + restart) and the overlay (re-sync + tell the operator to restart ServUO). |
|
||||
| `uninstall` | Remove only what the installer exclusively owns. It **never edits the ServUO tree** — it prints the overlay files to delete and the patch hunks to revert, and leaves that call to the operator. |
|
||||
|
||||
### Rust: `--game rust`
|
||||
|
||||
The same four verbs set up **Rust servers** (Oxide or Carbon) from a schema-2 Rust bundle — a
|
||||
Rust-Link sidecar and a Rust-Plugins plugin, checked by CI to speak one protocol. A Rust host
|
||||
commonly runs several servers, so each is a **named instance** with its own service, config,
|
||||
database and ports; every instance shares one sidecar binary, and `update` moves them together.
|
||||
|
||||
```bash
|
||||
sudo ./runicgateway-installer-linux-x86_64 install --game rust --rust /srv/rust --server-id main
|
||||
```
|
||||
|
||||
- The framework is **detected**, never asked, and the plugin goes to `oxide/plugins/` or
|
||||
`carbon/plugins/`. A tree with both, or neither, is refused.
|
||||
- `--server-id` is the id the website knows the server by. Where the plugin has no config yet the
|
||||
installer writes one holding just `ServerId` and `Port`; an existing one is never rewritten, and
|
||||
one naming another server refuses the run.
|
||||
- Services: `runicgateway-rust@<id>.service` (a systemd template) or `RunicGatewayRust-<id>`.
|
||||
Records: `rust/install.json`, beside and separate from ServUO's.
|
||||
- `doctor` checks the framework, the plugin file, the plugin config's `ServerId`, the required uMod
|
||||
plugins (reported, never installed), the service, and `/health` through to *plugin connected*.
|
||||
- `uninstall` removes the plugin file and keeps its config — that file is the website's.
|
||||
|
||||
A Pterodactyl panel uses the **egg** instead, published with each
|
||||
[Rust-Link release](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases). The Rust
|
||||
operator guide is
|
||||
[`rust-link/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md).
|
||||
|
||||
## Design constraints worth knowing up front
|
||||
|
||||
- **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and
|
||||
|
||||
@@ -3,26 +3,45 @@
|
||||
**These files are generated. Do not edit them by hand.**
|
||||
|
||||
A *bundle* names one exact, protocol-checked combination of the two components the installer
|
||||
deploys — a `uo-link` sidecar release and a `servuo-plugins` overlay release. The installer does not
|
||||
deploys for one game: a sidecar release and a game-side payload release. For ServUO that is `link`
|
||||
and a `servuo-plugins` overlay; for Rust it is `Rust-Link` and a `Rust-Plugins` plugin. The installer does not
|
||||
hardcode versions and does not resolve "latest" at run time; it fetches one of these documents and
|
||||
installs what it names. **The bundle is the compat matrix.**
|
||||
|
||||
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml), which composes
|
||||
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml) (the logic is
|
||||
in [`.gitea/scripts/compose-bundles.sh`](../.gitea/scripts/compose-bundles.sh)), which composes
|
||||
one whenever a component publishes a release (dispatched by that release's own workflow) and
|
||||
nightly, so a missed dispatch self-heals. A run that finds nothing changed writes nothing.
|
||||
|
||||
See `docs/installer/PLAN.md` §7 for the design.
|
||||
See `docs/installer/PLAN.md` §7 for the design, and `docs/modules/rust/PLAN.md` §34.2.2 for
|
||||
schema 2.
|
||||
|
||||
## Where they live: the `bundles` branch
|
||||
|
||||
**The JSON documents are not in this directory.** They are published to a branch of their own,
|
||||
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles), at its root:
|
||||
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles). There is one
|
||||
stream per game and schema, each a directory holding the same two kinds of file:
|
||||
|
||||
| Directory | Stream |
|
||||
|---|---|
|
||||
| *(root)* | Schema 1, ServUO. What every installer up to v0.2.x reads. **Retires 2027-01-01** |
|
||||
| `v2/servuo/` | Schema 2, `"game": "servuo"` |
|
||||
| `v2/rust/` | Schema 2, `"game": "rust"` |
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `current.json` | The bundle the installer uses by default. Always a copy of the newest `bundle-*.json`. |
|
||||
| `bundle-<tag>.json` | Every bundle ever published, kept forever so `--bundle <tag>` stays reproducible. |
|
||||
|
||||
**Schema 1 retires on 2027-01-01.** Until then it is composed exactly as before, beside schema 2, so
|
||||
every installer already in the field keeps updating. After that date it stops being composed and is
|
||||
left **frozen at its last bundle, never deleted**: an old installer still resolves something, and
|
||||
every `bundle-<tag>.json` stays pinnable. A ServUO matrix carries the **same tag** at both schemas.
|
||||
|
||||
A schema-2 document names one game rather than both, because the games release on their own
|
||||
schedules: a document naming both would hand a ServUO host a new bundle every time a Rust plugin
|
||||
shipped. A game none of whose repos has released composes nothing, and that is not a failure.
|
||||
|
||||
Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar release in the
|
||||
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
|
||||
exactly one matrix.
|
||||
@@ -45,6 +64,7 @@ Plain anonymous `GET`s against a public repo. The shard host gets no git and no
|
||||
```
|
||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
|
||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
|
||||
```
|
||||
|
||||
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
|
||||
@@ -52,7 +72,7 @@ the installer binaries, and `/releases/latest` returns whichever release is newe
|
||||
kind — interleaving the two would make "latest" intermittently resolve to a release containing no
|
||||
installer binary.
|
||||
|
||||
## Schema
|
||||
## Schema 1 (ServUO, retiring)
|
||||
|
||||
`schema` is the version of *this document's* shape, and is unrelated to `protocol` (the uo-link wire
|
||||
protocol) or to either component's release version. All three move independently.
|
||||
@@ -104,3 +124,48 @@ rejects a protocol mismatch with `409` rather than mis-parsing, so a mismatched
|
||||
emitting into a void. CI refuses to publish one: it reads `PROTOCOL_VERSION` from the sidecar's
|
||||
source at its release tag and the declared `protocol` from the overlay tarball's `manifest.json`, and
|
||||
fails if they differ. The top-level `protocol` is that agreed value.
|
||||
|
||||
## Schema 2
|
||||
|
||||
The same document for either game: a `game` discriminant, a `sidecar`, and a `payload` whose `kind`
|
||||
says what it is. A reader switches on `game`, and refuses a `schema` it does not know.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"schema": 2,
|
||||
"game": "rust", // or "servuo"
|
||||
"bundle": "2026.09.27",
|
||||
"generated": "2026-09-27T…Z",
|
||||
"protocol": 12,
|
||||
|
||||
"sidecar": {
|
||||
"repo": "RunicGateway/Rust-Link", "tag": "v…", "version": "…", "protocol": 12,
|
||||
"assets": { // Rust: no linux-aarch64 — RustDedicated has no arm64 build
|
||||
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
|
||||
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
|
||||
},
|
||||
"launcher": { "name": "with-sidecar.sh", "url": "…", "sha256": "…" } // Rust only: the egg's startup
|
||||
},
|
||||
|
||||
"payload": {
|
||||
"kind": "plugin", // "overlay" for ServUO
|
||||
"repo": "RunicGateway/Rust-Plugins", "tag": "v…", "version": "…", "commit": "…",
|
||||
"protocol": 12,
|
||||
"compat": { // per game; for ServUO it is schema 1's `servuo` block
|
||||
"frameworks": { "oxide": { "min_version": "2.0.7585" },
|
||||
"carbon": { "min_version": "2.0.259" } },
|
||||
"requires_plugins": ["Kits", "ZoneManager"]
|
||||
},
|
||||
"asset": { "name": "runicgateway-rust-plugin-….tar.gz", "url": "…", "sha256": "…" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A ServUO schema-2 document is schema 1's content re-shaped: `link` becomes `sidecar` (no
|
||||
`launcher`), `overlay` becomes a `payload` of `"kind": "overlay"`, and its `servuo` block becomes
|
||||
`compat` (`min_version`, `patches_verified_against`).
|
||||
|
||||
The two gates hold for both games. For Rust, gate 1 reads `PROTOCOL_VERSION` from `Rust-Link`'s
|
||||
`sidecar/src/main.rs` at its release tag and `protocol` from the `manifest.json` inside the plugin
|
||||
tarball (`runicgateway-rust-plugin/manifest.json`). The stakes are higher there than for ServUO: the
|
||||
Rust game link has no `409`, so a mismatched plugin would mis-parse rather than be refused.
|
||||
|
||||
@@ -3,10 +3,27 @@
|
||||
//! ## Scoped by what cannot be fetched again
|
||||
//!
|
||||
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
|
||||
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
|
||||
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
|
||||
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
|
||||
//! free: it would bury the two things that matter.
|
||||
//! re-downloadable and hash-named in the bundle, and the sidecar's database is overwhelmingly a
|
||||
//! projection of shard state that the sweeps repopulate. Backing it up would be bulk with little
|
||||
//! recovery value, and the bulk is not free: it would bury the two things that matter.
|
||||
//!
|
||||
//! That reasoning used to be stated two ways that are no longer true, and the correction is worth
|
||||
//! keeping rather than quietly deleting:
|
||||
//!
|
||||
//! - It said the database is safe because `store.rs` creates every table `IF NOT EXISTS`. That held
|
||||
//! only while every schema change added a whole *table*. Protocol 4 adds a *column* to a table
|
||||
//! that already exists, which `IF NOT EXISTS` cannot do, so `link` now carries a real migration
|
||||
//! (`PRAGMA user_version` steps). A run can therefore change the database's structure, not just
|
||||
//! its contents.
|
||||
//! - It said every table holds state the sweeps repopulate. `events` does not: it is never pruned,
|
||||
//! and the website backfills the events it missed from `GET /history` on every reconnect. So a
|
||||
//! lost database costs the gap-recovery window for anything that happened while the site was down.
|
||||
//!
|
||||
//! The decision is unchanged — this still does not copy the database — because the argument against
|
||||
//! backing up unbounded bulk survives both corrections: `events` grows without limit, the migration
|
||||
//! is transactional and additive, and the website holds its own durable copy of everything it has
|
||||
//! already ingested. Only the *reason* was wrong. Whether that table should be pruned or protected
|
||||
//! is a question for `link`, on its own merits, not something to settle inside a backup policy.
|
||||
//!
|
||||
//! What a run can destroy irrecoverably is short:
|
||||
//!
|
||||
|
||||
420
src/bundle.rs
420
src/bundle.rs
@@ -14,6 +14,7 @@
|
||||
//! half-understood.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -27,9 +28,6 @@ use std::collections::BTreeMap;
|
||||
const BUNDLE_BASE: &str =
|
||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
|
||||
|
||||
/// The only `schema` this build understands.
|
||||
const SUPPORTED_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Bundle {
|
||||
pub schema: u32,
|
||||
@@ -126,7 +124,10 @@ pub fn platform_key() -> Result<&'static str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
|
||||
/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle <tag>` pins it.
|
||||
///
|
||||
/// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md
|
||||
/// §34.4). This build reads it only as a fallback — see [`fetch`].
|
||||
pub fn url_for(tag: Option<&str>) -> String {
|
||||
match tag {
|
||||
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
|
||||
@@ -134,33 +135,137 @@ pub fn url_for(tag: Option<&str>) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and validates a bundle.
|
||||
/// Which game a schema-2 bundle describes, and so which stream it is read from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Game {
|
||||
ServUo,
|
||||
Rust,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
/// The `game` discriminant in the document, and the directory under `v2/`.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::ServUo => "servuo",
|
||||
Self::Rust => "rust",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Gitea **contents** API for the `bundles` branch.
|
||||
///
|
||||
/// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with
|
||||
/// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the
|
||||
/// branch — an `install` run right after a release would install the release before it. The
|
||||
/// contents API is `private, must-revalidate` and always answers with the branch as it is. It
|
||||
/// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps.
|
||||
const CONTENTS_BASE: &str =
|
||||
"https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents";
|
||||
|
||||
/// The file name of a bundle in its stream.
|
||||
fn file_name(tag: Option<&str>) -> String {
|
||||
match tag {
|
||||
Some(tag) => format!("bundle-{tag}.json"),
|
||||
None => "current.json".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a person opens to read a schema-2 bundle — what `install.json` records. The same
|
||||
/// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens.
|
||||
pub fn v2_url(game: Game, tag: Option<&str>) -> String {
|
||||
format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag))
|
||||
}
|
||||
|
||||
/// Fetches a schema-2 document's text from its game's stream.
|
||||
fn fetch_v2(game: Game, tag: Option<&str>) -> Result<String> {
|
||||
let url = format!(
|
||||
"{CONTENTS_BASE}/v2/{}/{}?ref=bundles",
|
||||
game.as_str(),
|
||||
file_name(tag)
|
||||
);
|
||||
let envelope = crate::net::get_text(&url)?;
|
||||
#[derive(Deserialize)]
|
||||
struct Contents {
|
||||
content: String,
|
||||
}
|
||||
let contents: Contents = serde_json::from_str(&envelope)
|
||||
.context("the bundles branch answered with something that is not a file")?;
|
||||
// Gitea wraps the base64 at 76 columns; the decoder wants it whole.
|
||||
let packed: String = contents
|
||||
.content
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(packed)
|
||||
.context("the bundle file is not valid base64")?;
|
||||
String::from_utf8(bytes).context("the bundle file is not UTF-8")
|
||||
}
|
||||
|
||||
/// Fetches and validates a ServUO bundle.
|
||||
///
|
||||
/// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any
|
||||
/// pin published since schema 2 are read from `v2/servuo/`; a `--bundle <tag>` from before it exists
|
||||
/// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody
|
||||
/// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing
|
||||
/// while both are composed and means this build never depends on the new stream alone.
|
||||
///
|
||||
/// The returned URL is the document that was actually used.
|
||||
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
|
||||
let url = url_for(tag);
|
||||
let body = crate::net::get_text(&url).with_context(|| match tag {
|
||||
Some(tag) => format!(
|
||||
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
|
||||
against {BUNDLE_BASE}/"
|
||||
),
|
||||
None => "cannot read the current bundle manifest".to_string(),
|
||||
})?;
|
||||
let v2 = fetch_v2(Game::ServUo, tag);
|
||||
let (body, url) = match v2 {
|
||||
Ok(body) => (body, v2_url(Game::ServUo, tag)),
|
||||
Err(v2_error) => {
|
||||
let url = url_for(tag);
|
||||
let body = crate::net::get_text(&url)
|
||||
.map_err(|_| v2_error)
|
||||
.with_context(|| match tag {
|
||||
Some(tag) => format!(
|
||||
"cannot read bundle {tag}. Every published bundle is kept forever, so \
|
||||
check the tag against {BUNDLE_BASE}/v2/servuo/"
|
||||
),
|
||||
None => "cannot read the current bundle manifest".to_string(),
|
||||
})?;
|
||||
(body, url)
|
||||
}
|
||||
};
|
||||
let bundle = parse(&body)?;
|
||||
Ok((bundle, url))
|
||||
}
|
||||
|
||||
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
|
||||
/// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is
|
||||
/// downloaded.
|
||||
///
|
||||
/// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO
|
||||
/// code path, and `install.json`, already speaks it, and the two documents carry exactly the same
|
||||
/// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO
|
||||
/// deployment therefore records the same thing whichever schema it was resolved from.
|
||||
pub fn parse(body: &str) -> Result<Bundle> {
|
||||
let bundle: Bundle = serde_json::from_str(body)
|
||||
let head: SchemaHead = serde_json::from_str(body)
|
||||
.context("the bundle manifest is not in the shape this installer understands")?;
|
||||
|
||||
if bundle.schema != SUPPORTED_SCHEMA {
|
||||
bail!(
|
||||
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
|
||||
let bundle: Bundle = match head.schema {
|
||||
1 => serde_json::from_str(body)
|
||||
.context("the bundle manifest is not in the shape this installer understands")?,
|
||||
2 => {
|
||||
if head.game.as_deref() != Some(Game::ServUo.as_str()) {
|
||||
bail!(
|
||||
"bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \
|
||||
Rust bundle.",
|
||||
head.bundle,
|
||||
head.game.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
let v2: ServUoBundleV2 = serde_json::from_str(body).context(
|
||||
"the schema-2 ServUO bundle is not in the shape this installer understands",
|
||||
)?;
|
||||
v2.lower()
|
||||
}
|
||||
other => bail!(
|
||||
"bundle {} declares schema {other} and this installer understands 1 and 2. \
|
||||
Update the installer — the bundle format changed.",
|
||||
bundle.bundle,
|
||||
bundle.schema
|
||||
);
|
||||
}
|
||||
head.bundle,
|
||||
),
|
||||
};
|
||||
|
||||
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
|
||||
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
|
||||
@@ -187,6 +292,202 @@ pub fn parse(body: &str) -> Result<Bundle> {
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
/// Just enough of any bundle to decide how to read the rest of it.
|
||||
#[derive(Deserialize)]
|
||||
struct SchemaHead {
|
||||
schema: u32,
|
||||
#[serde(default)]
|
||||
game: Option<String>,
|
||||
#[serde(default)]
|
||||
bundle: String,
|
||||
}
|
||||
|
||||
/// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower).
|
||||
#[derive(Deserialize)]
|
||||
struct ServUoBundleV2 {
|
||||
bundle: String,
|
||||
generated: String,
|
||||
protocol: u32,
|
||||
sidecar: LinkComponent,
|
||||
payload: OverlayPayloadV2,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OverlayPayloadV2 {
|
||||
kind: String,
|
||||
repo: String,
|
||||
tag: String,
|
||||
version: String,
|
||||
commit: String,
|
||||
protocol: u32,
|
||||
compat: ServUoCompat,
|
||||
asset: Asset,
|
||||
}
|
||||
|
||||
impl ServUoBundleV2 {
|
||||
fn lower(self) -> Bundle {
|
||||
// `kind` is informational here: `game` already said ServUO, and a ServUO payload is an
|
||||
// overlay by definition. Kept in the struct so a document missing it fails to parse.
|
||||
let _ = self.payload.kind;
|
||||
Bundle {
|
||||
schema: 2,
|
||||
bundle: self.bundle,
|
||||
generated: self.generated,
|
||||
protocol: self.protocol,
|
||||
link: self.sidecar,
|
||||
overlay: OverlayComponent {
|
||||
repo: self.payload.repo,
|
||||
tag: self.payload.tag,
|
||||
version: self.payload.version,
|
||||
commit: self.payload.commit,
|
||||
protocol: self.payload.protocol,
|
||||
servuo: self.payload.compat,
|
||||
asset: self.payload.asset,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rust ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI
|
||||
/// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RustBundle {
|
||||
pub schema: u32,
|
||||
pub game: String,
|
||||
pub bundle: String,
|
||||
pub generated: String,
|
||||
pub protocol: u32,
|
||||
pub sidecar: RustSidecar,
|
||||
pub payload: PluginPayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RustSidecar {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
/// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149).
|
||||
pub assets: BTreeMap<String, Asset>,
|
||||
/// The egg's launcher. The installer does not use it — a host runs the sidecar as a service —
|
||||
/// but it is part of the pair CI checked, so it is carried rather than dropped.
|
||||
pub launcher: Asset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PluginPayload {
|
||||
pub kind: String,
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
pub commit: String,
|
||||
pub protocol: u32,
|
||||
pub compat: PluginCompat,
|
||||
/// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`.
|
||||
pub asset: Asset,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PluginCompat {
|
||||
/// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by
|
||||
/// `doctor` beside a failure; never measured from a DLL (§34.2.3).
|
||||
pub frameworks: BTreeMap<String, FrameworkFloor>,
|
||||
/// Third-party plugins the features expect. Reported, never installed (D153).
|
||||
pub requires_plugins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FrameworkFloor {
|
||||
pub min_version: String,
|
||||
}
|
||||
|
||||
impl RustBundle {
|
||||
/// The sidecar binary for this host. Resolved before anything is written, like ServUO's.
|
||||
pub fn sidecar_asset(&self) -> Result<&Asset> {
|
||||
let key = platform_key()?;
|
||||
self.sidecar.assets.get(key).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \
|
||||
itself runs on linux-x86_64 and windows-x86_64 only.",
|
||||
self.bundle,
|
||||
self.sidecar
|
||||
.assets
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
|
||||
pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> {
|
||||
let body = fetch_v2(Game::Rust, tag).with_context(|| match tag {
|
||||
Some(tag) => format!(
|
||||
"cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \
|
||||
tag against {BUNDLE_BASE}/v2/rust/"
|
||||
),
|
||||
None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \
|
||||
released, there is none yet."
|
||||
.to_string(),
|
||||
})?;
|
||||
Ok((parse_rust(&body)?, v2_url(Game::Rust, tag)))
|
||||
}
|
||||
|
||||
/// Parses a Rust bundle and applies the checks that must hold before anything is downloaded.
|
||||
pub fn parse_rust(body: &str) -> Result<RustBundle> {
|
||||
let head: SchemaHead = serde_json::from_str(body)
|
||||
.context("the Rust bundle is not in the shape this installer understands")?;
|
||||
if head.schema != 2 {
|
||||
bail!(
|
||||
"Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2",
|
||||
head.bundle,
|
||||
head.schema
|
||||
);
|
||||
}
|
||||
if head.game.as_deref() != Some(Game::Rust.as_str()) {
|
||||
bail!(
|
||||
"bundle {} describes game {:?}, not Rust",
|
||||
head.bundle,
|
||||
head.game.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
let bundle: RustBundle = serde_json::from_str(body)
|
||||
.context("the Rust bundle is not in the shape this installer understands")?;
|
||||
|
||||
// Gate 1 again, for a document that never went through CI. It matters more here than for
|
||||
// ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being
|
||||
// refused.
|
||||
if bundle.sidecar.protocol != bundle.payload.protocol
|
||||
|| bundle.protocol != bundle.sidecar.protocol
|
||||
{
|
||||
bail!(
|
||||
"Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}",
|
||||
bundle.bundle,
|
||||
bundle.protocol,
|
||||
bundle.sidecar.protocol,
|
||||
bundle.payload.protocol
|
||||
);
|
||||
}
|
||||
if bundle.payload.kind != "plugin" {
|
||||
bail!(
|
||||
"Rust bundle {} carries a {:?} payload; this installer deploys a plugin",
|
||||
bundle.bundle,
|
||||
bundle.payload.kind
|
||||
);
|
||||
}
|
||||
if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() {
|
||||
bail!(
|
||||
"Rust bundle {} names a plugin asset with no URL or checksum",
|
||||
bundle.bundle
|
||||
);
|
||||
}
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -262,9 +563,78 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
||||
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
|
||||
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3");
|
||||
let err = parse(&body).unwrap_err().to_string();
|
||||
assert!(err.contains("schema 2"), "{err}");
|
||||
assert!(err.contains("schema 3"), "{err}");
|
||||
}
|
||||
|
||||
/// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as
|
||||
/// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag.
|
||||
const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json");
|
||||
const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json");
|
||||
/// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with
|
||||
/// the mock host's URLs pointed back at Gitea.
|
||||
const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json");
|
||||
|
||||
#[test]
|
||||
fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() {
|
||||
// The lowering is the whole claim that a ServUO host records the same deployment whichever
|
||||
// stream it read. Only `schema` and `generated` describe the document rather than the pair.
|
||||
let mut v2 = parse(V2_SERVUO).unwrap();
|
||||
let mut v1 = parse(V1_SAME).unwrap();
|
||||
assert_eq!(v2.schema, 2);
|
||||
v2.schema = 1;
|
||||
v2.generated.clear();
|
||||
v1.generated.clear();
|
||||
assert_eq!(v2, v1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
|
||||
let err = parse(RUST).unwrap_err().to_string();
|
||||
assert!(err.contains("--game rust"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rust_bundle_parses() {
|
||||
let bundle = parse_rust(RUST).unwrap();
|
||||
assert_eq!(bundle.game, "rust");
|
||||
assert_eq!(bundle.protocol, 12);
|
||||
assert_eq!(bundle.payload.kind, "plugin");
|
||||
assert_eq!(
|
||||
bundle.payload.compat.frameworks["carbon"].min_version,
|
||||
"2.0.259"
|
||||
);
|
||||
assert_eq!(
|
||||
bundle.payload.compat.requires_plugins,
|
||||
["Kits", "ZoneManager"]
|
||||
);
|
||||
assert!(!bundle.sidecar.assets.contains_key("linux-aarch64"));
|
||||
assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_servuo_bundle_is_refused_by_the_rust_reader() {
|
||||
let err = parse_rust(V2_SERVUO).unwrap_err().to_string();
|
||||
assert!(err.contains("not Rust"), "{err}");
|
||||
let err = parse_rust(V1_SAME).unwrap_err().to_string();
|
||||
assert!(err.contains("schema 1"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() {
|
||||
let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap();
|
||||
doc["payload"]["protocol"] = 11.into();
|
||||
let err = parse_rust(&doc.to_string()).unwrap_err().to_string();
|
||||
assert!(err.contains("internally inconsistent"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_2_urls_name_their_game() {
|
||||
assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json"));
|
||||
assert!(
|
||||
v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
141
src/cli.rs
141
src/cli.rs
@@ -56,6 +56,14 @@ pub enum PatchChoice {
|
||||
No,
|
||||
}
|
||||
|
||||
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
|
||||
/// default, so every command line written before Rust existed means exactly what it meant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Game {
|
||||
ServUo,
|
||||
Rust,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
Run(Command),
|
||||
@@ -66,6 +74,15 @@ pub enum Mode {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Cli {
|
||||
pub mode: Mode,
|
||||
/// `--game servuo|rust`.
|
||||
pub game: Game,
|
||||
/// `--rust <path>`: a Rust server root (the directory holding `RustDedicated`).
|
||||
pub rust: Option<String>,
|
||||
/// `--server-id <id>`: names one Rust instance — its service, config, database and ports — and
|
||||
/// is the plugin's `ServerId` (D148).
|
||||
pub server_id: Option<String>,
|
||||
/// `--web-port <port>`: the port the website reaches a Rust instance's sidecar on.
|
||||
pub web_port: Option<u16>,
|
||||
/// `--verify`: report every change that would be made, write nothing.
|
||||
pub verify: bool,
|
||||
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
|
||||
@@ -77,7 +94,7 @@ pub struct Cli {
|
||||
pub patches_unsupported_servuo: bool,
|
||||
/// `--host <name>`: the hostname to print in the website URLs.
|
||||
pub host: Option<String>,
|
||||
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
|
||||
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
|
||||
pub site_url: Option<String>,
|
||||
/// `--yes`: assume the default answer to every prompt.
|
||||
pub assume_yes: bool,
|
||||
@@ -92,6 +109,10 @@ impl Default for Cli {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: Mode::Help,
|
||||
game: Game::ServUo,
|
||||
rust: None,
|
||||
server_id: None,
|
||||
web_port: None,
|
||||
verify: false,
|
||||
servuo: None,
|
||||
bundle: None,
|
||||
@@ -107,20 +128,23 @@ impl Default for Cli {
|
||||
}
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
|
||||
Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic
|
||||
Gateway website.
|
||||
|
||||
Usage: runicgateway-installer <COMMAND> [OPTIONS]
|
||||
|
||||
Commands:
|
||||
install Deploy the plugin overlay, install the uo-link sidecar and its
|
||||
service, record what was deployed, and print the values the
|
||||
website needs.
|
||||
install Deploy the game-side plugin (the ServUO overlay, or the Rust
|
||||
plugin), install the sidecar and its service, record what was
|
||||
deployed, and print the values the website needs.
|
||||
doctor Diagnose an existing deployment end to end.
|
||||
update Re-resolve the bundle and move both components to it.
|
||||
uninstall Remove what the installer exclusively owns. Never edits the
|
||||
ServUO tree — it prints what to remove there.
|
||||
ServUO tree — it prints what to remove there. For Rust it
|
||||
removes only its own plugin file and keeps the plugin config.
|
||||
|
||||
Options:
|
||||
--game <servuo|rust> Which game's shard side. Default servuo.
|
||||
--verify install, update. Dry run: report every
|
||||
change that would be made, write nothing.
|
||||
--servuo <PATH> install, doctor, update. The ServUO root,
|
||||
@@ -137,7 +161,7 @@ Options:
|
||||
--host <NAME> install. The hostname to print in the
|
||||
website URLs.
|
||||
--site-url <URL> install. Your site's base URL, for the
|
||||
Admin → Shard link.
|
||||
Admin → Shard (uo-link) link.
|
||||
--yes Assume the default answer to every prompt.
|
||||
On uninstall it means yes: that prompt
|
||||
defaults to no, and typing `uninstall
|
||||
@@ -149,6 +173,16 @@ Options:
|
||||
--purge uninstall. Also delete sidecar.toml,
|
||||
uo-link.db, the cached patch set and every
|
||||
backup, all of which are otherwise kept.
|
||||
Rust (with --game rust):
|
||||
--rust <PATH> install. The Rust server root — the
|
||||
directory holding RustDedicated. Oxide or
|
||||
Carbon is detected, never asked.
|
||||
--server-id <ID> install: required. doctor, uninstall:
|
||||
one instance instead of all. The id the
|
||||
website knows this server by, and the
|
||||
name of its sidecar's service.
|
||||
--web-port <PORT> install. The sidecar's website port.
|
||||
Default: the first free from 8090.
|
||||
-V, --version Print the installer version and exit.
|
||||
-h, --help Print this help and exit.
|
||||
|
||||
@@ -199,6 +233,30 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
|
||||
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
|
||||
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
|
||||
"--game" => {
|
||||
cli.game = match take_value(&name, inline, &mut it)?.as_str() {
|
||||
"servuo" => Game::ServUo,
|
||||
"rust" => Game::Rust,
|
||||
other => return Err(format!("--game must be servuo or rust, not {other:?}")),
|
||||
}
|
||||
}
|
||||
"--rust" => cli.rust = Some(take_value(&name, inline, &mut it)?),
|
||||
"--server-id" => {
|
||||
let id = take_value(&name, inline, &mut it)?;
|
||||
if !valid_server_id(&id) {
|
||||
return Err(format!(
|
||||
"--server-id {id:?} is not a valid server id: lowercase letters, digits \
|
||||
and '-', 1 to 64 characters, not starting with '-' (the website's rule)"
|
||||
));
|
||||
}
|
||||
cli.server_id = Some(id);
|
||||
}
|
||||
"--web-port" => {
|
||||
let raw = take_value(&name, inline, &mut it)?;
|
||||
cli.web_port = Some(raw.parse::<u16>().ok().filter(|p| *p >= 1024).ok_or_else(
|
||||
|| format!("--web-port must be a port from 1024 to 65535, not {raw:?}"),
|
||||
)?);
|
||||
}
|
||||
other if other.starts_with('-') => {
|
||||
return Err(format!("unrecognized argument: {other}"))
|
||||
}
|
||||
@@ -212,6 +270,20 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// The Rust flags belong to `--game rust` only. Silently ignoring one on a ServUO run would leave
|
||||
// an operator believing they had named a server they had not.
|
||||
if cli.game == Game::ServUo {
|
||||
for (flag, set) in [
|
||||
("--rust", cli.rust.is_some()),
|
||||
("--server-id", cli.server_id.is_some()),
|
||||
("--web-port", cli.web_port.is_some()),
|
||||
] {
|
||||
if set {
|
||||
return Err(format!("{flag} needs --game rust"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match command {
|
||||
Some(c) => cli.mode = Mode::Run(c),
|
||||
// No verb is not an error worth an exit code — it is someone typing the binary's name to
|
||||
@@ -221,6 +293,17 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`. The same rule the plugin and the
|
||||
/// egg apply, so an id that passes here is one all three accept.
|
||||
pub fn valid_server_id(id: &str) -> bool {
|
||||
!id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& !id.starts_with('-')
|
||||
&& id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// Pulls a flag's value, from `--flag=value` or from the next token.
|
||||
///
|
||||
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
|
||||
@@ -339,6 +422,50 @@ mod tests {
|
||||
assert!(parse_str(&["install", "update"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn servuo_stays_the_default_game() {
|
||||
assert_eq!(parse_str(&["install"]).unwrap().game, Game::ServUo);
|
||||
let rust = parse_str(&[
|
||||
"install",
|
||||
"--game",
|
||||
"rust",
|
||||
"--rust",
|
||||
"/srv/rust",
|
||||
"--server-id",
|
||||
"alpha",
|
||||
"--web-port=8091",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(rust.game, Game::Rust);
|
||||
assert_eq!(rust.server_id.as_deref(), Some("alpha"));
|
||||
assert_eq!(rust.web_port, Some(8091));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rust_flag_without_game_rust_is_refused() {
|
||||
for args in [
|
||||
vec!["install", "--rust", "/srv/rust"],
|
||||
vec!["doctor", "--server-id", "alpha"],
|
||||
vec!["install", "--web-port", "8091"],
|
||||
] {
|
||||
let err = parse_str(&args).unwrap_err();
|
||||
assert!(err.contains("--game rust"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_id_follows_the_websites_rule() {
|
||||
for good in ["main", "alpha", "eu-2", "0", &"a".repeat(64)] {
|
||||
assert!(valid_server_id(good), "{good}");
|
||||
}
|
||||
for bad in ["", "-a", "Alpha", "a_b", "a.b", &"a".repeat(65)] {
|
||||
assert!(!valid_server_id(bad), "{bad}");
|
||||
}
|
||||
assert!(parse_str(&["install", "--game", "rust", "--server-id", "Bad"]).is_err());
|
||||
assert!(parse_str(&["install", "--game", "rust", "--web-port", "80"]).is_err());
|
||||
assert!(parse_str(&["install", "--game", "minecraft"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_does_not_matter() {
|
||||
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();
|
||||
|
||||
132
src/doctor.rs
132
src/doctor.rs
@@ -68,7 +68,7 @@ pub struct Row {
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
|
||||
pub(crate) fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
mark,
|
||||
label: label.to_string(),
|
||||
@@ -77,24 +77,24 @@ impl Row {
|
||||
}
|
||||
}
|
||||
|
||||
fn ok(label: &str, detail: impl Into<String>) -> Self {
|
||||
pub(crate) fn ok(label: &str, detail: impl Into<String>) -> Self {
|
||||
Self::new(Mark::Ok, label, detail)
|
||||
}
|
||||
|
||||
fn warn(label: &str, detail: impl Into<String>) -> Self {
|
||||
pub(crate) fn warn(label: &str, detail: impl Into<String>) -> Self {
|
||||
Self::new(Mark::Warn, label, detail)
|
||||
}
|
||||
|
||||
fn fail(label: &str, detail: impl Into<String>) -> Self {
|
||||
pub(crate) fn fail(label: &str, detail: impl Into<String>) -> Self {
|
||||
Self::new(Mark::Fail, label, detail)
|
||||
}
|
||||
|
||||
fn note(mut self, note: impl Into<String>) -> Self {
|
||||
pub(crate) fn note(mut self, note: impl Into<String>) -> Self {
|
||||
self.notes.push(note.into());
|
||||
self
|
||||
}
|
||||
|
||||
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
|
||||
pub(crate) fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
|
||||
self.notes.extend(notes);
|
||||
self
|
||||
}
|
||||
@@ -158,12 +158,22 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
// ── The bundle ───────────────────────────────────────────────────────────
|
||||
rows.push(bundle_row(&record));
|
||||
|
||||
// ── The host ─────────────────────────────────────────────────────────────
|
||||
// Linux only, and absent entirely elsewhere (see `imaging_row`).
|
||||
rows.extend(imaging_row());
|
||||
|
||||
// ── Backups ──────────────────────────────────────────────────────────────
|
||||
rows.push(backup_row(&layout));
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────────
|
||||
Ok(report(&rows))
|
||||
}
|
||||
|
||||
/// Prints the rows and the one-line verdict, and returns the exit code: `1` when anything failed.
|
||||
/// Shared with `doctor --game rust`, so both games' reports read the same way.
|
||||
pub(crate) fn report(rows: &[Row]) -> i32 {
|
||||
println!();
|
||||
for row in &rows {
|
||||
for row in rows {
|
||||
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
|
||||
for note in &row.notes {
|
||||
println!(" {note}");
|
||||
@@ -181,7 +191,11 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
INSTALL.md's Troubleshooting table is keyed to these symptoms."
|
||||
),
|
||||
}
|
||||
Ok(if failed > 0 { 1 } else { 0 })
|
||||
if failed > 0 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// The ServUO root to inspect: `--servuo` if given, else the one the record names.
|
||||
@@ -544,7 +558,7 @@ fn live_config(link: &LinkRecord) -> Option<sidecar::ConfigDoc> {
|
||||
///
|
||||
/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and
|
||||
/// this check is about whether the process on *this* host is answering.
|
||||
fn health_of(bind: &str) -> Option<Health> {
|
||||
pub(crate) fn health_of(bind: &str) -> Option<Health> {
|
||||
let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind);
|
||||
let url = format!("http://127.0.0.1:{port}/health");
|
||||
let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?;
|
||||
@@ -755,6 +769,71 @@ fn bundle_row(record: &InstallRecord) -> Row {
|
||||
Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination")
|
||||
}
|
||||
|
||||
/// `libgdiplus` on a Linux shard host — the one host prerequisite the Asset Bridge added
|
||||
/// (docs/link/v8.md §4.4, docs/link/SHARD_PREREQS.md).
|
||||
///
|
||||
/// ServUO targets `net48`, so on Linux it runs under Mono, and Mono's `System.Drawing` is a thin
|
||||
/// layer over this library — which sits in the **decode** path, not merely the encode: without it
|
||||
/// the shard cannot read a single sprite out of the operator's UO client. Windows hosts ship
|
||||
/// `System.Drawing` with .NET Framework and need nothing, which is why this row exists only on
|
||||
/// Linux rather than reporting "not applicable" on three quarters of the hosts that run it.
|
||||
///
|
||||
/// **A `⚠`, never a `✗`.** Everything else on this plane works without it: the cliloc table and
|
||||
/// the shard's own spawn files have no pixels in them, and a bridge that serves names and an atlas
|
||||
/// but no artwork is a working bridge with one feature missing. It is also not the last word — the
|
||||
/// shard reports `NO_IMAGING` on the asset plane itself, from inside the process that would do the
|
||||
/// decoding. This row exists to move that discovery from "the bestiary is empty, weeks later" to
|
||||
/// "the host is missing a package, now".
|
||||
#[cfg(target_os = "linux")]
|
||||
fn imaging_row() -> Option<Row> {
|
||||
Some(imaging_verdict(imaging_present()))
|
||||
}
|
||||
|
||||
/// Is the library on this host? Two answers, in the order that is most likely to be right.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn imaging_present() -> bool {
|
||||
// `ldconfig -p` is the loader's own cache, which is the same question Mono asks at runtime —
|
||||
// strictly better than probing paths, because a distro that puts the file somewhere unusual has
|
||||
// told the loader about it and would otherwise read here as missing.
|
||||
let cached = crate::util::run("ldconfig", &["-p"])
|
||||
.ok()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains("libgdiplus.so"))
|
||||
.unwrap_or(false);
|
||||
|
||||
// The fallback is for a host with no `ldconfig` on PATH (a slim container, mostly), where a
|
||||
// present library would otherwise be reported absent.
|
||||
cached
|
||||
|| [
|
||||
"/usr/lib/libgdiplus.so",
|
||||
"/usr/lib64/libgdiplus.so",
|
||||
"/usr/lib/x86_64-linux-gnu/libgdiplus.so",
|
||||
"/usr/lib/aarch64-linux-gnu/libgdiplus.so",
|
||||
"/usr/local/lib/libgdiplus.so",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| Path::new(p).exists())
|
||||
}
|
||||
|
||||
/// The operator-visible half, split out so the wording and the mark are testable on a host that
|
||||
/// has the library and on one that does not — which the detection itself is not.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn imaging_verdict(found: bool) -> Row {
|
||||
if found {
|
||||
return Row::ok("Imaging (libgdiplus)", "present");
|
||||
}
|
||||
Row::warn("Imaging (libgdiplus)", "not found on this host")
|
||||
.note("this shard cannot decode artwork out of its UO client — creature portraits and")
|
||||
.note("item pictures will be absent; names and the spawn atlas are unaffected")
|
||||
.note("install it: apt-get install libgdiplus / dnf install libgdiplus")
|
||||
.note("see docs/link/SHARD_PREREQS.md — Windows hosts need nothing")
|
||||
}
|
||||
|
||||
/// Windows and macOS hosts do not need it, so there is no row to print.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn imaging_row() -> Option<Row> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -913,4 +992,39 @@ mod tests {
|
||||
assert_eq!(row.mark, Mark::Ok);
|
||||
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
|
||||
}
|
||||
|
||||
// ── The host row (Linux only; see `imaging_row`) ─────────────────────────
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_missing_libgdiplus_warns_and_names_the_package() {
|
||||
let row = imaging_verdict(false);
|
||||
// A ⚠, never a ✗: the cliloc table and the spawn atlas have no pixels in them, so a host
|
||||
// without this library still runs a useful bridge. `doctor`'s exit code must not turn red
|
||||
// over one absent feature.
|
||||
assert_eq!(row.mark, Mark::Warn);
|
||||
let notes = row.notes.join(" ");
|
||||
assert!(notes.contains("apt-get install libgdiplus"), "{notes}");
|
||||
assert!(notes.contains("SHARD_PREREQS.md"), "{notes}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_present_libgdiplus_is_one_quiet_ok_line() {
|
||||
let row = imaging_verdict(true);
|
||||
assert_eq!(row.mark, Mark::Ok);
|
||||
assert!(
|
||||
row.notes.is_empty(),
|
||||
"a satisfied prerequisite needs no advice"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn detection_answers_rather_than_panicking_on_a_host_with_no_ldconfig() {
|
||||
// The value depends on the host and is not asserted — what is asserted is that a missing
|
||||
// `ldconfig` degrades to the path probe instead of taking `doctor` down, which is the rule
|
||||
// every row in this module follows.
|
||||
let _ = imaging_present();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,8 +576,9 @@ fn install_sidecar(
|
||||
layout.relocated,
|
||||
)?;
|
||||
|
||||
let outcome = service::register(&prepared, layout, action.writes())?;
|
||||
let mut outcome = service::register(&prepared, layout, action.writes())?;
|
||||
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
|
||||
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
|
||||
let service_record = match &outcome {
|
||||
service::Outcome::Registered {
|
||||
kind,
|
||||
@@ -694,7 +695,7 @@ fn preflight_writable(layout: &paths::Layout) -> Result<()> {
|
||||
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
|
||||
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
|
||||
/// aborting a completed install over an unanswerable prompt costs them the whole run.
|
||||
fn resolve_host(cli: &Cli) -> String {
|
||||
pub(crate) fn resolve_host(cli: &Cli) -> String {
|
||||
if let Some(host) = &cli.host {
|
||||
return host.clone();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
|
||||
//! commands `doctor`, `update` and `uninstall`.
|
||||
//!
|
||||
//! **`--game rust`** (module-rust phase 18, docs/modules/rust/PLAN.md §34) runs the same four verbs
|
||||
//! for Rust servers, in [`rustgame`]: schema-2 Rust bundles, named instances, a Rust-Link sidecar
|
||||
//! and service per instance, and the plugin placed for Oxide or Carbon. ServUO's pipeline, files
|
||||
//! and record are untouched by it; ServUO bundles are now read at schema 2 with schema 1 as the
|
||||
//! fallback ([`bundle::fetch`]).
|
||||
//!
|
||||
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
|
||||
//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that
|
||||
//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that.
|
||||
@@ -39,6 +45,7 @@ pub mod overlay;
|
||||
pub mod patch;
|
||||
pub mod paths;
|
||||
pub mod record;
|
||||
pub mod rustgame;
|
||||
pub mod service;
|
||||
pub mod servuo;
|
||||
pub mod sidecar;
|
||||
@@ -76,6 +83,7 @@ pub fn run() -> i32 {
|
||||
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(0)
|
||||
}
|
||||
Mode::Run(command) if parsed.game == cli::Game::Rust => rustgame::run(&parsed, command),
|
||||
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
|
||||
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
|
||||
Mode::Run(Command::Doctor) => doctor::run(&parsed),
|
||||
|
||||
79
src/paths.rs
79
src/paths.rs
@@ -29,6 +29,9 @@ pub struct Layout {
|
||||
pub data_dir: PathBuf,
|
||||
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
|
||||
pub sidecar_bin: PathBuf,
|
||||
/// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust
|
||||
/// instance on the host (docs/modules/rust/PLAN.md §34.2.3).
|
||||
pub rust_sidecar_bin: PathBuf,
|
||||
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
|
||||
/// deployment. Service registration is skipped when it is set — see [`layout`].
|
||||
pub relocated: bool,
|
||||
@@ -83,6 +86,53 @@ impl Layout {
|
||||
pub fn systemd_unit(&self) -> PathBuf {
|
||||
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
|
||||
}
|
||||
|
||||
// ── Rust ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a
|
||||
// host running both games has two records that cannot corrupt each other (§34.4) and an
|
||||
// uninstall of one never reaches the other.
|
||||
|
||||
/// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance.
|
||||
pub fn rust_record(&self) -> PathBuf {
|
||||
self.state_dir.join("rust").join("install.json")
|
||||
}
|
||||
|
||||
/// Where instance configs live on Linux — the directory the template unit names with `%i`.
|
||||
pub fn rust_config_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("rust")
|
||||
}
|
||||
|
||||
/// One instance's `sidecar.toml`.
|
||||
///
|
||||
/// **Linux:** `/etc/runicgateway/rust/<id>.toml`, beside its siblings, because the template
|
||||
/// unit derives the path from the instance name. **Windows:** inside the instance's own
|
||||
/// directory, because a Windows service logs beside its config — two instances sharing a
|
||||
/// directory would share, and fight over, one log file.
|
||||
pub fn rust_config(&self, server_id: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
self.rust_data_dir(server_id).join("sidecar.toml")
|
||||
} else {
|
||||
self.rust_config_dir().join(format!("{server_id}.toml"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `/var/lib/runicgateway/rust/<id>` — one instance's store (and, on Windows, its config and
|
||||
/// logs).
|
||||
pub fn rust_data_dir(&self, server_id: &str) -> PathBuf {
|
||||
self.data_dir.join("rust").join(server_id)
|
||||
}
|
||||
|
||||
/// One instance's SQLite store. Written into its config as an absolute path, so the store never
|
||||
/// depends on anybody's working directory or on a shared default name.
|
||||
pub fn rust_db(&self, server_id: &str) -> PathBuf {
|
||||
self.rust_data_dir(server_id).join("rust-link.db")
|
||||
}
|
||||
|
||||
/// The shared systemd template for every Rust instance.
|
||||
pub fn rust_template_unit(&self) -> PathBuf {
|
||||
PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||
@@ -107,8 +157,14 @@ pub fn layout() -> Layout {
|
||||
.file_name()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
|
||||
let rust_bin_name = layout
|
||||
.rust_sidecar_bin
|
||||
.file_name()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("rust-link-sidecar"));
|
||||
layout.data_dir = root.join("data");
|
||||
layout.sidecar_bin = root.join("bin").join(bin_name);
|
||||
layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name);
|
||||
layout.state_dir = root;
|
||||
layout.relocated = true;
|
||||
}
|
||||
@@ -134,6 +190,9 @@ fn platform_layout() -> Layout {
|
||||
sidecar_bin: program_files
|
||||
.join("RunicGateway")
|
||||
.join("uo-link-sidecar.exe"),
|
||||
rust_sidecar_bin: program_files
|
||||
.join("RunicGateway")
|
||||
.join("rust-link-sidecar.exe"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
@@ -144,6 +203,7 @@ fn platform_layout() -> Layout {
|
||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
@@ -185,6 +245,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_rust_instances_share_nothing_but_the_binary() {
|
||||
// The failure this prevents: two instances on one database, one token file, or (on
|
||||
// Windows) one log file.
|
||||
let l = platform_layout();
|
||||
assert_ne!(l.rust_config("alpha"), l.rust_config("beta"));
|
||||
assert_ne!(l.rust_db("alpha"), l.rust_db("beta"));
|
||||
// A Windows service logs beside its config, so there each instance needs its own directory.
|
||||
if cfg!(windows) {
|
||||
assert_ne!(
|
||||
l.rust_config("alpha").parent(),
|
||||
l.rust_config("beta").parent()
|
||||
);
|
||||
}
|
||||
assert_ne!(l.rust_record(), l.install_record());
|
||||
assert_ne!(l.rust_sidecar_bin, l.sidecar_bin);
|
||||
assert!(l.rust_db("alpha").is_absolute());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relocated_layout_moves_the_binary_too() {
|
||||
// The failure this prevents: a test run that writes its config and database under the
|
||||
|
||||
297
src/rustgame/doctor.rs
Normal file
297
src/rustgame/doctor.rs
Normal file
@@ -0,0 +1,297 @@
|
||||
//! `doctor --game rust` — every recorded instance, or one with `--server-id`.
|
||||
//!
|
||||
//! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only
|
||||
//! 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;
|
||||
//! - that the plugin's config names this instance;
|
||||
//! - `requires_plugins` present, as a warning (D153);
|
||||
//! - the service registered and running;
|
||||
//! - `/health`: reachable, the protocol, and whether the plugin is connected.
|
||||
//!
|
||||
//! **The framework version floor is not measured from files.** A plugin that compiled and connected
|
||||
//! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and
|
||||
//! the floors are printed beside a plugin that is not.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::plugin;
|
||||
use super::record::{Instance, RustRecord};
|
||||
use super::server;
|
||||
use crate::cli::Cli;
|
||||
use crate::doctor::{health_of, report, Row};
|
||||
use crate::{bundle, paths, service, ui};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<i32> {
|
||||
let layout = paths::layout();
|
||||
let record_path = layout.rust_record();
|
||||
println!(
|
||||
"\nRunic Gateway installer {} — doctor (Rust)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let Some(record) = RustRecord::load(&record_path)? else {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \
|
||||
rust` first. If you installed with {} set, set it again for this run.",
|
||||
record_path.display(),
|
||||
paths::STATE_DIR_ENV
|
||||
));
|
||||
return Ok(1);
|
||||
};
|
||||
|
||||
let mut rows = vec![Row::ok(
|
||||
"Rust record",
|
||||
format!(
|
||||
"{} (bundle {}, {} instance(s), installer {})",
|
||||
record_path.display(),
|
||||
record.bundle.tag,
|
||||
record.instances.len(),
|
||||
record.installer.version
|
||||
),
|
||||
)];
|
||||
|
||||
let selected: Vec<(&String, &Instance)> = match &cli.server_id {
|
||||
Some(id) => match record.instances.get_key_value(id) {
|
||||
Some(pair) => vec![pair],
|
||||
None => {
|
||||
rows.push(Row::fail(
|
||||
"Instance",
|
||||
format!(
|
||||
"{id:?} is not recorded (recorded: {})",
|
||||
record
|
||||
.instances
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
));
|
||||
return Ok(report(&rows));
|
||||
}
|
||||
},
|
||||
None => record.instances.iter().collect(),
|
||||
};
|
||||
|
||||
// The floors, fetched once, for the note beside a plugin that has not connected. Read from the
|
||||
// installed bundle rather than the current one: they describe the plugin that is installed.
|
||||
let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok();
|
||||
|
||||
for (id, instance) in selected {
|
||||
rows.extend(instance_rows(
|
||||
id,
|
||||
instance,
|
||||
&record,
|
||||
installed_bundle.as_ref().map(|b| &b.0),
|
||||
));
|
||||
}
|
||||
rows.push(bundle_row(&record));
|
||||
Ok(report(&rows))
|
||||
}
|
||||
|
||||
fn instance_rows(
|
||||
id: &str,
|
||||
instance: &Instance,
|
||||
record: &RustRecord,
|
||||
bundle: Option<&bundle::RustBundle>,
|
||||
) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
let label = |what: &str| format!("{id}: {what}");
|
||||
|
||||
// ── The server and its framework ─────────────────────────────────────────
|
||||
let server = server::open(Path::new(&instance.server_root));
|
||||
match &server {
|
||||
Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok(
|
||||
&label("server"),
|
||||
format!("{} ({})", s.path.display(), instance.framework),
|
||||
)),
|
||||
Ok(s) => rows.push(
|
||||
Row::fail(
|
||||
&label("server"),
|
||||
format!(
|
||||
"{} now runs {}, but the plugin was installed for {}",
|
||||
s.path.display(),
|
||||
s.framework.as_str(),
|
||||
instance.framework
|
||||
),
|
||||
)
|
||||
.note(format!(
|
||||
"reinstall it: install --game rust --server-id {id} --rust {}",
|
||||
s.path.display()
|
||||
)),
|
||||
),
|
||||
Err(e) => rows.push(Row::fail(&label("server"), e.to_string())),
|
||||
}
|
||||
|
||||
// ── The plugin file: ours ────────────────────────────────────────────────
|
||||
let plugin_path = Path::new(&instance.plugin_path);
|
||||
match std::fs::read(plugin_path) {
|
||||
Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => {
|
||||
rows.push(Row::ok(
|
||||
&label("plugin"),
|
||||
format!(
|
||||
"{} (Rust-Plugins {})",
|
||||
instance.plugin_path, record.plugin.tag
|
||||
),
|
||||
))
|
||||
}
|
||||
Ok(_) => rows.push(
|
||||
Row::fail(
|
||||
&label("plugin"),
|
||||
format!("{} is not the file that was deployed", instance.plugin_path),
|
||||
)
|
||||
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
|
||||
),
|
||||
Err(_) => rows.push(Row::fail(
|
||||
&label("plugin"),
|
||||
format!("{} is missing", instance.plugin_path),
|
||||
)),
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
&label("plugin config"),
|
||||
format!(
|
||||
"ServerId {id}, Port {}",
|
||||
view.port
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| "default".into())
|
||||
),
|
||||
)),
|
||||
Ok(Some(view)) => rows.push(Row::fail(
|
||||
&label("plugin config"),
|
||||
format!(
|
||||
"{} says ServerId {:?}; this instance is {id:?}",
|
||||
instance.plugin_config,
|
||||
view.server_id.unwrap_or_else(|| "main (default)".into())
|
||||
),
|
||||
)),
|
||||
Ok(None) => rows.push(Row::warn(
|
||||
&label("plugin config"),
|
||||
format!(
|
||||
"{} is gone — the plugin will write one with ServerId \"main\" on its next load",
|
||||
instance.plugin_config
|
||||
),
|
||||
)),
|
||||
Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())),
|
||||
}
|
||||
|
||||
// ── Third-party plugins: reported, never installed (D153) ────────────────
|
||||
if let (Ok(s), Some(b)) = (&server, bundle) {
|
||||
let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins);
|
||||
rows.push(if missing.is_empty() {
|
||||
Row::ok(
|
||||
&label("required plugins"),
|
||||
b.payload.compat.requires_plugins.join(", "),
|
||||
)
|
||||
} else {
|
||||
Row::warn(
|
||||
&label("required plugins"),
|
||||
format!(
|
||||
"missing {} in {}",
|
||||
missing.join(", "),
|
||||
s.plugins_dir().display()
|
||||
),
|
||||
)
|
||||
.note("install from uMod; the features that use them stay off until then")
|
||||
});
|
||||
}
|
||||
|
||||
// ── The service ──────────────────────────────────────────────────────────
|
||||
match &instance.service {
|
||||
Some(svc) => {
|
||||
let status = service::observe(&svc.kind, &svc.name);
|
||||
let detail = format!("{} {}", svc.name, status.detail);
|
||||
rows.push(if status.running && status.enabled {
|
||||
Row::ok(&label("service"), detail)
|
||||
} else {
|
||||
Row::fail(&label("service"), detail)
|
||||
});
|
||||
}
|
||||
None => rows.push(Row::fail(
|
||||
&label("service"),
|
||||
"none registered — the install printed the steps to register it by hand",
|
||||
)),
|
||||
}
|
||||
|
||||
// ── The sidecar, and the plugin through it ───────────────────────────────
|
||||
let health = health_of(&format!("127.0.0.1:{}", instance.web_port));
|
||||
let Some(health) = health else {
|
||||
rows.push(Row::fail(
|
||||
&label("sidecar"),
|
||||
format!(
|
||||
"nothing answered http://127.0.0.1:{}/health",
|
||||
instance.web_port
|
||||
),
|
||||
));
|
||||
return rows;
|
||||
};
|
||||
rows.push(Row::ok(
|
||||
&label("sidecar"),
|
||||
format!(
|
||||
"127.0.0.1:{} /health {}, database {}",
|
||||
instance.web_port,
|
||||
health.status.as_deref().unwrap_or("ok"),
|
||||
health.database.as_deref().unwrap_or("unknown")
|
||||
),
|
||||
));
|
||||
match health.protocol {
|
||||
Some(p) if p == record.bundle.protocol => {
|
||||
rows.push(Row::ok(&label("protocol"), p.to_string()))
|
||||
}
|
||||
Some(p) => rows.push(Row::fail(
|
||||
&label("protocol"),
|
||||
format!(
|
||||
"the sidecar speaks {p}; bundle {} is {}",
|
||||
record.bundle.tag, record.bundle.protocol
|
||||
),
|
||||
)),
|
||||
None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")),
|
||||
}
|
||||
let floors = bundle
|
||||
.map(|b| {
|
||||
b.payload
|
||||
.compat
|
||||
.frameworks
|
||||
.iter()
|
||||
.map(|(f, v)| format!("{f} {}", v.min_version))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path));
|
||||
rows.push(match (health.plugin_connected, running) {
|
||||
(Some(true), _) => Row::ok(&label("plugin connected"), "yes"),
|
||||
(_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"),
|
||||
(_, true) => {
|
||||
Row::fail(&label("plugin connected"), "no, and the server is running").note(format!(
|
||||
"the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \
|
||||
error; the plugin is known good on {floors}",
|
||||
instance.game_port
|
||||
))
|
||||
}
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// Whether a newer Rust bundle than the installed one is published.
|
||||
fn bundle_row(record: &RustRecord) -> Row {
|
||||
match bundle::fetch_rust(None) {
|
||||
Ok((current, _)) if current.bundle == record.bundle.tag => {
|
||||
Row::ok("Bundle", format!("{} is current", record.bundle.tag))
|
||||
}
|
||||
Ok((current, _)) => Row::warn(
|
||||
"Bundle",
|
||||
format!(
|
||||
"{} is installed; {} is published",
|
||||
record.bundle.tag, current.bundle
|
||||
),
|
||||
)
|
||||
.note("update --game rust moves every instance to it"),
|
||||
Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")),
|
||||
}
|
||||
}
|
||||
777
src/rustgame/install.rs
Normal file
777
src/rustgame/install.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
//! `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::plugin::{self, ConfigView};
|
||||
use super::record::{ComponentRecord, Instance, 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,
|
||||
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
|
||||
),
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
)?);
|
||||
}
|
||||
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
|
||||
print_plan(&layout, &planned, binary_action, &bundle);
|
||||
|
||||
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,
|
||||
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,
|
||||
);
|
||||
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()).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,
|
||||
) -> 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,
|
||||
};
|
||||
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,
|
||||
game_port,
|
||||
web_port,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_plan(
|
||||
layout: &paths::Layout,
|
||||
planned: &[Planned],
|
||||
binary: BinaryAction,
|
||||
bundle: &RustBundle,
|
||||
) {
|
||||
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()
|
||||
),
|
||||
);
|
||||
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
|
||||
),
|
||||
);
|
||||
let missing = plugin::missing_plugins(
|
||||
&p.server.plugins_dir(),
|
||||
&bundle.payload.compat.requires_plugins,
|
||||
);
|
||||
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.
|
||||
fn deploy_instance(
|
||||
layout: &paths::Layout,
|
||||
prepared: &service::Prepared,
|
||||
bundle: &RustBundle,
|
||||
released: &plugin::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 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(),
|
||||
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),
|
||||
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(())
|
||||
}
|
||||
34
src/rustgame/mod.rs
Normal file
34
src/rustgame/mod.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! `--game rust`: the installer's second game (docs/modules/rust/PLAN.md §34, R4).
|
||||
//!
|
||||
//! The same four verbs as ServUO and the same bundle discipline — an exact, protocol-checked pair
|
||||
//! from CI, never "latest of each" — over a different shape of host. A Rust host commonly runs
|
||||
//! several servers, and R8 gives each its own sidecar, so where ServUO has one deployment this has
|
||||
//! **named instances** (D148): one server root, one plugin, and one sidecar service per
|
||||
//! `--server-id`, all sharing one binary and one bundle.
|
||||
//!
|
||||
//! Kept in its own module rather than threaded through the ServUO pipeline: the two share the
|
||||
//! bundle reader, the download and checksum code, the service machinery and the report format, and
|
||||
//! share nothing about what is deployed or where. ServUO's files and record are untouched by any
|
||||
//! of this.
|
||||
|
||||
mod doctor;
|
||||
mod install;
|
||||
mod plugin;
|
||||
mod record;
|
||||
mod server;
|
||||
mod sidecar;
|
||||
mod uninstall;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::cli::{Cli, Command};
|
||||
|
||||
/// Runs one verb for Rust. The `i32` is the exit code, as for ServUO.
|
||||
pub fn run(cli: &Cli, command: Command) -> Result<i32> {
|
||||
match command {
|
||||
Command::Install => install::deploy(cli, crate::install::Mode::Install).map(|()| 0),
|
||||
Command::Update => install::deploy(cli, crate::install::Mode::Update).map(|()| 0),
|
||||
Command::Doctor => doctor::run(cli),
|
||||
Command::Uninstall => uninstall::run(cli),
|
||||
}
|
||||
}
|
||||
238
src/rustgame/plugin.rs
Normal file
238
src/rustgame/plugin.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! The plugin: its released tarball, and its config in the server root.
|
||||
//!
|
||||
//! Two files, 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`.
|
||||
//! - **`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
|
||||
//! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the
|
||||
//! ServUO overlay follows for `Bridge.cfg`.
|
||||
|
||||
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 the plugin tarball (Rust-Plugins' release.yml).
|
||||
const PREFIX: &str = "runicgateway-rust-plugin";
|
||||
|
||||
/// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
#[serde(default)]
|
||||
pub files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// The released plugin, read out of its tarball.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Released {
|
||||
pub manifest: Manifest,
|
||||
pub source: Vec<u8>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that 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 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);
|
||||
}
|
||||
|
||||
let manifest = manifest
|
||||
.ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?;
|
||||
let source = source.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"the plugin tarball has no {PREFIX}/{}",
|
||||
super::server::PLUGIN_FILE
|
||||
)
|
||||
})?;
|
||||
let manifest: Manifest =
|
||||
serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?;
|
||||
let sha256 = sha256_bytes(&source);
|
||||
if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) {
|
||||
if !declared.eq_ignore_ascii_case(&sha256) {
|
||||
bail!(
|
||||
"the plugin 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Released {
|
||||
manifest,
|
||||
source,
|
||||
sha256,
|
||||
})
|
||||
}
|
||||
|
||||
/// What the plugin's config says, as far as the installer cares.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigView {
|
||||
pub server_id: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
}
|
||||
|
||||
/// Reads `RunicGateway.json`, or `None` when the plugin has never written one.
|
||||
pub fn read_config(path: &Path) -> Result<Option<ConfigView>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text =
|
||||
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let value: serde_json::Value = serde_json::from_str(&text).with_context(|| {
|
||||
format!(
|
||||
"{} is not valid JSON. The plugin will not load it either; fix or remove it.",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(ConfigView {
|
||||
server_id: value
|
||||
.get("ServerId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string),
|
||||
port: value
|
||||
.get("Port")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|p| u16::try_from(p).ok()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The config the installer writes when there is none: just the two keys it decides. The plugin
|
||||
/// fills in every other key on its first load, with its own defaults (§34.2.3).
|
||||
pub fn initial_config(server_id: &str, port: u16) -> String {
|
||||
let doc = serde_json::json!({ "ServerId": server_id, "Port": port });
|
||||
let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default();
|
||||
text.push('\n');
|
||||
text
|
||||
}
|
||||
|
||||
/// Refuses an existing config that names another server.
|
||||
pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> {
|
||||
match view.server_id.as_deref() {
|
||||
Some(id) if id == server_id => Ok(()),
|
||||
Some(id) => bail!(
|
||||
"{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \
|
||||
The website locks a server's id once it has seen it, and this installer never rewrites \
|
||||
the plugin's config. Run again with --server-id {id}, or — if this server really is \
|
||||
new to the site — remove that file and run again.",
|
||||
path.display()
|
||||
),
|
||||
// A config with no ServerId gets the plugin's default ("main") on load. Treat that as what
|
||||
// it will become rather than as agreement.
|
||||
None => {
|
||||
if server_id == "main" {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!(
|
||||
"{} has no ServerId, so the plugin will call this server \"main\"; this run was \
|
||||
asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \
|
||||
with --server-id main.",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of `required` are missing from a plugins directory. Reported, never installed (D153).
|
||||
pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec<String> {
|
||||
required
|
||||
.iter()
|
||||
.filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file())
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
#[test]
|
||||
fn the_first_config_holds_only_what_the_installer_decides() {
|
||||
let text = initial_config("alpha", 7800);
|
||||
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(value.as_object().unwrap().len(), 2);
|
||||
assert_eq!(value["ServerId"], "alpha");
|
||||
assert_eq!(value["Port"], 7800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_existing_config_for_another_server_refuses_the_run() {
|
||||
let path = Path::new("oxide/config/RunicGateway.json");
|
||||
let view = ConfigView {
|
||||
server_id: Some("rust-oxide".into()),
|
||||
port: Some(7799),
|
||||
};
|
||||
assert!(check_server_id(&view, "rust-oxide", path).is_ok());
|
||||
let err = check_server_id(&view, "alpha", path)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("\"rust-oxide\"") && err.contains("\"alpha\""),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_config_without_an_id_is_main() {
|
||||
let path = Path::new("x.json");
|
||||
let view = ConfigView {
|
||||
server_id: None,
|
||||
port: None,
|
||||
};
|
||||
assert!(check_server_id(&view, "main", path).is_ok());
|
||||
assert!(check_server_id(&view, "alpha", path).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() {
|
||||
let dir = TempDir::new("rg-rust-cfg").unwrap();
|
||||
let path = dir.path().join("RunicGateway.json");
|
||||
assert_eq!(read_config(&path).unwrap(), None);
|
||||
// What the rig's plugin actually wrote, 2026-09-26.
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide",
|
||||
"EventsEnabled": true, "MapMaxBases": 2000 }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let view = read_config(&path).unwrap().unwrap();
|
||||
assert_eq!(view.server_id.as_deref(), Some("rust-oxide"));
|
||||
assert_eq!(view.port, Some(7799));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_required_plugin_is_named() {
|
||||
let dir = TempDir::new("rg-rust-req").unwrap();
|
||||
std::fs::write(dir.path().join("Kits.cs"), b"").unwrap();
|
||||
let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]);
|
||||
assert_eq!(missing, ["ZoneManager"]);
|
||||
}
|
||||
}
|
||||
208
src/rustgame/record.rs
Normal file
208
src/rustgame/record.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! `rust/install.json` — what this host has deployed for Rust.
|
||||
//!
|
||||
//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md
|
||||
//! §34.4): a host running both games has two records that cannot corrupt each other, and an
|
||||
//! installer that predates Rust never sees this one at all.
|
||||
//!
|
||||
//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every
|
||||
//! instance together because they share the binary (§34.4), which is why the bundle is recorded once
|
||||
//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in
|
||||
//! each instance's `sidecar.toml` — and fields this build has no name for are carried through.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
|
||||
use crate::util::write_atomic;
|
||||
|
||||
/// The shape of this document.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RustRecord {
|
||||
pub schema: u32,
|
||||
pub installer: InstallerInfo,
|
||||
pub updated: String,
|
||||
pub bundle: BundleRef,
|
||||
pub sidecar: ComponentRecord,
|
||||
pub binary: BinaryRef,
|
||||
pub plugin: ComponentRecord,
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub service_user_created: bool,
|
||||
pub instances: BTreeMap<String, Instance>,
|
||||
#[serde(flatten)]
|
||||
pub extra: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// A released component, as installed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ComponentRecord {
|
||||
pub repo: String,
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub commit: String,
|
||||
pub protocol: u32,
|
||||
}
|
||||
|
||||
/// One Rust server and its sidecar.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Instance {
|
||||
pub server_root: String,
|
||||
/// `oxide` or `carbon`, as detected when this was recorded.
|
||||
pub framework: String,
|
||||
pub plugin_path: String,
|
||||
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
|
||||
pub plugin_sha256: String,
|
||||
/// 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.
|
||||
#[serde(default)]
|
||||
pub plugin_config_written: bool,
|
||||
/// The loopback port the plugin dials — the plugin config's `Port`.
|
||||
pub game_port: u16,
|
||||
/// The port the website reaches this instance's sidecar on.
|
||||
pub web_port: u16,
|
||||
pub config_path: String,
|
||||
pub db_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub service: Option<ServiceRecord>,
|
||||
}
|
||||
|
||||
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 {
|
||||
let mut a = self.clone();
|
||||
let mut b = other.clone();
|
||||
a.updated.clear();
|
||||
b.updated.clear();
|
||||
a == b
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Option<Self>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let body = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||
let record = serde_json::from_str(&body).with_context(|| {
|
||||
format!(
|
||||
"{} exists but is not a record this installer understands. \
|
||||
Move it aside to start over, or install a newer installer.",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(record))
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
let mut body =
|
||||
serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?;
|
||||
body.push('\n');
|
||||
write_atomic(path, body.as_bytes())
|
||||
}
|
||||
|
||||
/// Game ports held by instances other than `except`.
|
||||
pub fn game_ports_except(&self, except: &str) -> Vec<u16> {
|
||||
self.instances
|
||||
.iter()
|
||||
.filter(|(id, _)| id.as_str() != except)
|
||||
.map(|(_, i)| i.game_port)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Web ports held by instances other than `except`.
|
||||
pub fn web_ports_except(&self, except: &str) -> Vec<u16> {
|
||||
self.instances
|
||||
.iter()
|
||||
.filter(|(id, _)| id.as_str() != except)
|
||||
.map(|(_, i)| i.web_port)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
pub fn sample() -> RustRecord {
|
||||
let instance = |root: &str, game: u16, web: u16| Instance {
|
||||
server_root: root.into(),
|
||||
framework: "oxide".into(),
|
||||
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
|
||||
plugin_sha256: "ab".repeat(32),
|
||||
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
|
||||
plugin_config_written: true,
|
||||
game_port: game,
|
||||
web_port: web,
|
||||
config_path: "/etc/runicgateway/rust/x.toml".into(),
|
||||
db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(),
|
||||
service: None,
|
||||
};
|
||||
RustRecord {
|
||||
schema: SCHEMA,
|
||||
installer: InstallerInfo {
|
||||
version: "0.3.0".into(),
|
||||
},
|
||||
updated: "2026-09-26T00:00:00Z".into(),
|
||||
bundle: BundleRef {
|
||||
tag: "2026.09.26".into(),
|
||||
protocol: 12,
|
||||
url: "https://example/v2/rust/current.json".into(),
|
||||
},
|
||||
sidecar: ComponentRecord {
|
||||
repo: "RunicGateway/Rust-Link".into(),
|
||||
tag: "v0.1.0".into(),
|
||||
version: "0.1.0".into(),
|
||||
commit: String::new(),
|
||||
protocol: 12,
|
||||
},
|
||||
binary: BinaryRef {
|
||||
path: "/usr/bin/runicgateway-rust-link".into(),
|
||||
sha256: "cd".repeat(32),
|
||||
},
|
||||
plugin: ComponentRecord {
|
||||
repo: "RunicGateway/Rust-Plugins".into(),
|
||||
tag: "v0.1.0".into(),
|
||||
version: "0.1.0".into(),
|
||||
commit: "abc".into(),
|
||||
protocol: 12,
|
||||
},
|
||||
service_user_created: true,
|
||||
instances: BTreeMap::from([
|
||||
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
|
||||
("beta".to_string(), instance("/srv/b", 7800, 8091)),
|
||||
]),
|
||||
extra: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_round_trips_and_holds_no_token() {
|
||||
let dir = TempDir::new("rg-rust-record").unwrap();
|
||||
let path = dir.path().join("rust").join("install.json");
|
||||
let record = sample();
|
||||
record.save(&path).unwrap();
|
||||
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(!text.contains("auth_token") && !text.contains("token\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
|
||||
let record = sample();
|
||||
assert_eq!(record.game_ports_except("alpha"), [7800]);
|
||||
assert_eq!(record.web_ports_except("beta"), [8090]);
|
||||
}
|
||||
}
|
||||
219
src/rustgame/server.rs
Normal file
219
src/rustgame/server.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
//! A Rust server root: that it is one, and which modding framework it runs.
|
||||
//!
|
||||
//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one
|
||||
//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes
|
||||
//! in, and the server itself already says which framework it has. Asking would be one more answer an
|
||||
//! operator could get wrong.
|
||||
//!
|
||||
//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code
|
||||
//! relied on them:
|
||||
//!
|
||||
//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`.
|
||||
//! - **Carbon:** `carbon/managed/Carbon.dll`.
|
||||
//!
|
||||
//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both
|
||||
//! is one mid-migration, and guessing would put the plugin where one of them never looks.
|
||||
//!
|
||||
//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be
|
||||
//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no
|
||||
//! folder keys at all — Carbon's directories move only by a launch argument, which an installer
|
||||
//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
|
||||
/// Which framework loads the plugin.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Framework {
|
||||
Oxide,
|
||||
Carbon,
|
||||
}
|
||||
|
||||
impl Framework {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Oxide => "oxide",
|
||||
Self::Carbon => "carbon",
|
||||
}
|
||||
}
|
||||
|
||||
/// The file whose presence says this framework is installed.
|
||||
fn marker(self, root: &Path) -> PathBuf {
|
||||
match self {
|
||||
Self::Oxide => root
|
||||
.join("RustDedicated_Data")
|
||||
.join("Managed")
|
||||
.join("Oxide.Rust.dll"),
|
||||
Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the framework loads plugins from.
|
||||
pub fn plugins_dir(self, root: &Path) -> PathBuf {
|
||||
match self {
|
||||
Self::Oxide => root.join("oxide").join("plugins"),
|
||||
Self::Carbon => root.join("carbon").join("plugins"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`.
|
||||
pub fn config_dir(self, root: &Path) -> PathBuf {
|
||||
match self {
|
||||
Self::Oxide => root.join("oxide").join("config"),
|
||||
Self::Carbon => root.join("carbon").join("configs"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated Rust server root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RustServer {
|
||||
pub path: PathBuf,
|
||||
pub framework: Framework,
|
||||
}
|
||||
|
||||
/// The file this installer owns in a server root: the plugin.
|
||||
pub const PLUGIN_FILE: &str = "RunicGateway.cs";
|
||||
/// The plugin's config, which is the website's once written (§34.2.3).
|
||||
pub const PLUGIN_CONFIG: &str = "RunicGateway.json";
|
||||
|
||||
impl RustServer {
|
||||
pub fn plugins_dir(&self) -> PathBuf {
|
||||
self.framework.plugins_dir(&self.path)
|
||||
}
|
||||
|
||||
pub fn plugin_path(&self) -> PathBuf {
|
||||
self.plugins_dir().join(PLUGIN_FILE)
|
||||
}
|
||||
|
||||
pub fn plugin_config_path(&self) -> PathBuf {
|
||||
self.framework.config_dir(&self.path).join(PLUGIN_CONFIG)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `path` as a Rust server root and detects its framework.
|
||||
pub fn open(path: &Path) -> Result<RustServer> {
|
||||
let path = std::fs::canonicalize(path)
|
||||
.map(crate::servuo::strip_extended_prefix)
|
||||
.with_context(|| format!("{} does not exist", path.display()))?;
|
||||
if !path.is_dir() {
|
||||
bail!("{} is not a directory", path.display());
|
||||
}
|
||||
if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() {
|
||||
bail!(
|
||||
"{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let oxide = Framework::Oxide.marker(&path).is_file();
|
||||
let carbon = Framework::Carbon.marker(&path).is_file();
|
||||
let framework = match (oxide, carbon) {
|
||||
(true, false) => Framework::Oxide,
|
||||
(false, true) => Framework::Carbon,
|
||||
(true, true) => bail!(
|
||||
"{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \
|
||||
is part-way through a migration. Remove the one you are leaving, then run again.",
|
||||
path.display(),
|
||||
Framework::Oxide.marker(&path).display(),
|
||||
Framework::Carbon.marker(&path).display()
|
||||
),
|
||||
(false, false) => bail!(
|
||||
"{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \
|
||||
them. Install one (looked for {} and {}), start the server once so it lays out its \
|
||||
directories, and run this again.",
|
||||
path.display(),
|
||||
Framework::Oxide.marker(&path).display(),
|
||||
Framework::Carbon.marker(&path).display()
|
||||
),
|
||||
};
|
||||
Ok(RustServer { path, framework })
|
||||
}
|
||||
|
||||
/// Whether a RustDedicated is running out of this root.
|
||||
///
|
||||
/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the
|
||||
/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3).
|
||||
/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent.
|
||||
pub fn is_running(root: &Path) -> bool {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let root = normalize(&root.to_string_lossy());
|
||||
system.processes().values().any(|process| {
|
||||
let exe = process
|
||||
.exe()
|
||||
.map(|p| normalize(&p.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
exe.starts_with(&root) && exe.contains("rustdedicated")
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize(s: &str) -> String {
|
||||
s.to_lowercase().replace('\\', "/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
fn server(dir: &Path, frameworks: &[Framework]) {
|
||||
std::fs::write(dir.join("RustDedicated"), b"").unwrap();
|
||||
for f in frameworks {
|
||||
let marker = f.marker(dir);
|
||||
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
|
||||
std::fs::write(marker, b"").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_framework_is_detected_and_places_the_plugin_its_own_way() {
|
||||
for (f, plugins, config) in [
|
||||
(Framework::Oxide, "oxide/plugins", "oxide/config"),
|
||||
(Framework::Carbon, "carbon/plugins", "carbon/configs"),
|
||||
] {
|
||||
let dir = TempDir::new("rg-rust-root").unwrap();
|
||||
server(dir.path(), &[f]);
|
||||
let root = open(dir.path()).unwrap();
|
||||
assert_eq!(root.framework, f);
|
||||
let rel = |p: PathBuf| {
|
||||
p.strip_prefix(&root.path)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
};
|
||||
assert_eq!(rel(root.plugins_dir()), plugins);
|
||||
assert_eq!(
|
||||
rel(root.plugin_config_path()),
|
||||
format!("{config}/RunicGateway.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_frameworks_are_refused_and_so_is_neither() {
|
||||
let dir = TempDir::new("rg-rust-both").unwrap();
|
||||
server(dir.path(), &[Framework::Oxide, Framework::Carbon]);
|
||||
let err = open(dir.path()).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("both Oxide") && err.contains("Carbon"),
|
||||
"{err}"
|
||||
);
|
||||
|
||||
let dir = TempDir::new("rg-rust-none").unwrap();
|
||||
server(dir.path(), &[]);
|
||||
let err = open(dir.path()).unwrap_err().to_string();
|
||||
assert!(err.contains("neither Oxide nor Carbon"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_without_the_game_is_not_a_server_root() {
|
||||
let dir = TempDir::new("rg-rust-empty").unwrap();
|
||||
let err = open(dir.path()).unwrap_err().to_string();
|
||||
assert!(err.contains("RustDedicated"), "{err}");
|
||||
}
|
||||
}
|
||||
325
src/rustgame/sidecar.rs
Normal file
325
src/rustgame/sidecar.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! One instance's rust-link sidecar: its config, and asking the installed binary about it.
|
||||
//!
|
||||
//! **The installer writes each instance's `sidecar.toml` itself, once.** ServUO's sidecar is one per
|
||||
//! host and its defaults are right as shipped; a Rust host runs several, and every one of them needs
|
||||
//! its own game port, web port and database, which no default can give it. So the first run writes
|
||||
//! the file with those three decided and the token blank, and `--print-config` then generates the
|
||||
//! token and saves it into that same file — the sidecar still owns the secret, exactly as for ServUO.
|
||||
//! An existing file is never rewritten: after the first run it is the operator's, and the ports it
|
||||
//! names are the ones recorded.
|
||||
//!
|
||||
//! The same rule as `crate::sidecar` holds for the token: it crosses one process boundary, here,
|
||||
//! and goes to the terminal and nowhere else.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// What `rust-link-sidecar --print-config` prints (Rust-Link `sidecar/src/config.rs::describe`).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ConfigDoc {
|
||||
pub component: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub config_path: String,
|
||||
pub token_generated: bool,
|
||||
pub game: GameDoc,
|
||||
pub web: WebDoc,
|
||||
pub store: StoreDoc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GameDoc {
|
||||
pub bind: String,
|
||||
#[serde(default)]
|
||||
pub server_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WebDoc {
|
||||
pub bind: String,
|
||||
/// **A secret.** Printed in the handoff and recorded nowhere.
|
||||
pub auth_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct StoreDoc {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// The first `sidecar.toml` for an instance. Pure, so its content is a test.
|
||||
///
|
||||
/// Paths are TOML **literal** strings (single quotes): a Windows path is all backslashes, and a basic
|
||||
/// string would read each one as an escape. A literal string cannot hold a single quote, and neither
|
||||
/// a server id nor any path this installer builds contains one.
|
||||
pub fn instance_config(server_id: &str, game_port: u16, web_port: u16, db: &Path) -> String {
|
||||
format!(
|
||||
"# rust-link sidecar for Rust server {server_id:?}.\n\
|
||||
#\n\
|
||||
# Written once by the Runic Gateway installer, which decided the ports and the database\n\
|
||||
# below; it is never rewritten, so it is yours to edit from here. The token is generated\n\
|
||||
# by the sidecar on first start and saved into this file.\n\
|
||||
\n\
|
||||
[game]\n\
|
||||
# Where the plugin dials in. Loopback: there is no token on this link.\n\
|
||||
bind = '127.0.0.1:{game_port}'\n\
|
||||
# Cross-checked against the serverId the plugin announces.\n\
|
||||
server_id = '{server_id}'\n\
|
||||
\n\
|
||||
[web]\n\
|
||||
# Where the website reaches this sidecar. Put a TLS proxy in front to reach it from\n\
|
||||
# another machine (docs/rust-link/INSTALL.md).\n\
|
||||
bind = '127.0.0.1:{web_port}'\n\
|
||||
auth_token = \"\"\n\
|
||||
\n\
|
||||
[store]\n\
|
||||
path = '{db}'\n",
|
||||
db = db.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs the installed binary's `--print-config` against one instance's config, provisioning the
|
||||
/// token on first use. **Must not print or attach the child's stdout** — it holds the token.
|
||||
pub fn print_config(binary: &Path, config: &Path) -> Result<ConfigDoc> {
|
||||
let output = std::process::Command::new(binary)
|
||||
.arg("--print-config")
|
||||
.arg("--config")
|
||||
.arg(config)
|
||||
// An operator's shell may export RUSTLINK_* while testing; an installed instance is
|
||||
// described by its file alone, so none of them may leak into this read.
|
||||
.env_remove("RUSTLINK_CONFIG")
|
||||
.env_remove("RUSTLINK_GAME_BIND")
|
||||
.env_remove("RUSTLINK_WEB_BIND")
|
||||
.env_remove("RUSTLINK_WEB_TOKEN")
|
||||
.env_remove("RUSTLINK_DB_PATH")
|
||||
.env_remove("RUSTLINK_SERVER_ID")
|
||||
.output()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"cannot run {} --print-config — the binary cannot execute here",
|
||||
binary.display()
|
||||
)
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
let reason = String::from_utf8_lossy(&output.stderr)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("(nothing on stderr)")
|
||||
.to_string();
|
||||
bail!(
|
||||
"{} --print-config --config {} failed: {reason}",
|
||||
binary.display(),
|
||||
config.display()
|
||||
);
|
||||
}
|
||||
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
|
||||
"the rust-link sidecar's --print-config output is not the document this installer \
|
||||
expects. It is not shown here because it contains the auth token.",
|
||||
)?;
|
||||
if doc.component != "rust-link-sidecar" {
|
||||
bail!(
|
||||
"the binary at {} identifies itself as {:?}, not rust-link-sidecar",
|
||||
binary.display(),
|
||||
doc.component
|
||||
);
|
||||
}
|
||||
if doc.web.auth_token.trim().is_empty() {
|
||||
bail!(
|
||||
"the sidecar reported an empty auth token from {}; refusing to continue",
|
||||
doc.config_path
|
||||
);
|
||||
}
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// The port half of a bind.
|
||||
pub fn port_of(bind: &str) -> Option<u16> {
|
||||
bind.rsplit_once(':').and_then(|(_, p)| p.parse().ok())
|
||||
}
|
||||
|
||||
/// The lowest port from `start` that no recorded instance holds and nothing on this host is bound
|
||||
/// to. `keep`, when given, is the port this instance already has, which is always kept.
|
||||
pub fn choose_port(start: u16, held: &[u16], keep: Option<u16>) -> Result<u16> {
|
||||
if let Some(port) = keep {
|
||||
return Ok(port);
|
||||
}
|
||||
(start..=u16::MAX)
|
||||
.take(1000)
|
||||
.find(|p| !held.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("no free port found in the 1000 from {start}"))
|
||||
}
|
||||
|
||||
/// The end-of-run block for one instance: what `/admin/rust/servers` asks for.
|
||||
///
|
||||
/// It says what the run did, not what it hopes: nothing here has seen the plugin connect — on a
|
||||
/// stopped server it has not even loaded — so the first line never claims a link. `doctor` is the
|
||||
/// command that checks one (D156).
|
||||
pub fn handoff(
|
||||
server_id: &str,
|
||||
doc: &ConfigDoc,
|
||||
host: &str,
|
||||
site_url: Option<&str>,
|
||||
registered: bool,
|
||||
running: bool,
|
||||
) -> String {
|
||||
let port = port_of(&doc.web.bind)
|
||||
.map(|p| p.to_string())
|
||||
.unwrap_or_else(|| doc.web.bind.clone());
|
||||
let site = site_url
|
||||
.map(|s| s.trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| "https://<your-site>".to_string());
|
||||
let loopback = doc.web.bind.starts_with("127.") || doc.web.bind.starts_with("[::1]");
|
||||
format!(
|
||||
"\nRust server {server_id:?} is set up. {when}\n\n\
|
||||
One manual step remains — add it to the website:\n\n \
|
||||
Server id {server_id}\n \
|
||||
Sidecar URL http://{host}:{port}\n \
|
||||
Auth token {token}\n \
|
||||
(also in {config})\n \
|
||||
Protocol {protocol}\n\n\
|
||||
Add these at {site}/admin/rust/servers\n{loopback_note}{service_note}",
|
||||
token = doc.web.auth_token,
|
||||
config = doc.config_path,
|
||||
protocol = doc.protocol,
|
||||
when = if running {
|
||||
format!(
|
||||
"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()
|
||||
},
|
||||
loopback_note = if loopback {
|
||||
format!(
|
||||
"\nThe sidecar listens on {} only. If the website runs on another machine, put a \
|
||||
TLS proxy in\nfront of it and give the site the proxy's URL \
|
||||
(docs/rust-link/INSTALL.md).\n",
|
||||
doc.web.bind
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
service_note = if registered {
|
||||
""
|
||||
} else {
|
||||
"\nNo service was registered, so nothing is listening yet — see the steps above.\n"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_instance_config_parses_and_keeps_a_windows_path_intact() {
|
||||
let db = Path::new(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db");
|
||||
let text = instance_config("alpha", 7800, 8091, db);
|
||||
let value: toml_lite::Doc = toml_lite::parse(&text);
|
||||
assert_eq!(value.get("game", "bind"), Some("127.0.0.1:7800"));
|
||||
assert_eq!(value.get("game", "server_id"), Some("alpha"));
|
||||
assert_eq!(value.get("web", "bind"), Some("127.0.0.1:8091"));
|
||||
assert_eq!(value.get("web", "auth_token"), Some(""));
|
||||
assert_eq!(
|
||||
value.get("store", "path"),
|
||||
Some(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_print_config_document_parses() {
|
||||
// What Rust-Link v0.1.0 prints (sidecar/src/config.rs::describe).
|
||||
let doc: ConfigDoc = serde_json::from_str(
|
||||
r#"{ "component": "rust-link-sidecar", "version": "0.1.0", "protocol": 12,
|
||||
"config_path": "/etc/runicgateway/rust/alpha.toml", "config_created": false,
|
||||
"token_generated": true,
|
||||
"game": { "bind": "127.0.0.1:7800", "server_id": "alpha" },
|
||||
"web": { "bind": "127.0.0.1:8091", "ws_path": "/ws", "auth_required": true,
|
||||
"auth_token": "t0k" },
|
||||
"store": { "path": "/var/lib/runicgateway/rust/alpha/rust-link.db" } }"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(port_of(&doc.web.bind), Some(8091));
|
||||
let text = handoff("alpha", &doc, "rust.example", None, true, false);
|
||||
for needle in [
|
||||
"alpha",
|
||||
"http://rust.example:8091",
|
||||
"t0k",
|
||||
"/admin/rust/servers",
|
||||
"12",
|
||||
] {
|
||||
assert!(text.contains(needle), "{needle} missing from:\n{text}");
|
||||
}
|
||||
assert!(text.contains("TLS proxy"), "{text}");
|
||||
|
||||
// D156: never a claim about a link nothing has seen.
|
||||
assert!(!text.contains("is connected"), "{text}");
|
||||
assert!(
|
||||
text.contains("The plugin connects when the server next starts."),
|
||||
"{text}"
|
||||
);
|
||||
let running = handoff("alpha", &doc, "rust.example", None, true, true);
|
||||
assert!(!running.contains("is connected"), "{running}");
|
||||
assert!(
|
||||
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]
|
||||
fn a_held_port_is_skipped_and_a_kept_one_is_kept() {
|
||||
let port = choose_port(47_990, &[47_990, 47_991], None).unwrap();
|
||||
assert!(port >= 47_992);
|
||||
assert_eq!(
|
||||
choose_port(47_990, &[47_990], Some(47_990)).unwrap(),
|
||||
47_990
|
||||
);
|
||||
}
|
||||
|
||||
/// Enough TOML to read back the file `instance_config` writes — `[section]` and
|
||||
/// `key = 'literal'` / `key = "basic"` lines. The installer has no TOML dependency and this
|
||||
/// test is the only reader it needs.
|
||||
mod toml_lite {
|
||||
pub struct Doc(Vec<(String, String, String)>);
|
||||
impl Doc {
|
||||
pub fn get(&self, section: &str, key: &str) -> Option<&str> {
|
||||
self.0
|
||||
.iter()
|
||||
.find(|(s, k, _)| s == section && k == key)
|
||||
.map(|(_, _, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
pub fn parse(text: &str) -> Doc {
|
||||
let mut section = String::new();
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line.starts_with('#') || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
|
||||
section = name.to_string();
|
||||
} else if let Some((k, v)) = line.split_once('=') {
|
||||
let v = v.trim();
|
||||
let v = v
|
||||
.strip_prefix('\'')
|
||||
.and_then(|v| v.strip_suffix('\''))
|
||||
.or_else(|| v.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
|
||||
.unwrap_or(v);
|
||||
out.push((section.clone(), k.trim().to_string(), v.to_string()));
|
||||
}
|
||||
}
|
||||
Doc(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
199
src/rustgame/uninstall.rs
Normal file
199
src/rustgame/uninstall.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! `uninstall --game rust [--server-id <id>] [--purge]`.
|
||||
//!
|
||||
//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4):
|
||||
//!
|
||||
//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's;
|
||||
//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the
|
||||
//! server's id; a reinstall must find it;
|
||||
//! - with `--purge`, the instance's sidecar config (its token) and database.
|
||||
//!
|
||||
//! With the last instance gone: the shared binary, the template unit, the record, and — only if
|
||||
//! this installer created it and no ServUO deployment uses it — the service account.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::record::RustRecord;
|
||||
use crate::cli::Cli;
|
||||
use crate::{paths, service, ui};
|
||||
|
||||
pub fn run(cli: &Cli) -> Result<i32> {
|
||||
let layout = paths::layout();
|
||||
let record_path = layout.rust_record();
|
||||
println!(
|
||||
"\nRunic Gateway installer {} — uninstall (Rust)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let Some(mut record) = RustRecord::load(&record_path)? else {
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).",
|
||||
record_path.display()
|
||||
));
|
||||
return Ok(0);
|
||||
};
|
||||
|
||||
let ids: Vec<String> = match &cli.server_id {
|
||||
Some(id) if record.instances.contains_key(id) => vec![id.clone()],
|
||||
Some(id) => anyhow::bail!(
|
||||
"{id:?} is not a recorded Rust instance (recorded: {})",
|
||||
record
|
||||
.instances
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
),
|
||||
None => record.instances.keys().cloned().collect(),
|
||||
};
|
||||
let last = ids.len() == record.instances.len();
|
||||
|
||||
// ── Say what will happen, then ask ───────────────────────────────────────
|
||||
println!();
|
||||
for id in &ids {
|
||||
let i = &record.instances[id];
|
||||
ui::heading(&format!("Instance {id}"));
|
||||
if let Some(svc) = &i.service {
|
||||
println!(" · remove the service {}", svc.name);
|
||||
}
|
||||
println!(" · remove {}", i.plugin_path);
|
||||
println!(
|
||||
" · keep {} (the website's; it names this server)",
|
||||
i.plugin_config
|
||||
);
|
||||
if cli.purge {
|
||||
println!(
|
||||
" · remove {} and the database {}",
|
||||
i.config_path, i.db_path
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" · keep {} and {} (--purge removes them)",
|
||||
i.config_path, i.db_path
|
||||
);
|
||||
}
|
||||
}
|
||||
if last {
|
||||
println!(
|
||||
"\n and, with no instance left: {} and {}",
|
||||
record.binary.path,
|
||||
record_path.display()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
let proceed = if cli.assume_yes {
|
||||
println!("Remove the components listed above? [y/N] (--yes)");
|
||||
true
|
||||
} else {
|
||||
ui::confirm("Remove the components listed above?", false, false)?
|
||||
};
|
||||
if !proceed {
|
||||
println!("\nNothing was removed.");
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
// ── Remove ───────────────────────────────────────────────────────────────
|
||||
let mut done = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for id in &ids {
|
||||
let Some(instance) = record.instances.remove(id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(svc) = &instance.service {
|
||||
let removal = service::remove(svc);
|
||||
done.extend(removal.done);
|
||||
problems.extend(removal.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);
|
||||
let data_dir = layout.rust_data_dir(id);
|
||||
if data_dir.exists() {
|
||||
match std::fs::remove_dir_all(&data_dir) {
|
||||
Ok(()) => done.push(format!("removed {}", data_dir.display())),
|
||||
Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if record.instances.is_empty() {
|
||||
remove_file(Path::new(&record.binary.path), &mut done, &mut problems);
|
||||
done.extend(service::remove_rust_template(&layout));
|
||||
remove_shared_user(&record, &layout, &mut done, &mut problems);
|
||||
remove_file(&record_path, &mut done, &mut problems);
|
||||
} else {
|
||||
match record.save(&record_path) {
|
||||
Ok(()) => done.push(format!(
|
||||
"{} keeps {} instance(s): {}",
|
||||
record_path.display(),
|
||||
record.instances.len(),
|
||||
record
|
||||
.instances
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)),
|
||||
Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
for line in &done {
|
||||
println!(" · {line}");
|
||||
}
|
||||
for problem in &problems {
|
||||
println!();
|
||||
ui::warn(problem);
|
||||
}
|
||||
println!(
|
||||
"\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \
|
||||
contacts it."
|
||||
);
|
||||
Ok(if problems.is_empty() { 0 } else { 1 })
|
||||
}
|
||||
|
||||
/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's
|
||||
/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains.
|
||||
fn remove_shared_user(
|
||||
record: &RustRecord,
|
||||
layout: &paths::Layout,
|
||||
done: &mut Vec<String>,
|
||||
problems: &mut Vec<String>,
|
||||
) {
|
||||
if !record.service_user_created || cfg!(windows) {
|
||||
return;
|
||||
}
|
||||
if layout.install_record().exists() {
|
||||
done.push(format!(
|
||||
"left the {} account — the ServUO deployment on this host runs as it",
|
||||
service::SERVICE_USER
|
||||
));
|
||||
return;
|
||||
}
|
||||
let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER])
|
||||
.or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER]));
|
||||
match removed {
|
||||
Ok(_) => done.push(format!(
|
||||
"removed the {} service user",
|
||||
service::SERVICE_USER
|
||||
)),
|
||||
Err(e) => problems.push(format!(
|
||||
"cannot remove the {} service user ({}); remove it by hand if you want it gone",
|
||||
service::SERVICE_USER,
|
||||
e.to_string().replace('\n', " ")
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => done.push(format!("removed {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
done.push(format!("{} was already gone", path.display()))
|
||||
}
|
||||
Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
463
src/service.rs
463
src/service.rs
@@ -231,7 +231,21 @@ fn register_systemd(
|
||||
) -> Result<Outcome> {
|
||||
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
||||
let text = systemd_unit_text(binary, config, db, &user);
|
||||
register_systemd_unit(prepared, SYSTEMD_UNIT, unit_path, &text, restart)
|
||||
}
|
||||
|
||||
/// Writes a unit file (when its content changed), then enables and starts `name`.
|
||||
///
|
||||
/// `name` and the file differ for a template: the file is `runicgateway-rust@.service` and what is
|
||||
/// enabled is `runicgateway-rust@alpha.service`, one per Rust instance (D148).
|
||||
#[cfg(unix)]
|
||||
fn register_systemd_unit(
|
||||
prepared: &Prepared,
|
||||
name: &str,
|
||||
unit_path: &Path,
|
||||
text: &str,
|
||||
restart: bool,
|
||||
) -> Result<Outcome> {
|
||||
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
|
||||
// every run is a change an operator watching /etc has to investigate and then dismiss.
|
||||
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
|
||||
@@ -241,31 +255,31 @@ fn register_systemd(
|
||||
run_ok("systemctl", &["daemon-reload"])?;
|
||||
}
|
||||
|
||||
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
|
||||
run_ok("systemctl", &["enable", name])?;
|
||||
if restart {
|
||||
// The binary underneath a running service has just been replaced; `start` on an already
|
||||
// active unit is a no-op and would leave the old code running.
|
||||
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
|
||||
run_ok("systemctl", &["restart", name])?;
|
||||
} else {
|
||||
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
|
||||
run_ok("systemctl", &["start", name])?;
|
||||
}
|
||||
|
||||
Ok(Outcome::Registered {
|
||||
kind: "systemd",
|
||||
name: SYSTEMD_UNIT.to_string(),
|
||||
name: name.to_string(),
|
||||
unit_path: Some(unit_path.to_path_buf()),
|
||||
user: prepared.user.clone(),
|
||||
user_created: prepared.user_created,
|
||||
state: systemd_state(),
|
||||
state: systemd_state(name),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
|
||||
/// start` succeeding and the service still being up a second later are different claims.
|
||||
#[cfg(unix)]
|
||||
fn systemd_state() -> String {
|
||||
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
|
||||
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
|
||||
fn systemd_state(name: &str) -> String {
|
||||
let active = one_word(run("systemctl", &["is-active", name]));
|
||||
let enabled = one_word(run("systemctl", &["is-enabled", name]));
|
||||
format!("{active}, {enabled}")
|
||||
}
|
||||
|
||||
@@ -300,7 +314,14 @@ fn prepare_platform() -> Prepared {
|
||||
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
|
||||
/// is translated.
|
||||
pub fn windows_service_account() -> String {
|
||||
format!("NT SERVICE\\{WINDOWS_SERVICE}")
|
||||
windows_account_for(WINDOWS_SERVICE)
|
||||
}
|
||||
|
||||
/// The virtual account for any service this installer registers — `NT SERVICE\<name>`. A Rust
|
||||
/// instance's is `NT SERVICE\RunicGatewayRust-<id>`, so two instances cannot read each other's
|
||||
/// token.
|
||||
pub fn windows_account_for(name: &str) -> String {
|
||||
format!("NT SERVICE\\{name}")
|
||||
}
|
||||
|
||||
/// The `binPath=` value: the executable and the `--config` it must always be started with.
|
||||
@@ -325,7 +346,19 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
|
||||
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
|
||||
/// config — so the config is the last thing to look at, not the first.
|
||||
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
|
||||
let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]);
|
||||
windows_start_failure_for(WINDOWS_SERVICE, MIN_SERVICE_SIDECAR, code, binary, config)
|
||||
}
|
||||
|
||||
/// [`windows_start_failure`] for any service: `min_sidecar` is the first release of that sidecar
|
||||
/// that speaks the SCM handshake.
|
||||
pub fn windows_start_failure_for(
|
||||
name: &str,
|
||||
min_sidecar: &str,
|
||||
code: i32,
|
||||
binary: &Path,
|
||||
config: &Path,
|
||||
) -> String {
|
||||
let command = crate::util::command_line("sc.exe", &["start", name]);
|
||||
match code {
|
||||
1053 => format!(
|
||||
"`{command}` failed with 1053 — the service did not respond to the start request in \
|
||||
@@ -339,6 +372,7 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
|
||||
\"{config}\"",
|
||||
binary = binary.display(),
|
||||
config = config.display(),
|
||||
MIN_SERVICE_SIDECAR = min_sidecar,
|
||||
),
|
||||
// ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this
|
||||
// is a policy that forbids virtual service accounts rather than a wrong password.
|
||||
@@ -348,11 +382,11 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
|
||||
password, so this is a local policy forbidding them rather than a bad credential. \
|
||||
Register the service by hand against an account this host allows — INSTALL.md \
|
||||
Appendix A4.",
|
||||
account = windows_service_account(),
|
||||
account = windows_account_for(name),
|
||||
),
|
||||
_ => format!(
|
||||
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
|
||||
(System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \
|
||||
(System, source \"Service Control Manager\"), and `sc query {name}` for \
|
||||
the service's own exit code. A sidecar that exits immediately usually cannot read its \
|
||||
config: {}\n\n Running it in the foreground prints the reason:\n\n \
|
||||
\"{}\" --config \"{}\"",
|
||||
@@ -365,23 +399,29 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
|
||||
|
||||
#[cfg(windows)]
|
||||
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
||||
let bin_path = windows_bin_path(binary, config);
|
||||
let account = windows_service_account();
|
||||
register_windows_named(WINDOWS_SERVICE, DISPLAY_NAME, binary, config, restart)
|
||||
}
|
||||
|
||||
if windows_service_exists() {
|
||||
/// Registers (or reconfigures) one SCM service and sets its restart policy. It does not start it:
|
||||
/// [`start_registered`] does, after [`grant_service_access`].
|
||||
#[cfg(windows)]
|
||||
fn register_windows_named(
|
||||
name: &str,
|
||||
display: &str,
|
||||
binary: &Path,
|
||||
config: &Path,
|
||||
restart: bool,
|
||||
) -> Result<Outcome> {
|
||||
let bin_path = windows_bin_path(binary, config);
|
||||
let account = windows_account_for(name);
|
||||
|
||||
if windows_service_exists(name) {
|
||||
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
|
||||
// more to the point, would briefly leave a host with no service if the create half failed.
|
||||
run_ok(
|
||||
"sc.exe",
|
||||
&[
|
||||
"config",
|
||||
WINDOWS_SERVICE,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"start=",
|
||||
"auto",
|
||||
"obj=",
|
||||
&account,
|
||||
"config", name, "binPath=", &bin_path, "start=", "auto", "obj=", &account,
|
||||
],
|
||||
)?;
|
||||
} else {
|
||||
@@ -389,7 +429,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"sc.exe",
|
||||
&[
|
||||
"create",
|
||||
WINDOWS_SERVICE,
|
||||
name,
|
||||
"binPath=",
|
||||
&bin_path,
|
||||
"start=",
|
||||
@@ -397,7 +437,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"obj=",
|
||||
&account,
|
||||
"DisplayName=",
|
||||
DISPLAY_NAME,
|
||||
display,
|
||||
],
|
||||
)
|
||||
.context(
|
||||
@@ -405,7 +445,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
(no password); if this host's policy forbids them, register the service by hand — \
|
||||
INSTALL.md Appendix A4.",
|
||||
)?;
|
||||
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
|
||||
let _ = run("sc.exe", &["description", name, display]);
|
||||
}
|
||||
|
||||
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
|
||||
@@ -415,7 +455,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
"sc.exe",
|
||||
&[
|
||||
"failure",
|
||||
WINDOWS_SERVICE,
|
||||
name,
|
||||
"reset=",
|
||||
"86400",
|
||||
"actions=",
|
||||
@@ -423,42 +463,38 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
],
|
||||
)?;
|
||||
|
||||
if restart && windows_service_state().contains("RUNNING") {
|
||||
stop_windows_service()?;
|
||||
}
|
||||
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(windows_start_failure(
|
||||
start.status.code().unwrap_or(-1),
|
||||
binary,
|
||||
config
|
||||
));
|
||||
if restart && windows_service_state(name).contains("RUNNING") {
|
||||
stop_windows_service(name)?;
|
||||
}
|
||||
// NOT started here: the virtual account `sc create` just made cannot read the config yet —
|
||||
// `protect_config` locked it to SYSTEM and Administrators, and `grant_service_access` is what
|
||||
// lets the account in. Started first, the sidecar died on `Access is denied` and the service
|
||||
// sat STOPPED; the restart policy never fires for a clean exit with an error code. The phase 18
|
||||
// walk found it (D157). The caller grants, then calls `start_registered`.
|
||||
|
||||
Ok(Outcome::Registered {
|
||||
kind: "windows-scm",
|
||||
name: WINDOWS_SERVICE.to_string(),
|
||||
name: name.to_string(),
|
||||
unit_path: None,
|
||||
user: Some(account),
|
||||
user_created: false,
|
||||
state: format!("{}, automatic start", windows_service_state()),
|
||||
state: format!("{}, automatic start", windows_service_state(name)),
|
||||
})
|
||||
}
|
||||
|
||||
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
|
||||
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
|
||||
#[cfg(windows)]
|
||||
fn windows_service_exists() -> bool {
|
||||
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
|
||||
fn windows_service_exists(name: &str) -> bool {
|
||||
match run("sc.exe", &["query", name]) {
|
||||
Ok(output) => output.status.code() != Some(1060),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_service_state() -> String {
|
||||
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
|
||||
fn windows_service_state(name: &str) -> String {
|
||||
let Ok(output) = run("sc.exe", &["query", name]) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -474,12 +510,12 @@ fn windows_service_state() -> String {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn stop_windows_service() -> Result<()> {
|
||||
fn stop_windows_service(name: &str) -> Result<()> {
|
||||
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
|
||||
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
|
||||
let stop = run("sc.exe", &["stop", name])?;
|
||||
if !stop.status.success() && stop.status.code() != Some(1062) {
|
||||
anyhow::bail!(
|
||||
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
|
||||
"cannot stop {name} (exit code {}). The sidecar binary is locked while the \
|
||||
service runs, so the install cannot replace it.",
|
||||
stop.status.code().unwrap_or(-1)
|
||||
);
|
||||
@@ -488,17 +524,259 @@ fn stop_windows_service() -> Result<()> {
|
||||
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
|
||||
// or a delay on every run.
|
||||
for _ in 0..30 {
|
||||
if windows_service_state() == "STOPPED" {
|
||||
if windows_service_state(name) == "STOPPED" {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
anyhow::bail!(
|
||||
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
|
||||
sc.exe stop {WINDOWS_SERVICE}"
|
||||
"{name} did not stop within 15 seconds. Stop it by hand and re-run: \
|
||||
sc.exe stop {name}"
|
||||
)
|
||||
}
|
||||
|
||||
// ── Rust instances (docs/modules/rust/PLAN.md §34.2.3, D148) ─────────────────
|
||||
//
|
||||
// One sidecar binary per host and one service per instance. On Linux that is a systemd TEMPLATE
|
||||
// unit, so every instance runs the same definition with its own config; on Windows it is one SCM
|
||||
// service per instance, each under its own virtual account. The ServUO service above is untouched:
|
||||
// a host running both games has three kinds of name and none of them collide.
|
||||
|
||||
/// The template unit file. Instances are `runicgateway-rust@<id>.service`.
|
||||
pub const RUST_TEMPLATE_UNIT: &str = "runicgateway-rust@.service";
|
||||
/// The prefix of every Rust instance's SCM service name, `RunicGatewayRust-<id>`. Rust-Link's
|
||||
/// `windows.rs` carries the same literal.
|
||||
pub const RUST_WINDOWS_PREFIX: &str = "RunicGatewayRust-";
|
||||
/// The first Rust-Link release whose sidecar speaks the SCM handshake — its first release at all.
|
||||
#[cfg(windows)]
|
||||
const MIN_RUST_SERVICE_SIDECAR: &str = "v0.1.0";
|
||||
|
||||
/// The systemd instance name for a server id.
|
||||
pub fn rust_systemd_instance(server_id: &str) -> String {
|
||||
format!("runicgateway-rust@{server_id}.service")
|
||||
}
|
||||
|
||||
/// The Windows service name for a server id.
|
||||
pub fn rust_windows_service(server_id: &str) -> String {
|
||||
format!("{RUST_WINDOWS_PREFIX}{server_id}")
|
||||
}
|
||||
|
||||
/// The service name this platform uses for an instance.
|
||||
pub fn rust_service_name(server_id: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
rust_windows_service(server_id)
|
||||
} else {
|
||||
rust_systemd_instance(server_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Windows service name is one this installer registers — the ServUO sidecar's, or a Rust
|
||||
/// instance's. Anything else in a record is not ours to query, stop or delete.
|
||||
pub fn is_ours(name: &str) -> bool {
|
||||
name == WINDOWS_SERVICE || name.starts_with(RUST_WINDOWS_PREFIX)
|
||||
}
|
||||
|
||||
/// The template unit. `%i` is the server id, so each instance reads its own config — which names
|
||||
/// its own ports and its own database (see `crate::rustgame`). Pure, so it is a test.
|
||||
pub fn rust_template_unit_text(binary: &Path, config_dir: &Path, user: &str) -> String {
|
||||
format!(
|
||||
"# Runic Gateway rust-link sidecar, one instance per Rust server.\n\
|
||||
#\n\
|
||||
# Generated by the Runic Gateway installer {installer}. Instances are\n\
|
||||
# {instance}; each reads {config_dir}/<id>.toml.\n\
|
||||
# Local edits belong in a drop-in: systemctl edit runicgateway-rust@<id>.service\n\
|
||||
\n\
|
||||
[Unit]\n\
|
||||
Description=Runic Gateway rust-link sidecar (%i)\n\
|
||||
After=network.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
User={user}\n\
|
||||
Environment=RUSTLINK_CONFIG={config_dir}/%i.toml\n\
|
||||
ExecStart={binary}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=multi-user.target\n",
|
||||
installer = env!("CARGO_PKG_VERSION"),
|
||||
instance = rust_systemd_instance("<id>"),
|
||||
config_dir = config_dir.display(),
|
||||
binary = binary.display(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Registers, enables and starts one Rust instance — or explains why it did not.
|
||||
///
|
||||
/// `restart` is true when the shared binary was just replaced: every instance runs it, so every
|
||||
/// instance the run touches is restarted rather than started.
|
||||
pub fn register_rust(
|
||||
prepared: &Prepared,
|
||||
layout: &crate::paths::Layout,
|
||||
server_id: &str,
|
||||
config: &Path,
|
||||
restart: bool,
|
||||
) -> Result<Outcome> {
|
||||
let binary = &layout.rust_sidecar_bin;
|
||||
match &prepared.manager {
|
||||
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
|
||||
reason: reason.clone(),
|
||||
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
|
||||
}),
|
||||
#[cfg(unix)]
|
||||
Manager::Systemd => {
|
||||
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
||||
let text = rust_template_unit_text(binary, &layout.rust_config_dir(), &user);
|
||||
let mut outcome = register_systemd_unit(
|
||||
prepared,
|
||||
&rust_systemd_instance(server_id),
|
||||
&layout.rust_template_unit(),
|
||||
&text,
|
||||
restart,
|
||||
)?;
|
||||
// The template file is shared by every instance, so it is not this instance's to
|
||||
// remove: `uninstall` deletes it with the last one.
|
||||
if let Outcome::Registered { unit_path, .. } = &mut outcome {
|
||||
*unit_path = None;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Manager::WindowsScm => register_windows_named(
|
||||
&rust_windows_service(server_id),
|
||||
&format!("Runic Gateway rust-link sidecar ({server_id})"),
|
||||
binary,
|
||||
config,
|
||||
restart,
|
||||
),
|
||||
#[allow(unreachable_patterns)]
|
||||
other => Ok(Outcome::Skipped {
|
||||
reason: format!("{} is not supported by this build", other.kind()),
|
||||
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops every listed Rust instance so the shared binary can be replaced under them.
|
||||
pub fn stop_rust_instances(manager: &Manager, server_ids: &[String]) -> Result<()> {
|
||||
let _ = server_ids;
|
||||
match manager {
|
||||
#[cfg(unix)]
|
||||
Manager::Systemd => {
|
||||
for id in server_ids {
|
||||
let _ = run("systemctl", &["stop", &rust_systemd_instance(id)]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Manager::WindowsScm => {
|
||||
for id in server_ids {
|
||||
let name = rust_windows_service(id);
|
||||
if windows_service_exists(&name) {
|
||||
stop_windows_service(&name)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts (or restarts) an instance that is already registered, without re-registering it. Used by
|
||||
/// `update` for instances whose own files did not change but whose binary did.
|
||||
pub fn restart_rust_instance(server_id: &str) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
run_ok("systemctl", &["restart", &rust_systemd_instance(server_id)])?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = rust_windows_service(server_id);
|
||||
let start = run("sc.exe", &["start", &name])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(
|
||||
"cannot start {name} (exit code {})",
|
||||
start.status.code().unwrap_or(-1)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes the shared template unit, once the last Rust instance is gone. Linux only; Windows has
|
||||
/// no shared definition.
|
||||
pub fn remove_rust_template(layout: &crate::paths::Layout) -> Option<String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = layout.rust_template_unit();
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
let _ = run("systemctl", &["daemon-reload"]);
|
||||
return Some(format!("removed {}", path.display()));
|
||||
}
|
||||
}
|
||||
let _ = layout;
|
||||
None
|
||||
}
|
||||
|
||||
/// What to do by hand for one instance when no service was registered.
|
||||
pub fn rust_manual_steps(
|
||||
layout: &crate::paths::Layout,
|
||||
server_id: &str,
|
||||
config: &Path,
|
||||
config_protected: bool,
|
||||
) -> String {
|
||||
let binary = &layout.rust_sidecar_bin;
|
||||
let data_dir = layout.rust_data_dir(server_id);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let name = rust_windows_service(server_id);
|
||||
let account = windows_account_for(&name);
|
||||
format!(
|
||||
" From an elevated PowerShell:\n\n \
|
||||
sc.exe create {name} binPath= '{bin_path}' obj= '{account}' start= auto\n \
|
||||
sc.exe failure {name} reset= 86400 actions= restart/5000\n \
|
||||
icacls '{config}' /grant '{account}:(R)'\n \
|
||||
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
|
||||
sc.exe start {name}\n\n{token_note}",
|
||||
bin_path = windows_bin_path(binary, config),
|
||||
config = config.display(),
|
||||
data_dir = data_dir.display(),
|
||||
token_note = if config_protected {
|
||||
" That config file has already been restricted to Administrators and SYSTEM, \
|
||||
because it holds\n the auth token.\n"
|
||||
} else {
|
||||
" That config file holds the auth token, and this run did NOT restrict it. \
|
||||
Lock it down too.\n"
|
||||
},
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = config_protected;
|
||||
format!(
|
||||
" Write this to /etc/systemd/system/{RUST_TEMPLATE_UNIT}:\n\n{unit}\n \
|
||||
Then:\n\n \
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
|
||||
chown {SERVICE_USER} {config}\n \
|
||||
chown -R {SERVICE_USER} {data_dir}\n \
|
||||
systemctl daemon-reload\n \
|
||||
systemctl enable --now {instance}\n\n \
|
||||
Without systemd, run {binary} with RUSTLINK_CONFIG={config}\n \
|
||||
as an unprivileged user that can write {data_dir}.\n",
|
||||
unit = indent(&rust_template_unit_text(
|
||||
binary,
|
||||
&layout.rust_config_dir(),
|
||||
SERVICE_USER
|
||||
)),
|
||||
config = config.display(),
|
||||
data_dir = data_dir.display(),
|
||||
instance = rust_systemd_instance(server_id),
|
||||
binary = binary.display(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared entry points ──────────────────────────────────────────────────────
|
||||
|
||||
/// Stops a running service so its binary can be replaced.
|
||||
@@ -517,8 +795,8 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Manager::WindowsScm => {
|
||||
if windows_service_exists() {
|
||||
stop_windows_service()?;
|
||||
if windows_service_exists(WINDOWS_SERVICE) {
|
||||
stop_windows_service(WINDOWS_SERVICE)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -585,13 +863,13 @@ fn observe_platform(kind: &str, name: &str) -> Status {
|
||||
if kind != "windows-scm" {
|
||||
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
||||
}
|
||||
// Only the service this installer registers is queried by name; anything else would be reading
|
||||
// Only services this installer registers are queried by name; anything else would be reading
|
||||
// another product's service out of a hand-edited record.
|
||||
if name != WINDOWS_SERVICE || !windows_service_exists() {
|
||||
if !is_ours(name) || !windows_service_exists(name) {
|
||||
return Status::absent("not registered with the service manager".to_string());
|
||||
}
|
||||
let state = windows_service_state();
|
||||
let start = windows_start_type();
|
||||
let state = windows_service_state(name);
|
||||
let start = windows_start_type(name);
|
||||
Status {
|
||||
present: true,
|
||||
running: state.contains("RUNNING"),
|
||||
@@ -603,8 +881,8 @@ fn observe_platform(kind: &str, name: &str) -> Status {
|
||||
/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but
|
||||
/// was set to manual start is reported as such rather than as healthy.
|
||||
#[cfg(windows)]
|
||||
fn windows_start_type() -> String {
|
||||
let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else {
|
||||
fn windows_start_type(name: &str) -> String {
|
||||
let Ok(output) = run("sc.exe", &["qc", name]) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
@@ -681,8 +959,11 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
)),
|
||||
}
|
||||
} else if let Some(user) = record.user.as_deref() {
|
||||
// Not "this installer did not create it": a Rust instance's service never owns the account
|
||||
// even when this installer made it, because every instance shares it — the last instance's
|
||||
// removal decides (`rustgame::uninstall`). Said that way, the line is true for both games.
|
||||
out.done.push(format!(
|
||||
"left the {user} account alone — this installer did not create it"
|
||||
"left the {user} account alone — it is not this service's to remove"
|
||||
));
|
||||
}
|
||||
out
|
||||
@@ -699,7 +980,15 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
));
|
||||
return out;
|
||||
}
|
||||
if !windows_service_exists() {
|
||||
if !is_ours(&record.name) {
|
||||
out.problems.push(format!(
|
||||
"the record names a service called {}, which is not one this installer registers - \
|
||||
left alone",
|
||||
record.name
|
||||
));
|
||||
return out;
|
||||
}
|
||||
if !windows_service_exists(&record.name) {
|
||||
out.done
|
||||
.push(format!("{} was already unregistered", record.name));
|
||||
return out;
|
||||
@@ -708,7 +997,7 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
|
||||
// Stopping first is not politeness: `sc delete` on a running service only marks it for deletion,
|
||||
// and the service — and its lock on the binary this uninstall is about to remove — survives
|
||||
// until the process exits.
|
||||
if let Err(error) = stop_windows_service() {
|
||||
if let Err(error) = stop_windows_service(&record.name) {
|
||||
out.problems
|
||||
.push(error.to_string().replace('\n', " ").to_string());
|
||||
} else {
|
||||
@@ -877,6 +1166,47 @@ pub fn protect_config(
|
||||
protect_config_platform(config, data_dir, user, relocated)
|
||||
}
|
||||
|
||||
/// Starts a service that [`register`] or [`register_rust`] has just registered, once
|
||||
/// [`grant_service_access`] has let its account read the config.
|
||||
///
|
||||
/// A no-op on Linux, where registration starts the unit itself: the service user was known before
|
||||
/// the config existed, so `protect_config` had already handed the file to it. On Windows the order
|
||||
/// is the whole point — see `register_windows_named`. The outcome's `state` is refreshed so the
|
||||
/// run reports what the service is doing now, not what it was doing before it was started.
|
||||
pub fn start_registered(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
|
||||
start_registered_platform(outcome, binary, config)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn start_registered_platform(_outcome: &mut Outcome, _binary: &Path, _config: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn start_registered_platform(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
|
||||
let Outcome::Registered { name, state, .. } = outcome else {
|
||||
return Ok(());
|
||||
};
|
||||
let min_sidecar = if name.as_str() == WINDOWS_SERVICE {
|
||||
MIN_SERVICE_SIDECAR
|
||||
} else {
|
||||
MIN_RUST_SERVICE_SIDECAR
|
||||
};
|
||||
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||
let start = run("sc.exe", &["start", name])?;
|
||||
if !start.status.success() && start.status.code() != Some(1056) {
|
||||
anyhow::bail!(windows_start_failure_for(
|
||||
name,
|
||||
min_sidecar,
|
||||
start.status.code().unwrap_or(-1),
|
||||
binary,
|
||||
config
|
||||
));
|
||||
}
|
||||
*state = format!("{}, automatic start", windows_service_state(name));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grants the registered service account access to what it must read and write.
|
||||
///
|
||||
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
|
||||
@@ -954,10 +1284,14 @@ fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outco
|
||||
// Nothing to grant when nothing was registered: the account only exists because `sc create`
|
||||
// made it, and a skipped registration leaves the config locked to Administrators — which is the
|
||||
// right resting state for a host where no service is going to read it.
|
||||
if !outcome.registered() {
|
||||
// The account comes from what was registered: the ServUO service's, or one Rust instance's.
|
||||
let Outcome::Registered {
|
||||
user: Some(account),
|
||||
..
|
||||
} = outcome
|
||||
else {
|
||||
return Ok(());
|
||||
}
|
||||
let account = windows_service_account();
|
||||
};
|
||||
|
||||
run_ok(
|
||||
"icacls",
|
||||
@@ -1155,6 +1489,7 @@ mod tests {
|
||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||
relocated: true,
|
||||
};
|
||||
let outcome = register(&prepare(true), &layout, false).unwrap();
|
||||
|
||||
@@ -163,7 +163,7 @@ pub fn normalize_version(raw: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||
Some(stripped) => PathBuf::from(stripped),
|
||||
None => path,
|
||||
@@ -171,7 +171,7 @@ fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,18 @@ fn port_of(bind: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the shard settings live in the website's admin panel.
|
||||
///
|
||||
/// NOT `/admin/shard`, which is what this printed until 2026-08-24 and what an operator who ran
|
||||
/// an older build still has in their scrollback. Those screens belong to the `uo` MODULE now, and
|
||||
/// a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so the
|
||||
/// page moved. The old path does not 404 — the SPA sends it to the dashboard, which is the worst
|
||||
/// way for a link in a handoff to be wrong, because it looks like it worked.
|
||||
///
|
||||
/// API routes are NOT affected by that rule and keep `/api/v1/admin/shard/*`. This is the SPA URL
|
||||
/// a person types.
|
||||
const ADMIN_SHARD_PATH: &str = "/admin/uo/link";
|
||||
|
||||
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
|
||||
///
|
||||
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
|
||||
@@ -312,12 +324,13 @@ pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
|
||||
Protocol version {protocol}\n \
|
||||
Auth token {token}\n \
|
||||
(also in {config})\n\n\
|
||||
Paste these into Admin → Shard on your Runic Gateway site:\n \
|
||||
{site}/admin/shard\n\n\
|
||||
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
|
||||
{site}{admin_path}\n\n\
|
||||
The token is write-only once saved — the site will never show it back to you.\n",
|
||||
protocol = doc.protocol,
|
||||
token = doc.web.auth_token,
|
||||
config = doc.config_path,
|
||||
admin_path = ADMIN_SHARD_PATH,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,7 +414,7 @@ mod tests {
|
||||
assert!(block.contains(&doc.web.auth_token), "{block}");
|
||||
// The trailing slash on the site URL must not produce a double slash in the link.
|
||||
assert!(
|
||||
block.contains("https://my-site.example/admin/shard"),
|
||||
block.contains("https://my-site.example/admin/uo/link"),
|
||||
"{block}"
|
||||
);
|
||||
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
|
||||
@@ -412,7 +425,10 @@ mod tests {
|
||||
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
||||
// link the operator does not need.
|
||||
let block = handoff(&doc(), "shard", None);
|
||||
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
|
||||
assert!(
|
||||
block.contains("https://<your-site>/admin/uo/link"),
|
||||
"{block}"
|
||||
);
|
||||
assert!(block.contains("4f9c"), "{block}");
|
||||
}
|
||||
|
||||
|
||||
@@ -731,6 +731,7 @@ mod tests {
|
||||
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
|
||||
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
|
||||
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
|
||||
rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||
relocated: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,6 +623,7 @@ mod tests {
|
||||
state_dir: dir.to_path_buf(),
|
||||
data_dir: dir.join("data"),
|
||||
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
|
||||
rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"),
|
||||
relocated: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallReco
|
||||
println!();
|
||||
ui::warn(&format!(
|
||||
"The protocol version changed: {} → {}.\n \
|
||||
Update the Protocol version field in Admin → Shard on your website. Nothing else \
|
||||
Update the Protocol version field in Admin → Shard (uo-link) on your website. Nothing else \
|
||||
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
|
||||
website still set to\n {} with 409 rather than mis-parsing it.",
|
||||
previous_protocol.unwrap_or(bundle.protocol),
|
||||
|
||||
45
tests/fixtures/published-bundle-2026.09.15.json
vendored
Normal file
45
tests/fixtures/published-bundle-2026.09.15.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"bundle": "2026.09.15",
|
||||
"generated": "2026-09-15T00:12:17Z",
|
||||
"protocol": 8,
|
||||
"link": {
|
||||
"repo": "RunicGateway/link",
|
||||
"tag": "v2.3.0",
|
||||
"version": "2.3.0",
|
||||
"protocol": 8,
|
||||
"assets": {
|
||||
"linux-aarch64": {
|
||||
"name": "uo-link-sidecar-linux-aarch64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
|
||||
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"name": "uo-link-sidecar-linux-x86_64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
|
||||
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"name": "uo-link-sidecar-windows-x86_64.exe",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
|
||||
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overlay": {
|
||||
"repo": "RunicGateway/servuo-plugins",
|
||||
"tag": "v1.3.0",
|
||||
"version": "1.3.0",
|
||||
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
|
||||
"protocol": 8,
|
||||
"servuo": {
|
||||
"min_version": "57.4",
|
||||
"patches_verified_against": "57.4"
|
||||
},
|
||||
"asset": {
|
||||
"name": "runicgateway-overlay-1.3.0.tar.gz",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
|
||||
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
tests/fixtures/published-bundle-v2-servuo.json
vendored
Normal file
47
tests/fixtures/published-bundle-v2-servuo.json
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"game": "servuo",
|
||||
"bundle": "2026.09.15",
|
||||
"generated": "2026-09-26T03:55:28Z",
|
||||
"protocol": 8,
|
||||
"sidecar": {
|
||||
"repo": "RunicGateway/link",
|
||||
"tag": "v2.3.0",
|
||||
"version": "2.3.0",
|
||||
"protocol": 8,
|
||||
"assets": {
|
||||
"linux-aarch64": {
|
||||
"name": "uo-link-sidecar-linux-aarch64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
|
||||
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"name": "uo-link-sidecar-linux-x86_64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
|
||||
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"name": "uo-link-sidecar-windows-x86_64.exe",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
|
||||
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
|
||||
}
|
||||
}
|
||||
},
|
||||
"payload": {
|
||||
"kind": "overlay",
|
||||
"repo": "RunicGateway/servuo-plugins",
|
||||
"tag": "v1.3.0",
|
||||
"version": "1.3.0",
|
||||
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
|
||||
"protocol": 8,
|
||||
"asset": {
|
||||
"name": "runicgateway-overlay-1.3.0.tar.gz",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
|
||||
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
|
||||
},
|
||||
"compat": {
|
||||
"min_version": "57.4",
|
||||
"patches_verified_against": "57.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
57
tests/fixtures/rust-bundle-v2.json
vendored
Normal file
57
tests/fixtures/rust-bundle-v2.json
vendored
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": 2,
|
||||
"game": "rust",
|
||||
"bundle": "2026.09.26",
|
||||
"generated": "2026-09-26T04:06:03Z",
|
||||
"protocol": 12,
|
||||
"sidecar": {
|
||||
"repo": "RunicGateway/Rust-Link",
|
||||
"tag": "v0.1.0",
|
||||
"version": "0.1.0",
|
||||
"protocol": 12,
|
||||
"assets": {
|
||||
"linux-x86_64": {
|
||||
"name": "rust-link-sidecar-linux-x86_64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-linux-x86_64",
|
||||
"sha256": "3f69c56a78ea0f9a886286078744710a983b0058b61cd0be0e69a2b4db312bfe"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"name": "rust-link-sidecar-windows-x86_64.exe",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-windows-x86_64.exe",
|
||||
"sha256": "e8cc61bb5681ead44baf2dcd661c323630b53a84b834447410a0bc1e5b1b1e8c"
|
||||
}
|
||||
},
|
||||
"launcher": {
|
||||
"name": "with-sidecar.sh",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/with-sidecar.sh",
|
||||
"sha256": "b1d785873f0528663f12cf1b008ec2581ba7bd7282395203d8d5915e19ceb712"
|
||||
}
|
||||
},
|
||||
"payload": {
|
||||
"kind": "plugin",
|
||||
"repo": "RunicGateway/Rust-Plugins",
|
||||
"tag": "v0.2.3",
|
||||
"version": "0.2.3",
|
||||
"commit": "deadbeef",
|
||||
"protocol": 12,
|
||||
"compat": {
|
||||
"frameworks": {
|
||||
"oxide": {
|
||||
"min_version": "2.0.7585"
|
||||
},
|
||||
"carbon": {
|
||||
"min_version": "2.0.259"
|
||||
}
|
||||
},
|
||||
"requires_plugins": [
|
||||
"Kits",
|
||||
"ZoneManager"
|
||||
]
|
||||
},
|
||||
"asset": {
|
||||
"name": "runicgateway-rust-plugin-0.2.3.tar.gz",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases/download/v0.2.3/runicgateway-rust-plugin-0.2.3.tar.gz",
|
||||
"sha256": "2ca6269dba329b06d0d36e5786f6f88c10757c38a0b1dd7efdbcd4e803b94c78"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user