Compare commits
37 Commits
8e5258358c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 84c1106d58 | |||
| d6f0bcf2cf | |||
| 065edab8cd | |||
| 09eafe2911 | |||
| ea7e491ba3 | |||
| 7db58031c7 | |||
| ae53546446 | |||
| fd59a74912 |
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,42 +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 ─────────────────────────────────────────────────────────
|
# ── What a bundle is ─────────────────────────────────────────────────────────
|
||||||
# The bundle IS the compat matrix. The installer does not hardcode component
|
# 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
|
# 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
|
# document naming an exact, protocol-checked combination of a sidecar release
|
||||||
# and a servuo-plugins overlay release, and installs that. Because the bundle is
|
# 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
|
# 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
|
# 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
|
# up a sidecar patch, and this repo does not accumulate releases whose code is
|
||||||
# byte-identical.
|
# byte-identical.
|
||||||
#
|
#
|
||||||
# ── Where it is published, and why not as a release ──────────────────────────
|
# ── The streams ──────────────────────────────────────────────────────────────
|
||||||
# Bundles are COMMITTED to this repo under bundles/:
|
# Bundles are COMMITTED to this repo, on their own `bundles` branch:
|
||||||
#
|
#
|
||||||
# bundles/current.json the bundle the installer uses by default
|
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
|
||||||
# bundles/bundle-<tag>.json every bundle ever published, kept for --bundle
|
# 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.
|
||||||
#
|
#
|
||||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
|
# The installer's fetches are plain anonymous raw URLs on a public repo:
|
||||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json
|
#
|
||||||
|
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.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 obvious alternative — one Gitea release per bundle — was rejected because
|
# The obvious alternative — one Gitea release per bundle — was rejected because
|
||||||
# it collides with this repo's own product. release.yml publishes the installer
|
# it collides with this repo's own product. release.yml publishes the installer
|
||||||
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
|
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
|
||||||
# newest regardless of kind; interleaving bundle releases would make "latest"
|
# newest regardless of kind; interleaving bundle releases would make "latest"
|
||||||
# intermittently resolve to a release containing no installer binary. Committing
|
# intermittently resolve to a release containing no installer binary.
|
||||||
# also gets a reviewable diff and a git history of the compat matrix for free.
|
|
||||||
#
|
#
|
||||||
# The push to `main` needs no new branch-protection exception: release.yml's
|
# `main` is never pushed to by this workflow. (release.yml does not push to it
|
||||||
# version-bump commit already requires REGISTRY_USER to be able to push here.
|
# 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) ──────────────────────────────────────────────────
|
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
||||||
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
|
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
|
||||||
# as their final step, so a new release recomposes the
|
# Rust-Plugins' release workflows as their final step,
|
||||||
# bundle immediately.
|
# so a new release recomposes the bundle immediately.
|
||||||
# schedule (nightly) — recomputes from whatever the latest releases actually
|
# schedule (nightly) — recomputes from whatever the latest releases actually
|
||||||
# are, so a missed or failed dispatch self-heals instead
|
# are, so a missed or failed dispatch self-heals instead
|
||||||
# of silently pinning operators to a stale sidecar.
|
# of silently pinning operators to a stale sidecar.
|
||||||
@@ -47,11 +69,11 @@
|
|||||||
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
|
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
|
||||||
# REGISTRY_USER — Gitea username the token below belongs to
|
# REGISTRY_USER — Gitea username the token below belongs to
|
||||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
|
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
|
||||||
# on THIS repo (to push the bundle commit) and on
|
# on THIS repo (to push the bundle commit) and on the four
|
||||||
# RunicGateway/link + RunicGateway/servuo-plugins (to fire
|
# component repos (to fire their release workflows for the
|
||||||
# their release workflows for the stale case below). A token
|
# stale case below). A token without the latter degrades to
|
||||||
# without the latter degrades to a warning, not a failure —
|
# a warning, not a failure — the bundle it composes is still
|
||||||
# the bundle it composes is still valid.
|
# valid.
|
||||||
#
|
#
|
||||||
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
|
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
|
||||||
|
|
||||||
@@ -65,7 +87,7 @@ on:
|
|||||||
|
|
||||||
# Two component releases landing together dispatch this twice. Serialize rather
|
# Two component releases landing together dispatch this twice. Serialize rather
|
||||||
# than cancel: a cancelled run is a bundle that never got composed, and the
|
# 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:
|
concurrency:
|
||||||
group: compose-bundle
|
group: compose-bundle
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -73,27 +95,49 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
GITEA_HOST: gitea.whitlocktech.com
|
GITEA_HOST: gitea.whitlocktech.com
|
||||||
REPO: RunicGateway/installer
|
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:
|
jobs:
|
||||||
compose:
|
compose:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
# Full history: the push step rebases onto main if release.yml's version
|
# Full history: the publish step rebases onto the bundles branch if another
|
||||||
# bump landed while this job was composing, and a depth-1 clone has no
|
# run landed while this one was composing, and a depth-1 clone has no base
|
||||||
# base to rebase onto.
|
# to rebase onto.
|
||||||
- name: Check out the bundles directory
|
- name: Check out the repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
# 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
|
||||||
|
git config user.name "installer-ci"
|
||||||
|
git config user.email "ci@whitlocktech.com"
|
||||||
|
# `prune` matters on a re-run in an existing checkout: removing the
|
||||||
|
# directory leaves the worktree registered, and `worktree add` then
|
||||||
|
# refuses the path. CI checks out fresh every time, so this only shows
|
||||||
|
# up when driving the job by hand — which is how it is tested.
|
||||||
|
rm -rf published
|
||||||
|
git worktree prune
|
||||||
|
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: $(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
|
||||||
|
# nothing to do with the compat matrix.
|
||||||
|
EMPTY_TREE="$(git hash-object -t tree /dev/null)"
|
||||||
|
ROOT="$(git commit-tree "$EMPTY_TREE" -m 'chore(bundle): start the bundles branch')"
|
||||||
|
git worktree add -B bundles published "$ROOT"
|
||||||
|
echo "==> bundles branch does not exist yet; it will be created by the first publish"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Install jq and curl
|
- name: Install jq and curl
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -102,266 +146,27 @@ jobs:
|
|||||||
$SUDO apt-get update -qq
|
$SUDO apt-get update -qq
|
||||||
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
|
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
|
||||||
|
|
||||||
# ── Resolve the two component releases ───────────────────────────────
|
# ── Resolve, gate and compose every stream ───────────────────────────
|
||||||
# Read ANONYMOUSLY, on purpose. These are exactly the requests the shipped
|
# Release resolution, gate 2 (every asset present and matching its
|
||||||
# installer makes on an operator's machine, which has no Gitea credentials
|
# SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag
|
||||||
# (PLAN.md §1: no git and no token on the shard host). Authenticating here
|
# against the payload's declared protocol) and the documents all live in
|
||||||
# would hide a repo flipped to private until an operator hit it; this way
|
# the script. Each game composes independently: a Rust failure still lets
|
||||||
# the visibility regression fails CI instead.
|
# a ServUO bundle publish, and this step goes red afterwards. A game whose
|
||||||
- name: Resolve the latest release of each component
|
# repos have never released composes nothing, and that is not a failure.
|
||||||
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.
|
|
||||||
#
|
#
|
||||||
# `sha256sum -c` without --ignore-missing fails when SHA256SUMS names a
|
# The outputs are written even when the script fails, so the publish step
|
||||||
# file the release does not actually carry. The reverse — an asset with no
|
# (on `always()`) can still ship the game that did compose.
|
||||||
# SHA256SUMS entry — is checked separately below, because -c would not
|
- name: Compose the bundles
|
||||||
# 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 (aarch64, macOS) surfaces here as a
|
|
||||||
# red run, rather than being silently dropped 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 ;;
|
|
||||||
*-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 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 both"
|
|
||||||
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
|
|
||||||
id: compose
|
id: compose
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
LINK_ASSETS="$(jq -R -s '
|
rc=0
|
||||||
split("\n") | map(select(length > 0)) | map(split("\t"))
|
PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \
|
||||||
| map({ (.[0]): { name: .[1], url: .[2], sha256: .[3] } }) | add
|
bash .gitea/scripts/compose-bundles.sh || rc=$?
|
||||||
' work/link-platforms.tsv)"
|
if [ -f work/result.env ]; then
|
||||||
|
grep '^changed=' work/result.env >> "$GITHUB_OUTPUT"
|
||||||
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 bundles/current.json ]; then
|
|
||||||
if jq -S 'del(.bundle, .generated)' bundles/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
|
|
||||||
fi
|
fi
|
||||||
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
exit "$rc"
|
||||||
|
|
||||||
if [ "$CHANGED" = false ]; then
|
|
||||||
echo "==> identical to bundles/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 "bundles/bundle-${TAG}.json" ]; do
|
|
||||||
N=$((N+1)); TAG="${BASE}.${N}"
|
|
||||||
done
|
|
||||||
|
|
||||||
mkdir -p bundles
|
|
||||||
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 > "bundles/bundle-${TAG}.json"
|
|
||||||
cp "bundles/bundle-${TAG}.json" bundles/current.json
|
|
||||||
|
|
||||||
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "==> composed bundle ${TAG}"
|
|
||||||
|
|
||||||
# ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ───────
|
# ── 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
|
# Each component self-releases on merge to its own main, so by the time
|
||||||
@@ -376,10 +181,14 @@ jobs:
|
|||||||
# counts would report every README fix as a stuck release and re-dispatch
|
# counts would report every README fix as a stuck release and re-dispatch
|
||||||
# a workflow that correctly declines to run, every single night.
|
# a workflow that correctly declines to run, every single night.
|
||||||
#
|
#
|
||||||
# This runs even when the bundle is unchanged: an unchanged bundle is the
|
# This runs even when the bundle is unchanged, or a game failed: an
|
||||||
# exact symptom of a component release that never happened.
|
# 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
|
- name: Check for components with unreleased work, and dispatch them
|
||||||
id: stale
|
id: stale
|
||||||
|
if: always()
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
env:
|
env:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
@@ -387,14 +196,17 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||||
# Warnings go to a FILE, not a step output. The job summary below
|
# Warnings go to a FILE, not a step output. The job summary below
|
||||||
# reads it with `cat`; interpolating a multi-line `${{ }}` value into
|
# reads it with `cat`; interpolating a multi-line template value into
|
||||||
# a shell string there would let any character in a commit-derived
|
# a shell string there would let any character in a commit-derived
|
||||||
# message change what that script does.
|
# 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
|
: > work/stale-warnings.md
|
||||||
|
[ -f work/stale.tsv ] || exit 0
|
||||||
|
|
||||||
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
|
while IFS="$(printf '\t')" read -r SLUG TAG; do
|
||||||
"${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do
|
[ -n "$SLUG" ] || continue
|
||||||
SLUG="${pair%:*}"; TAG="${pair##*:}"
|
|
||||||
if ! curl -sSfL -o work/compare.json \
|
if ! curl -sSfL -o work/compare.json \
|
||||||
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
|
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
|
||||||
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
|
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
|
||||||
@@ -433,7 +245,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
echo "==> ${SLUG}: nothing releasable after ${TAG}"
|
echo "==> ${SLUG}: nothing releasable after ${TAG}"
|
||||||
fi
|
fi
|
||||||
done
|
done < work/stale.tsv
|
||||||
|
|
||||||
# ── Publish ──────────────────────────────────────────────────────────
|
# ── Publish ──────────────────────────────────────────────────────────
|
||||||
# Preflighted for the same reason the release workflows are: actions/
|
# Preflighted for the same reason the release workflows are: actions/
|
||||||
@@ -441,8 +253,11 @@ jobs:
|
|||||||
# config, so a push can succeed on that leftover even with the secrets
|
# 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
|
# empty. That makes "the push worked" no evidence at all that the repo is
|
||||||
# configured, and the failure surfaces somewhere less obvious later.
|
# 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
|
- name: Verify publish credentials are configured
|
||||||
if: ${{ steps.compose.outputs.changed == 'true' }}
|
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
@@ -452,45 +267,48 @@ jobs:
|
|||||||
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
|
[ -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"
|
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
|
||||||
if [ -n "$MISSING" ]; then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Publish credentials present."
|
echo "Publish credentials present."
|
||||||
|
|
||||||
- name: Commit and push the bundle
|
- name: Commit and push the bundles
|
||||||
if: ${{ steps.compose.outputs.changed == 'true' }}
|
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
TAG="${{ steps.compose.outputs.bundle_tag }}"
|
|
||||||
# Secrets can arrive with a trailing newline depending on how they were
|
# Secrets can arrive with a trailing newline depending on how they were
|
||||||
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
|
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
|
||||||
# cannot be parsed").
|
# cannot be parsed").
|
||||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
git config user.name "installer-ci"
|
|
||||||
git config user.email "ci@whitlocktech.com"
|
|
||||||
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||||
|
|
||||||
git add bundles
|
# The subject is the script's ("publish servuo 2026.09.27, rust
|
||||||
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]"
|
# 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
|
||||||
|
|
||||||
# The checkout is a detached snapshot of main; push the commit at HEAD
|
cd published
|
||||||
# to the branch the installer reads its raw URLs from. release.yml
|
git add -A
|
||||||
# pushes its version-bump commit to the same branch, so losing the
|
git commit -F ../work/commit-msg.txt
|
||||||
# race is normal rather than exceptional — rebase and retry once
|
|
||||||
# instead of failing and leaving the bundle unpublished until the
|
# Two runs can compose at once — a component release dispatches this
|
||||||
# next cron. Only bundles/ is touched here, so a rebase over a bump
|
# while the nightly cron is mid-flight — so losing the race is normal
|
||||||
# commit cannot conflict.
|
# rather than exceptional. Rebase and retry once instead of failing and
|
||||||
if ! git push origin "HEAD:main"; then
|
# leaving the bundle unpublished until tomorrow. Every file here is a
|
||||||
echo "::warning::push rejected (main moved during compose) — rebasing and retrying once"
|
# bundle nobody else edits, and a bundle tag names exactly one matrix,
|
||||||
git fetch origin main
|
# so a rebase cannot conflict.
|
||||||
git rebase origin/main
|
if ! git push origin bundles; then
|
||||||
git push origin "HEAD:main"
|
echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once"
|
||||||
|
git fetch origin bundles
|
||||||
|
git rebase origin/bundles
|
||||||
|
git push origin bundles
|
||||||
fi
|
fi
|
||||||
echo "==> published bundles/bundle-${TAG}.json and bundles/current.json"
|
echo "==> published on the bundles branch:"
|
||||||
|
sed 's/^/ /' ../work/published.txt
|
||||||
|
|
||||||
- name: Job summary
|
- name: Job summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -499,16 +317,17 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "## Bundle compose"
|
echo "## Bundle compose"
|
||||||
echo
|
echo
|
||||||
echo "| Component | Release | Protocol |"
|
if [ -s work/summary.md ]; then
|
||||||
echo "|---|---|---|"
|
cat work/summary.md
|
||||||
echo "| uo-link sidecar | \`${{ steps.resolve.outputs.link_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
|
else
|
||||||
echo "| servuo-plugins overlay | \`${{ steps.resolve.outputs.overlay_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
|
echo "Compose did not complete — see the failing step above."
|
||||||
echo
|
fi
|
||||||
case "${{ steps.compose.outputs.changed }}" in
|
if [ -s work/published.txt ]; then
|
||||||
true) echo "**Published \`${{ steps.compose.outputs.bundle_tag }}\`** → \`bundles/current.json\`" ;;
|
echo
|
||||||
false) echo "No change — \`bundles/current.json\` already names this combination." ;;
|
echo "Written to the bundles branch:"
|
||||||
*) echo "Compose did not complete — see the failing step above." ;;
|
echo
|
||||||
esac
|
sed 's/^/- /' work/published.txt
|
||||||
|
fi
|
||||||
if [ -s work/stale-warnings.md ]; then
|
if [ -s work/stale-warnings.md ]; then
|
||||||
echo
|
echo
|
||||||
echo "### ⚠ Components with unreleased work"
|
echo "### ⚠ Components with unreleased work"
|
||||||
|
|||||||
@@ -43,11 +43,11 @@
|
|||||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer):
|
# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer):
|
||||||
# REGISTRY_USER — Gitea username the token below belongs to
|
# REGISTRY_USER — Gitea username the token below belongs to
|
||||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push
|
# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push
|
||||||
# the bump commit + tag and create the release.
|
# the release tag and create the release.
|
||||||
# Also: `main` must accept a direct push from that user (disable branch
|
|
||||||
# protection for it, or add it as an exception) — the bump commit lands on main.
|
|
||||||
#
|
#
|
||||||
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
|
# `main` needs NO push exception: this workflow tags and publishes, and never
|
||||||
|
# writes to a branch. Keeping it that way is deliberate — a first release that
|
||||||
|
# depends on a write to a protected branch fails at the worst possible moment.
|
||||||
|
|
||||||
name: Release installer
|
name: Release installer
|
||||||
|
|
||||||
@@ -73,7 +73,8 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
|
# Vestigial since this workflow stopped writing a bump commit, and kept as
|
||||||
|
# belt-and-braces in case one ever returns.
|
||||||
# Quoted because the expression contains a colon (`chore(release):`), which an
|
# Quoted because the expression contains a colon (`chore(release):`), which an
|
||||||
# unquoted YAML scalar would misparse as a mapping value.
|
# unquoted YAML scalar would misparse as a mapping value.
|
||||||
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||||
@@ -164,6 +165,39 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
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
|
# Changelog range. A recovery run has nothing after the tag, so
|
||||||
# summarize what the tag itself contains rather than emitting an empty
|
# summarize what the tag itself contains rather than emitting an empty
|
||||||
# list: the range that produced it, i.e. previous-tag..this-tag.
|
# list: the range that produced it, i.e. previous-tag..this-tag.
|
||||||
@@ -246,8 +280,13 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||||
$SUDO apt-get update
|
$SUDO apt-get update
|
||||||
|
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only
|
||||||
|
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust
|
||||||
|
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling
|
||||||
|
# C, on a missing bits/libc-header-start.h.
|
||||||
$SUDO apt-get install -y --no-install-recommends \
|
$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 gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
|
||||||
|
curl ca-certificates git jq
|
||||||
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
if ! command -v cargo >/dev/null 2>&1; then
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||||
@@ -295,6 +334,18 @@ jobs:
|
|||||||
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
||||||
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
|
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, cross)
|
||||||
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||||
|
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
|
||||||
|
AR_aarch64_unknown_linux_gnu: aarch64-linux-gnu-ar
|
||||||
|
run: cargo build --release --locked --target "${ARM64_TARGET}"
|
||||||
|
|
||||||
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
|
||||||
# SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3),
|
# 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.
|
# so it ships with every release and the docs lead with the verify command.
|
||||||
@@ -312,33 +363,45 @@ jobs:
|
|||||||
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||||
|
|
||||||
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
|
||||||
- name: Commit version bump and push tag
|
# Tag only — `main` is never pushed to.
|
||||||
|
#
|
||||||
|
# This step used to commit the version bump back to main first, and it has
|
||||||
|
# never executed in any repo that carries it: an EMPTY template expression
|
||||||
|
# written literally in the comment below (the `$`+`{{ }}` token, spelled
|
||||||
|
# out here for that reason) makes the runner fail to build the script and
|
||||||
|
# skip the step WITHOUT failing the job. link/release.yml carried the same
|
||||||
|
# bug for six releases, which is why its Cargo.toml still says 0.1.0 while
|
||||||
|
# its tags reach v1.1.1 — the release API creates the tag when it
|
||||||
|
# publishes, so the pipeline worked by accident.
|
||||||
|
#
|
||||||
|
# It also would have been declined if it had run: `main` is protected, and
|
||||||
|
# the bundle job proved that on 2026-08-05 (`pre-receive hook declined`).
|
||||||
|
# A first release must not depend on a write to a protected branch.
|
||||||
|
#
|
||||||
|
# So the tag is the version, as in servuo-plugins. The version is still
|
||||||
|
# written into Cargo.toml before building, so a released binary
|
||||||
|
# self-reports correctly; it is simply not committed back. The next
|
||||||
|
# version is computed from the newest tag, never from the file.
|
||||||
|
- name: Push the release tag
|
||||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
VERSION="${{ steps.plan.outputs.version }}"
|
|
||||||
TAG="${{ steps.plan.outputs.tag }}"
|
TAG="${{ steps.plan.outputs.tag }}"
|
||||||
# Secrets can arrive with a trailing newline (depending on how they were
|
# Secrets can arrive with a trailing newline (depending on how they were
|
||||||
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
|
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
|
||||||
# be parsed"). Strip line breaks before building the URL. Passing them via
|
# be parsed"). Strip line breaks before building the URL. They are passed
|
||||||
# env (not inline ${{ }}) also keeps a newline from breaking this script.
|
# via env rather than interpolated into this script, so a newline cannot
|
||||||
|
# break it — do NOT write a template token literally in a comment here,
|
||||||
|
# or the runner will skip this step without failing the job.
|
||||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
git config user.name "installer-ci"
|
git config user.name "installer-ci"
|
||||||
git config user.email "ci@whitlocktech.com"
|
git config user.email "ci@whitlocktech.com"
|
||||||
git remote set-url origin \
|
git remote set-url origin \
|
||||||
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
|
||||||
|
|
||||||
git add Cargo.toml Cargo.lock
|
|
||||||
if ! git diff --cached --quiet; then
|
|
||||||
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
|
|
||||||
git push origin "HEAD:main"
|
|
||||||
else
|
|
||||||
echo "Version unchanged (first release) — no bump commit needed."
|
|
||||||
fi
|
|
||||||
# The tag may already exist when finishing a run that died after
|
# The tag may already exist when finishing a run that died after
|
||||||
# tagging (see the plan step). `git tag` on an existing name fails
|
# tagging (see the plan step). `git tag` on an existing name fails
|
||||||
# under `set -e`; pushing an identical existing tag is a harmless
|
# under `set -e`; pushing an identical existing tag is a harmless
|
||||||
@@ -365,17 +428,74 @@ jobs:
|
|||||||
# corrupt the Authorization header.
|
# corrupt the Authorization header.
|
||||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||||
|
|
||||||
REL_ID="$(curl -sSf -X POST "${API}/releases" \
|
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
||||||
-H "Authorization: token ${CI_TOKEN}" \
|
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
|
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
|
||||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
|
# second after the tag push and Gitea answered 500, having not finished
|
||||||
| jq -r '.id')"
|
# 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})"
|
echo "Created release ${TAG} (id=${REL_ID})"
|
||||||
|
|
||||||
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
|
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}" \
|
-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}"
|
echo " uploaded ${f}"
|
||||||
done
|
done
|
||||||
|
|||||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -402,6 +402,7 @@ name = "runicgateway-installer"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flate2",
|
"flate2",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ sha2 = "0.11"
|
|||||||
# host, which is the whole point of shipping the plugin as a release tarball.
|
# host, which is the whole point of shipping the plugin as a release tarball.
|
||||||
sha1 = "0.11"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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
|
its existing release/start scripts; the installer never writes a launcher and
|
||||||
never restarts the shard.
|
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
|
## 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
|
The binary does everything
|
||||||
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
[`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
|
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
|
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 —
|
`link`, and the bundle-manifest CI here), all of which have landed —
|
||||||
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
|
[`bundles/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json)
|
||||||
sidecar + overlay combination, recomposed on every component release and nightly
|
names the current protocol-checked sidecar + overlay combination, recomposed on
|
||||||
(see [`bundles/README.md`](bundles/README.md)).
|
every component release and nightly (see [`bundles/README.md`](bundles/README.md)).
|
||||||
|
|
||||||
| Phase | State |
|
**`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
|
||||||
| 0 — prerequisites in the other repos | ✅ merged |
|
operator. Both cutover gates were met first: Phase 5 (its scope settled as **no
|
||||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
`.deb` and no MSI** — either would give the sidecar binary, its service unit and
|
||||||
| 2 — uo-link install + service registration | ✅ on `edge` |
|
its service account a second owner beside this tool), and the **Windows SCM half
|
||||||
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
|
verified on a real host**. That second one earned its place: `sc start` failed
|
||||||
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
|
with 1053 on its first real run and needed a sidecar fix (link#29) before it
|
||||||
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
|
passed 13/13.
|
||||||
|
|
||||||
**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`.
|
|
||||||
|
|
||||||
## Related repos
|
## 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). |
|
| `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. |
|
| `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
|
## Design constraints worth knowing up front
|
||||||
|
|
||||||
- **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and
|
- **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and
|
||||||
|
|||||||
@@ -3,35 +3,68 @@
|
|||||||
**These files are generated. Do not edit them by hand.**
|
**These files are generated. Do not edit them by hand.**
|
||||||
|
|
||||||
A *bundle* names one exact, protocol-checked combination of the two components the installer
|
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
|
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.**
|
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
|
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.
|
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.
|
||||||
|
|
||||||
## Layout
|
## 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). 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 |
|
| File | What it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `current.json` | The bundle the installer uses by default. Always a copy of the newest `bundle-*.json`. |
|
| `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. |
|
| `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
|
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
|
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
|
||||||
exactly one matrix.
|
exactly one matrix.
|
||||||
|
|
||||||
|
**Why a branch rather than `main`.** `main` is protected and this job is unattended: the pre-receive
|
||||||
|
hook declines a push from CI, which is not something a nightly cron can resolve. A branch of its own
|
||||||
|
keeps everything the original choice was for — a reviewable diff, a git history of the compat
|
||||||
|
matrix, plain anonymous raw URLs, no credentials on the shard host — and needs no protection
|
||||||
|
exception. Whitelisting a scheduled job for pushes to the default branch would buy nothing this does
|
||||||
|
not.
|
||||||
|
|
||||||
|
This directory keeps the documentation, because that is what belongs on `main`: the branch carries
|
||||||
|
data, and only data.
|
||||||
|
|
||||||
## How the installer fetches these
|
## How the installer fetches these
|
||||||
|
|
||||||
Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials
|
Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials
|
||||||
(`PLAN.md` §1), so nothing here may require auth:
|
(`PLAN.md` §1), so nothing here may require auth:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
|
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
|
||||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.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
|
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
|
||||||
@@ -39,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
|
kind — interleaving the two would make "latest" intermittently resolve to a release containing no
|
||||||
installer binary.
|
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
|
`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.
|
protocol) or to either component's release version. All three move independently.
|
||||||
@@ -56,8 +89,9 @@ protocol) or to either component's release version. All three move independently
|
|||||||
"tag": "v1.1.0",
|
"tag": "v1.1.0",
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"protocol": 3,
|
"protocol": 3,
|
||||||
"assets": { // per-platform: the installer runs on both
|
"assets": { // per-platform: the installer runs on each
|
||||||
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
|
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
|
||||||
|
"linux-aarch64": { "name": "…", "url": "…", "sha256": "…" },
|
||||||
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
|
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -90,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
|
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
|
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.
|
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
|
//! ## Scoped by what cannot be fetched again
|
||||||
//!
|
//!
|
||||||
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
|
//! 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
|
//! re-downloadable and hash-named in the bundle, and the sidecar's database is overwhelmingly a
|
||||||
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
|
//! projection of shard state that the sweeps repopulate. Backing it up would be bulk with little
|
||||||
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
|
//! recovery value, and the bulk is not free: it would bury the two things that matter.
|
||||||
//! 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:
|
//! What a run can destroy irrecoverably is short:
|
||||||
//!
|
//!
|
||||||
|
|||||||
420
src/bundle.rs
420
src/bundle.rs
@@ -14,6 +14,7 @@
|
|||||||
//! half-understood.
|
//! half-understood.
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -27,9 +28,6 @@ use std::collections::BTreeMap;
|
|||||||
const BUNDLE_BASE: &str =
|
const BUNDLE_BASE: &str =
|
||||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
|
"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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Bundle {
|
pub struct Bundle {
|
||||||
pub schema: u32,
|
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 {
|
pub fn url_for(tag: Option<&str>) -> String {
|
||||||
match tag {
|
match tag {
|
||||||
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
|
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)> {
|
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
|
||||||
let url = url_for(tag);
|
let v2 = fetch_v2(Game::ServUo, tag);
|
||||||
let body = crate::net::get_text(&url).with_context(|| match tag {
|
let (body, url) = match v2 {
|
||||||
Some(tag) => format!(
|
Ok(body) => (body, v2_url(Game::ServUo, tag)),
|
||||||
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
|
Err(v2_error) => {
|
||||||
against {BUNDLE_BASE}/"
|
let url = url_for(tag);
|
||||||
),
|
let body = crate::net::get_text(&url)
|
||||||
None => "cannot read the current bundle manifest".to_string(),
|
.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)?;
|
let bundle = parse(&body)?;
|
||||||
Ok((bundle, url))
|
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> {
|
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")?;
|
.context("the bundle manifest is not in the shape this installer understands")?;
|
||||||
|
let bundle: Bundle = match head.schema {
|
||||||
if bundle.schema != SUPPORTED_SCHEMA {
|
1 => serde_json::from_str(body)
|
||||||
bail!(
|
.context("the bundle manifest is not in the shape this installer understands")?,
|
||||||
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
|
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.",
|
Update the installer — the bundle format changed.",
|
||||||
bundle.bundle,
|
head.bundle,
|
||||||
bundle.schema
|
),
|
||||||
);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
|
// 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
|
// 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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -262,9 +563,78 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
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();
|
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]
|
#[test]
|
||||||
|
|||||||
141
src/cli.rs
141
src/cli.rs
@@ -56,6 +56,14 @@ pub enum PatchChoice {
|
|||||||
No,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Mode {
|
pub enum Mode {
|
||||||
Run(Command),
|
Run(Command),
|
||||||
@@ -66,6 +74,15 @@ pub enum Mode {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
pub mode: Mode,
|
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.
|
/// `--verify`: report every change that would be made, write nothing.
|
||||||
pub verify: bool,
|
pub verify: bool,
|
||||||
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
|
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
|
||||||
@@ -77,7 +94,7 @@ pub struct Cli {
|
|||||||
pub patches_unsupported_servuo: bool,
|
pub patches_unsupported_servuo: bool,
|
||||||
/// `--host <name>`: the hostname to print in the website URLs.
|
/// `--host <name>`: the hostname to print in the website URLs.
|
||||||
pub host: Option<String>,
|
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>,
|
pub site_url: Option<String>,
|
||||||
/// `--yes`: assume the default answer to every prompt.
|
/// `--yes`: assume the default answer to every prompt.
|
||||||
pub assume_yes: bool,
|
pub assume_yes: bool,
|
||||||
@@ -92,6 +109,10 @@ impl Default for Cli {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
mode: Mode::Help,
|
mode: Mode::Help,
|
||||||
|
game: Game::ServUo,
|
||||||
|
rust: None,
|
||||||
|
server_id: None,
|
||||||
|
web_port: None,
|
||||||
verify: false,
|
verify: false,
|
||||||
servuo: None,
|
servuo: None,
|
||||||
bundle: None,
|
bundle: None,
|
||||||
@@ -107,20 +128,23 @@ impl Default for Cli {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub const USAGE: &str = "\
|
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]
|
Usage: runicgateway-installer <COMMAND> [OPTIONS]
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
install Deploy the plugin overlay, install the uo-link sidecar and its
|
install Deploy the game-side plugin (the ServUO overlay, or the Rust
|
||||||
service, record what was deployed, and print the values the
|
plugin), install the sidecar and its service, record what was
|
||||||
website needs.
|
deployed, and print the values the website needs.
|
||||||
doctor Diagnose an existing deployment end to end.
|
doctor Diagnose an existing deployment end to end.
|
||||||
update Re-resolve the bundle and move both components to it.
|
update Re-resolve the bundle and move both components to it.
|
||||||
uninstall Remove what the installer exclusively owns. Never edits the
|
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:
|
Options:
|
||||||
|
--game <servuo|rust> Which game's shard side. Default servuo.
|
||||||
--verify install, update. Dry run: report every
|
--verify install, update. Dry run: report every
|
||||||
change that would be made, write nothing.
|
change that would be made, write nothing.
|
||||||
--servuo <PATH> install, doctor, update. The ServUO root,
|
--servuo <PATH> install, doctor, update. The ServUO root,
|
||||||
@@ -137,7 +161,7 @@ Options:
|
|||||||
--host <NAME> install. The hostname to print in the
|
--host <NAME> install. The hostname to print in the
|
||||||
website URLs.
|
website URLs.
|
||||||
--site-url <URL> install. Your site's base URL, for the
|
--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.
|
--yes Assume the default answer to every prompt.
|
||||||
On uninstall it means yes: that prompt
|
On uninstall it means yes: that prompt
|
||||||
defaults to no, and typing `uninstall
|
defaults to no, and typing `uninstall
|
||||||
@@ -149,6 +173,16 @@ Options:
|
|||||||
--purge uninstall. Also delete sidecar.toml,
|
--purge uninstall. Also delete sidecar.toml,
|
||||||
uo-link.db, the cached patch set and every
|
uo-link.db, the cached patch set and every
|
||||||
backup, all of which are otherwise kept.
|
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.
|
-V, --version Print the installer version and exit.
|
||||||
-h, --help Print this help 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)?),
|
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
|
||||||
"--host" => cli.host = 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)?),
|
"--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('-') => {
|
other if other.starts_with('-') => {
|
||||||
return Err(format!("unrecognized argument: {other}"))
|
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 {
|
match command {
|
||||||
Some(c) => cli.mode = Mode::Run(c),
|
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
|
// 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)
|
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.
|
/// 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
|
/// 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());
|
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]
|
#[test]
|
||||||
fn order_does_not_matter() {
|
fn order_does_not_matter() {
|
||||||
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();
|
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 {
|
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 {
|
Self {
|
||||||
mark,
|
mark,
|
||||||
label: label.to_string(),
|
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)
|
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)
|
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)
|
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.notes.push(note.into());
|
||||||
self
|
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.notes.extend(notes);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
@@ -158,12 +158,22 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
// ── The bundle ───────────────────────────────────────────────────────────
|
// ── The bundle ───────────────────────────────────────────────────────────
|
||||||
rows.push(bundle_row(&record));
|
rows.push(bundle_row(&record));
|
||||||
|
|
||||||
|
// ── The host ─────────────────────────────────────────────────────────────
|
||||||
|
// Linux only, and absent entirely elsewhere (see `imaging_row`).
|
||||||
|
rows.extend(imaging_row());
|
||||||
|
|
||||||
// ── Backups ──────────────────────────────────────────────────────────────
|
// ── Backups ──────────────────────────────────────────────────────────────
|
||||||
rows.push(backup_row(&layout));
|
rows.push(backup_row(&layout));
|
||||||
|
|
||||||
// ── Report ───────────────────────────────────────────────────────────────
|
// ── 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!();
|
println!();
|
||||||
for row in &rows {
|
for row in rows {
|
||||||
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
|
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
|
||||||
for note in &row.notes {
|
for note in &row.notes {
|
||||||
println!(" {note}");
|
println!(" {note}");
|
||||||
@@ -181,7 +191,11 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
INSTALL.md's Troubleshooting table is keyed to these symptoms."
|
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.
|
/// 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
|
/// 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.
|
/// 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 port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind);
|
||||||
let url = format!("http://127.0.0.1:{port}/health");
|
let url = format!("http://127.0.0.1:{port}/health");
|
||||||
let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?;
|
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")
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -913,4 +992,39 @@ mod tests {
|
|||||||
assert_eq!(row.mark, Mark::Ok);
|
assert_eq!(row.mark, Mark::Ok);
|
||||||
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
|
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,
|
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::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
|
||||||
|
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
|
||||||
let service_record = match &outcome {
|
let service_record = match &outcome {
|
||||||
service::Outcome::Registered {
|
service::Outcome::Registered {
|
||||||
kind,
|
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
|
/// 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;
|
/// 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.
|
/// 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 {
|
if let Some(host) = &cli.host {
|
||||||
return host.clone();
|
return host.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
|
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
|
||||||
//! commands `doctor`, `update` and `uninstall`.
|
//! 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
|
//! 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
|
//! 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.
|
//! 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 patch;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod record;
|
pub mod record;
|
||||||
|
pub mod rustgame;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub mod servuo;
|
pub mod servuo;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
@@ -76,6 +83,7 @@ pub fn run() -> i32 {
|
|||||||
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
|
||||||
Ok(0)
|
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::Install) => install::run(&parsed).map(|()| 0),
|
||||||
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
|
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
|
||||||
Mode::Run(Command::Doctor) => doctor::run(&parsed),
|
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,
|
pub data_dir: PathBuf,
|
||||||
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
|
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
|
||||||
pub sidecar_bin: PathBuf,
|
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
|
/// 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`].
|
/// deployment. Service registration is skipped when it is set — see [`layout`].
|
||||||
pub relocated: bool,
|
pub relocated: bool,
|
||||||
@@ -83,6 +86,53 @@ impl Layout {
|
|||||||
pub fn systemd_unit(&self) -> PathBuf {
|
pub fn systemd_unit(&self) -> PathBuf {
|
||||||
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
|
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`].
|
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
|
||||||
@@ -107,8 +157,14 @@ pub fn layout() -> Layout {
|
|||||||
.file_name()
|
.file_name()
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
|
.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.data_dir = root.join("data");
|
||||||
layout.sidecar_bin = root.join("bin").join(bin_name);
|
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.state_dir = root;
|
||||||
layout.relocated = true;
|
layout.relocated = true;
|
||||||
}
|
}
|
||||||
@@ -134,6 +190,9 @@ fn platform_layout() -> Layout {
|
|||||||
sidecar_bin: program_files
|
sidecar_bin: program_files
|
||||||
.join("RunicGateway")
|
.join("RunicGateway")
|
||||||
.join("uo-link-sidecar.exe"),
|
.join("uo-link-sidecar.exe"),
|
||||||
|
rust_sidecar_bin: program_files
|
||||||
|
.join("RunicGateway")
|
||||||
|
.join("rust-link-sidecar.exe"),
|
||||||
relocated: false,
|
relocated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,6 +203,7 @@ fn platform_layout() -> Layout {
|
|||||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||||
|
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||||
relocated: false,
|
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]
|
#[test]
|
||||||
fn a_relocated_layout_moves_the_binary_too() {
|
fn a_relocated_layout_moves_the_binary_too() {
|
||||||
// The failure this prevents: a test run that writes its config and database under the
|
// 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
315
src/rustgame/sidecar.rs
Normal file
315
src/rustgame/sidecar.rs
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
//! 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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> {
|
) -> Result<Outcome> {
|
||||||
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
|
||||||
let text = systemd_unit_text(binary, config, db, &user);
|
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
|
// 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.
|
// 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();
|
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", &["daemon-reload"])?;
|
||||||
}
|
}
|
||||||
|
|
||||||
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
|
run_ok("systemctl", &["enable", name])?;
|
||||||
if restart {
|
if restart {
|
||||||
// The binary underneath a running service has just been replaced; `start` on an already
|
// 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.
|
// 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 {
|
} else {
|
||||||
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
|
run_ok("systemctl", &["start", name])?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Outcome::Registered {
|
Ok(Outcome::Registered {
|
||||||
kind: "systemd",
|
kind: "systemd",
|
||||||
name: SYSTEMD_UNIT.to_string(),
|
name: name.to_string(),
|
||||||
unit_path: Some(unit_path.to_path_buf()),
|
unit_path: Some(unit_path.to_path_buf()),
|
||||||
user: prepared.user.clone(),
|
user: prepared.user.clone(),
|
||||||
user_created: prepared.user_created,
|
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
|
/// 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.
|
/// start` succeeding and the service still being up a second later are different claims.
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn systemd_state() -> String {
|
fn systemd_state(name: &str) -> String {
|
||||||
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
|
let active = one_word(run("systemctl", &["is-active", name]));
|
||||||
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
|
let enabled = one_word(run("systemctl", &["is-enabled", name]));
|
||||||
format!("{active}, {enabled}")
|
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
|
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
|
||||||
/// is translated.
|
/// is translated.
|
||||||
pub fn windows_service_account() -> String {
|
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.
|
/// 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
|
/// 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.
|
/// 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 {
|
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 {
|
match code {
|
||||||
1053 => format!(
|
1053 => format!(
|
||||||
"`{command}` failed with 1053 — the service did not respond to the start request in \
|
"`{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}\"",
|
\"{config}\"",
|
||||||
binary = binary.display(),
|
binary = binary.display(),
|
||||||
config = config.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
|
// 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.
|
// 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. \
|
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 \
|
Register the service by hand against an account this host allows — INSTALL.md \
|
||||||
Appendix A4.",
|
Appendix A4.",
|
||||||
account = windows_service_account(),
|
account = windows_account_for(name),
|
||||||
),
|
),
|
||||||
_ => format!(
|
_ => format!(
|
||||||
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
|
"`{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 \
|
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: {}\n\n Running it in the foreground prints the reason:\n\n \
|
||||||
\"{}\" --config \"{}\"",
|
\"{}\" --config \"{}\"",
|
||||||
@@ -365,23 +399,29 @@ pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
||||||
let bin_path = windows_bin_path(binary, config);
|
register_windows_named(WINDOWS_SERVICE, DISPLAY_NAME, binary, config, restart)
|
||||||
let account = windows_service_account();
|
}
|
||||||
|
|
||||||
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,
|
// `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.
|
// more to the point, would briefly leave a host with no service if the create half failed.
|
||||||
run_ok(
|
run_ok(
|
||||||
"sc.exe",
|
"sc.exe",
|
||||||
&[
|
&[
|
||||||
"config",
|
"config", name, "binPath=", &bin_path, "start=", "auto", "obj=", &account,
|
||||||
WINDOWS_SERVICE,
|
|
||||||
"binPath=",
|
|
||||||
&bin_path,
|
|
||||||
"start=",
|
|
||||||
"auto",
|
|
||||||
"obj=",
|
|
||||||
&account,
|
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
} else {
|
} else {
|
||||||
@@ -389,7 +429,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
|||||||
"sc.exe",
|
"sc.exe",
|
||||||
&[
|
&[
|
||||||
"create",
|
"create",
|
||||||
WINDOWS_SERVICE,
|
name,
|
||||||
"binPath=",
|
"binPath=",
|
||||||
&bin_path,
|
&bin_path,
|
||||||
"start=",
|
"start=",
|
||||||
@@ -397,7 +437,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
|||||||
"obj=",
|
"obj=",
|
||||||
&account,
|
&account,
|
||||||
"DisplayName=",
|
"DisplayName=",
|
||||||
DISPLAY_NAME,
|
display,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.context(
|
.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 — \
|
(no password); if this host's policy forbids them, register the service by hand — \
|
||||||
INSTALL.md Appendix A4.",
|
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`
|
// 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",
|
"sc.exe",
|
||||||
&[
|
&[
|
||||||
"failure",
|
"failure",
|
||||||
WINDOWS_SERVICE,
|
name,
|
||||||
"reset=",
|
"reset=",
|
||||||
"86400",
|
"86400",
|
||||||
"actions=",
|
"actions=",
|
||||||
@@ -423,42 +463,38 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
if restart && windows_service_state().contains("RUNNING") {
|
if restart && windows_service_state(name).contains("RUNNING") {
|
||||||
stop_windows_service()?;
|
stop_windows_service(name)?;
|
||||||
}
|
|
||||||
// 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
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
// 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 {
|
Ok(Outcome::Registered {
|
||||||
kind: "windows-scm",
|
kind: "windows-scm",
|
||||||
name: WINDOWS_SERVICE.to_string(),
|
name: name.to_string(),
|
||||||
unit_path: None,
|
unit_path: None,
|
||||||
user: Some(account),
|
user: Some(account),
|
||||||
user_created: false,
|
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
|
/// 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.
|
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn windows_service_exists() -> bool {
|
fn windows_service_exists(name: &str) -> bool {
|
||||||
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
|
match run("sc.exe", &["query", name]) {
|
||||||
Ok(output) => output.status.code() != Some(1060),
|
Ok(output) => output.status.code() != Some(1060),
|
||||||
Err(_) => false,
|
Err(_) => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn windows_service_state() -> String {
|
fn windows_service_state(name: &str) -> String {
|
||||||
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
|
let Ok(output) = run("sc.exe", &["query", name]) else {
|
||||||
return "unknown".to_string();
|
return "unknown".to_string();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
@@ -474,12 +510,12 @@ fn windows_service_state() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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.
|
// 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) {
|
if !stop.status.success() && stop.status.code() != Some(1062) {
|
||||||
anyhow::bail!(
|
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.",
|
service runs, so the install cannot replace it.",
|
||||||
stop.status.code().unwrap_or(-1)
|
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
|
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
|
||||||
// or a delay on every run.
|
// or a delay on every run.
|
||||||
for _ in 0..30 {
|
for _ in 0..30 {
|
||||||
if windows_service_state() == "STOPPED" {
|
if windows_service_state(name) == "STOPPED" {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
|
"{name} did not stop within 15 seconds. Stop it by hand and re-run: \
|
||||||
sc.exe stop {WINDOWS_SERVICE}"
|
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 ──────────────────────────────────────────────────────
|
// ── Shared entry points ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Stops a running service so its binary can be replaced.
|
/// Stops a running service so its binary can be replaced.
|
||||||
@@ -517,8 +795,8 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
|
|||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
Manager::WindowsScm => {
|
Manager::WindowsScm => {
|
||||||
if windows_service_exists() {
|
if windows_service_exists(WINDOWS_SERVICE) {
|
||||||
stop_windows_service()?;
|
stop_windows_service(WINDOWS_SERVICE)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -585,13 +863,13 @@ fn observe_platform(kind: &str, name: &str) -> Status {
|
|||||||
if kind != "windows-scm" {
|
if kind != "windows-scm" {
|
||||||
return Status::absent(format!("recorded as {kind}, which this host does not run"));
|
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.
|
// 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());
|
return Status::absent("not registered with the service manager".to_string());
|
||||||
}
|
}
|
||||||
let state = windows_service_state();
|
let state = windows_service_state(name);
|
||||||
let start = windows_start_type();
|
let start = windows_start_type(name);
|
||||||
Status {
|
Status {
|
||||||
present: true,
|
present: true,
|
||||||
running: state.contains("RUNNING"),
|
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
|
/// `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.
|
/// was set to manual start is reported as such rather than as healthy.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
fn windows_start_type() -> String {
|
fn windows_start_type(name: &str) -> String {
|
||||||
let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else {
|
let Ok(output) = run("sc.exe", &["qc", name]) else {
|
||||||
return "unknown".to_string();
|
return "unknown".to_string();
|
||||||
};
|
};
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
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() {
|
} 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!(
|
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
|
out
|
||||||
@@ -699,7 +980,15 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
|
|||||||
));
|
));
|
||||||
return out;
|
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
|
out.done
|
||||||
.push(format!("{} was already unregistered", record.name));
|
.push(format!("{} was already unregistered", record.name));
|
||||||
return out;
|
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,
|
// 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
|
// and the service — and its lock on the binary this uninstall is about to remove — survives
|
||||||
// until the process exits.
|
// until the process exits.
|
||||||
if let Err(error) = stop_windows_service() {
|
if let Err(error) = stop_windows_service(&record.name) {
|
||||||
out.problems
|
out.problems
|
||||||
.push(error.to_string().replace('\n', " ").to_string());
|
.push(error.to_string().replace('\n', " ").to_string());
|
||||||
} else {
|
} else {
|
||||||
@@ -877,6 +1166,47 @@ pub fn protect_config(
|
|||||||
protect_config_platform(config, data_dir, user, relocated)
|
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.
|
/// 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`
|
/// 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`
|
// 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
|
// 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.
|
// 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(());
|
return Ok(());
|
||||||
}
|
};
|
||||||
let account = windows_service_account();
|
|
||||||
|
|
||||||
run_ok(
|
run_ok(
|
||||||
"icacls",
|
"icacls",
|
||||||
@@ -1155,6 +1489,7 @@ mod tests {
|
|||||||
state_dir: PathBuf::from("/etc/runicgateway"),
|
state_dir: PathBuf::from("/etc/runicgateway"),
|
||||||
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
data_dir: PathBuf::from("/var/lib/runicgateway"),
|
||||||
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
|
||||||
|
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||||
relocated: true,
|
relocated: true,
|
||||||
};
|
};
|
||||||
let outcome = register(&prepare(true), &layout, false).unwrap();
|
let outcome = register(&prepare(true), &layout, false).unwrap();
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ pub fn normalize_version(raw: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[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"\\?\")) {
|
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
|
||||||
Some(stripped) => PathBuf::from(stripped),
|
Some(stripped) => PathBuf::from(stripped),
|
||||||
None => path,
|
None => path,
|
||||||
@@ -171,7 +171,7 @@ fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
|
||||||
path
|
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.
|
/// 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
|
/// 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 \
|
Protocol version {protocol}\n \
|
||||||
Auth token {token}\n \
|
Auth token {token}\n \
|
||||||
(also in {config})\n\n\
|
(also in {config})\n\n\
|
||||||
Paste these into Admin → Shard on your Runic Gateway site:\n \
|
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
|
||||||
{site}/admin/shard\n\n\
|
{site}{admin_path}\n\n\
|
||||||
The token is write-only once saved — the site will never show it back to you.\n",
|
The token is write-only once saved — the site will never show it back to you.\n",
|
||||||
protocol = doc.protocol,
|
protocol = doc.protocol,
|
||||||
token = doc.web.auth_token,
|
token = doc.web.auth_token,
|
||||||
config = doc.config_path,
|
config = doc.config_path,
|
||||||
|
admin_path = ADMIN_SHARD_PATH,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +414,7 @@ mod tests {
|
|||||||
assert!(block.contains(&doc.web.auth_token), "{block}");
|
assert!(block.contains(&doc.web.auth_token), "{block}");
|
||||||
// The trailing slash on the site URL must not produce a double slash in the link.
|
// The trailing slash on the site URL must not produce a double slash in the link.
|
||||||
assert!(
|
assert!(
|
||||||
block.contains("https://my-site.example/admin/shard"),
|
block.contains("https://my-site.example/admin/uo/link"),
|
||||||
"{block}"
|
"{block}"
|
||||||
);
|
);
|
||||||
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{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
|
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
|
||||||
// link the operator does not need.
|
// link the operator does not need.
|
||||||
let block = handoff(&doc(), "shard", None);
|
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}");
|
assert!(block.contains("4f9c"), "{block}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -731,6 +731,7 @@ mod tests {
|
|||||||
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
|
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
|
||||||
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
|
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
|
||||||
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
|
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
|
||||||
|
rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"),
|
||||||
relocated: false,
|
relocated: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ mod tests {
|
|||||||
state_dir: dir.to_path_buf(),
|
state_dir: dir.to_path_buf(),
|
||||||
data_dir: dir.join("data"),
|
data_dir: dir.join("data"),
|
||||||
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
|
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
|
||||||
|
rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"),
|
||||||
relocated: true,
|
relocated: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallReco
|
|||||||
println!();
|
println!();
|
||||||
ui::warn(&format!(
|
ui::warn(&format!(
|
||||||
"The protocol version changed: {} → {}.\n \
|
"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 \
|
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.",
|
website still set to\n {} with 409 rather than mis-parsing it.",
|
||||||
previous_protocol.unwrap_or(bundle.protocol),
|
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