Compare commits
21 Commits
14f65d50a0
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f81cbcdd04 | |||
| 007791c4fc | |||
| 1173a10049 | |||
| 84c1106d58 | |||
| d6f0bcf2cf | |||
| 065edab8cd | |||
| 8e5258358c | |||
| 09eafe2911 | |||
| 7771e2e68d | |||
| b7d1bbbc78 | |||
| 2787eaadff | |||
| ea7e491ba3 | |||
| 7db58031c7 | |||
| 07a2cca5f6 | |||
| 10bc2224a6 | |||
| 461192a470 | |||
| ae53546446 | |||
| c79374ff06 | |||
| 82900da939 | |||
| 6941925fa5 | |||
| fd59a74912 |
@@ -13,15 +13,23 @@
|
||||
# byte-identical.
|
||||
#
|
||||
# ── Where it is published, and why not as a release ──────────────────────────
|
||||
# Bundles are COMMITTED to this repo under bundles/:
|
||||
# Bundles are COMMITTED to this repo, on their own `bundles` branch, at its root:
|
||||
#
|
||||
# bundles/current.json the bundle the installer uses by default
|
||||
# bundles/bundle-<tag>.json every bundle ever published, kept for --bundle
|
||||
# current.json the bundle the installer uses by default
|
||||
# bundle-<tag>.json every bundle ever published, kept for --bundle
|
||||
#
|
||||
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
|
||||
#
|
||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/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/current.json
|
||||
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
|
||||
#
|
||||
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
|
||||
# the pre-receive hook declines a push from CI, which is not a thing a nightly
|
||||
# cron can resolve. Publishing to a branch of its own keeps everything the
|
||||
# original choice was for — a reviewable diff, a git history of the compat
|
||||
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
|
||||
# exception. The alternative, whitelisting a scheduled job for pushes to the
|
||||
# default branch, buys nothing this does not.
|
||||
#
|
||||
# The obvious alternative — one Gitea release per bundle — was rejected because
|
||||
# it collides with this repo's own product. release.yml publishes the installer
|
||||
@@ -30,8 +38,8 @@
|
||||
# intermittently resolve to a release containing no installer binary. Committing
|
||||
# also gets a reviewable diff and a git history of the compat matrix for free.
|
||||
#
|
||||
# The push to `main` needs no new branch-protection exception: release.yml's
|
||||
# version-bump commit already requires REGISTRY_USER to be able to push here.
|
||||
# `main` is never pushed to by this workflow. (release.yml does not push to it
|
||||
# either — it tags and lets the release API do the rest.)
|
||||
#
|
||||
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
|
||||
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
|
||||
@@ -86,14 +94,44 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
# Full history: the push step rebases onto main if release.yml's version
|
||||
# bump landed while this job was composing, and a depth-1 clone has no
|
||||
# base to rebase onto.
|
||||
- name: Check out the bundles directory
|
||||
# Full history: the publish step rebases onto the bundles branch if another
|
||||
# run landed while this one was composing, and a depth-1 clone has no base
|
||||
# to rebase onto.
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
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.
|
||||
# Everything downstream reads and writes `published/`, which means the
|
||||
# ".2 suffix" scan and the idempotence check both see what is actually
|
||||
# published rather than a stale copy on main.
|
||||
- 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: $(ls published/*.json 2>/dev/null | 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -173,22 +211,33 @@ jobs:
|
||||
|
||||
# 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.
|
||||
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
|
||||
# as a red run, rather than being silently dropped from every bundle.
|
||||
#
|
||||
# linux-aarch64 was recognized here one merge BEFORE link published one
|
||||
# (PLAN.md §5.2, steps 1 and 3). That order was forced by the two rules
|
||||
# below being strict in opposite directions: an unknown name fails the
|
||||
# run, and a missing REQUIRED key fails it too. So the name had to be
|
||||
# taught before the release that carried it, and the key could only be
|
||||
# required after — requiring it first would have failed every bundle
|
||||
# for as long as the gap lasted. link v1.1.1 ships the binary, so the
|
||||
# key is now required: a dropped target reddens this job instead of
|
||||
# vanishing from every bundle.
|
||||
: > work/link-platforms.tsv
|
||||
while IFS="$(printf '\t')" read -r NAME URL; do
|
||||
[ -n "$NAME" ] || continue
|
||||
case "$NAME" in
|
||||
*-linux-x86_64) PLAT=linux-x86_64 ;;
|
||||
*-linux-aarch64) PLAT=linux-aarch64 ;;
|
||||
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
|
||||
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
|
||||
esac
|
||||
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
|
||||
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
|
||||
done < work/link/asset-list.tsv
|
||||
for REQUIRED in linux-x86_64 windows-x86_64; do
|
||||
for REQUIRED in linux-x86_64 linux-aarch64 windows-x86_64; do
|
||||
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|
||||
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for both"
|
||||
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for all three"
|
||||
done
|
||||
|
||||
# The overlay release carries exactly one artifact: the tarball.
|
||||
@@ -330,8 +379,8 @@ jobs:
|
||||
# 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 \
|
||||
if [ -f published/current.json ]; then
|
||||
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
|
||||
&& jq -S '.' work/content.json > work/new-content.json \
|
||||
&& cmp -s work/old-content.json work/new-content.json; then
|
||||
CHANGED=false
|
||||
@@ -340,7 +389,7 @@ jobs:
|
||||
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$CHANGED" = false ]; then
|
||||
echo "==> identical to bundles/current.json — nothing to publish."
|
||||
echo "==> identical to the published current.json — nothing to publish."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -350,15 +399,14 @@ jobs:
|
||||
# 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
|
||||
while [ -f "published/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
|
||||
work/content.json > "published/bundle-${TAG}.json"
|
||||
cp "published/bundle-${TAG}.json" published/current.json
|
||||
|
||||
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "==> composed bundle ${TAG}"
|
||||
@@ -387,9 +435,11 @@ jobs:
|
||||
set -euo pipefail
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
|
||||
# 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
|
||||
# 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.)
|
||||
: > work/stale-warnings.md
|
||||
|
||||
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
|
||||
@@ -470,27 +520,25 @@ jobs:
|
||||
# cannot be parsed").
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | 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 add bundles
|
||||
cd published
|
||||
git add -A
|
||||
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
|
||||
|
||||
# The checkout is a detached snapshot of main; push the commit at HEAD
|
||||
# to the branch the installer reads its raw URLs from. release.yml
|
||||
# pushes its version-bump commit to the same branch, so losing the
|
||||
# race is normal rather than exceptional — rebase and retry once
|
||||
# instead of failing and leaving the bundle unpublished until the
|
||||
# next cron. Only bundles/ is touched here, so a rebase over a bump
|
||||
# commit cannot conflict.
|
||||
if ! git push origin "HEAD:main"; then
|
||||
echo "::warning::push rejected (main moved during compose) — rebasing and retrying once"
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
git push origin "HEAD:main"
|
||||
# Two runs can compose at once — a component release dispatches this
|
||||
# while the nightly cron is mid-flight — so losing the race is normal
|
||||
# rather than exceptional. Rebase and retry once instead of failing and
|
||||
# leaving the bundle unpublished until tomorrow. Every file here is a
|
||||
# bundle nobody else edits, and a bundle tag names exactly one matrix,
|
||||
# so a rebase cannot conflict.
|
||||
if ! git push origin bundles; then
|
||||
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
|
||||
echo "==> published bundles/bundle-${TAG}.json and bundles/current.json"
|
||||
echo "==> published bundle-${TAG}.json and current.json on the bundles branch"
|
||||
|
||||
- name: Job summary
|
||||
if: always()
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer):
|
||||
# REGISTRY_USER — Gitea username the token below belongs to
|
||||
# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push
|
||||
# the bump commit + 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 release tag and create the release.
|
||||
#
|
||||
# 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
|
||||
|
||||
@@ -66,11 +66,15 @@ env:
|
||||
BIN: runicgateway-installer
|
||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||
WINDOWS_TARGET: x86_64-pc-windows-gnu
|
||||
# The installer has to run wherever the sidecar it installs can run, and link
|
||||
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
|
||||
ARM64_TARGET: aarch64-unknown-linux-gnu
|
||||
|
||||
jobs:
|
||||
release:
|
||||
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
|
||||
# unquoted YAML scalar would misparse as a mapping value.
|
||||
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
|
||||
@@ -237,14 +241,19 @@ jobs:
|
||||
echo "Release credentials present."
|
||||
|
||||
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
||||
- name: Install Rust toolchain, Windows target, and MinGW linker
|
||||
- name: Install Rust toolchain, cross targets, and their linkers
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# libc6-dev-arm64-cross is named explicitly on purpose: gcc-aarch64-linux-gnu only
|
||||
# *recommends* it, and this install runs --no-install-recommends. Without it the Rust
|
||||
# half of the arm64 build succeeds and then `ring` (under ureq's rustls) dies compiling
|
||||
# C, on a missing bits/libc-header-start.h.
|
||||
$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
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
|
||||
@@ -254,6 +263,7 @@ jobs:
|
||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||
rustup component add rustfmt
|
||||
rustup target add "${WINDOWS_TARGET}"
|
||||
rustup target add "${ARM64_TARGET}"
|
||||
|
||||
- name: Set the crate version to match the release
|
||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||
@@ -291,6 +301,18 @@ jobs:
|
||||
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
|
||||
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
|
||||
|
||||
# The installer has to run wherever the sidecar it installs can run, and link publishes an
|
||||
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
|
||||
# artifact is packaged, but nothing ever builds it — which is exactly how the first release
|
||||
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
|
||||
- name: cargo build --release (Linux arm64, 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) ────────────────────
|
||||
# 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.
|
||||
@@ -299,38 +321,54 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
||||
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
|
||||
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
|
||||
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||
# Every artifact must be listed: `sha256sum -c` passes silently over a
|
||||
# file the sums do not mention, and an operator verifying a download
|
||||
# would get a pass on a binary nobody vouched for.
|
||||
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
|
||||
ls -l dist && echo "----" && cat dist/SHA256SUMS
|
||||
|
||||
# ── 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' }}
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ steps.plan.outputs.version }}"
|
||||
TAG="${{ steps.plan.outputs.tag }}"
|
||||
# Secrets can arrive with a trailing newline (depending on how they were
|
||||
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
|
||||
# be parsed"). Strip line breaks before building the URL. Passing them via
|
||||
# env (not inline ${{ }}) also keeps a newline from breaking this script.
|
||||
# be parsed"). Strip line breaks before building the URL. They are passed
|
||||
# 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_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 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
|
||||
# tagging (see the plan step). `git tag` on an existing name fails
|
||||
# under `set -e`; pushing an identical existing tag is a harmless
|
||||
@@ -365,7 +403,7 @@ jobs:
|
||||
| jq -r '.id')"
|
||||
echo "Created release ${TAG} (id=${REL_ID})"
|
||||
|
||||
for f in "${BIN}-linux-x86_64" "${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}" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-F "attachment=@dist/${f}" >/dev/null
|
||||
|
||||
50
README.md
50
README.md
@@ -27,7 +27,13 @@ never restarts the shard.
|
||||
|
||||
## Status
|
||||
|
||||
**Phase 1 (installer core) is built, on the `edge` branch. Nothing is released yet.**
|
||||
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
|
||||
|
||||
The binary does everything
|
||||
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
|
||||
describes: bundle resolution, ServUO detection and validation, the overlay sync,
|
||||
the opt-in patch tier, `install.json`, the uo-link sidecar and its service, the
|
||||
token handoff, and `doctor` / `update` / `uninstall`.
|
||||
|
||||
The design of record is
|
||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
||||
@@ -42,17 +48,29 @@ sidecar + overlay combination, recomposed on every component release and nightly
|
||||
|---|---|
|
||||
| 0 — prerequisites in the other repos | ✅ merged |
|
||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||
| 2 — uo-link install + service registration | next |
|
||||
| 3 — the opt-in stock-file patch tier | |
|
||||
| 4 — `doctor`, `update`, `uninstall` | |
|
||||
| 2 — uo-link install + service registration | ✅ on `edge` |
|
||||
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
|
||||
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
|
||||
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
|
||||
|
||||
**Why `edge`:** `release.yml` publishes an installer binary on every push to
|
||||
`main`, and a binary that deploys the overlay but cannot yet install the sidecar
|
||||
is not something to hand an operator. Phases 1 and 2 land on `edge`; the
|
||||
`edge → main` cutover cuts the first release. PRs into `edge` run the same gates
|
||||
as PRs into `main`.
|
||||
`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`.
|
||||
|
||||
Until then, the way to install is by hand —
|
||||
**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`.
|
||||
|
||||
@@ -66,7 +84,7 @@ is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
| [RunicGateway/website](https://gitea.whitlocktech.com/RunicGateway/website) | The public site and admin panel. The installer never contacts it — it prints values for Admin → Shard. |
|
||||
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. |
|
||||
|
||||
## Planned commands
|
||||
## Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
@@ -90,6 +108,10 @@ is the same deployment done with `curl`, `tar` and `systemctl`.
|
||||
- **A successful copy is not a working bridge.** ServUO ignores the script build's
|
||||
exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify
|
||||
post-boot state rather than trusting a clean boot.
|
||||
- **What a run overwrites is copied first.** Every `.cs` file the overlay owns is
|
||||
replaced unconditionally, so an operator's edit to one is saved under
|
||||
`backups/<timestamp>/` before it goes. Restoring is theirs to do — this tool
|
||||
will not put an old file back over a newer release.
|
||||
- **The audience is public** — any ServUO operator, not only shards we run.
|
||||
|
||||
## Build & run
|
||||
@@ -103,8 +125,12 @@ cargo run -- install --servuo /path/to/ServUO --verify # dry run: writes nothi
|
||||
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||
```
|
||||
|
||||
`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway`
|
||||
or `%ProgramData%\RunicGateway`), which is how a run is tested without root.
|
||||
`RUNICGATEWAY_STATE_DIR` relocates **everything the installer writes** — state,
|
||||
data, and the sidecar binary (normally `/etc/runicgateway`, `/var/lib/runicgateway`
|
||||
and `/usr/bin`, or `%ProgramData%\RunicGateway` and `%ProgramFiles%\RunicGateway`).
|
||||
It also suppresses service registration, since there is no such thing as a
|
||||
relocated systemd unit or Windows service. That is how a full run is tested
|
||||
without root.
|
||||
|
||||
Two layout notes that look odd until you know why:
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ nightly, so a missed dispatch self-heals. A run that finds nothing changed write
|
||||
|
||||
See `docs/installer/PLAN.md` §7 for the design.
|
||||
|
||||
## 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), at its root:
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
@@ -24,14 +27,24 @@ Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar
|
||||
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/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/current.json
|
||||
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
|
||||
```
|
||||
|
||||
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
|
||||
@@ -56,8 +69,9 @@ protocol) or to either component's release version. All three move independently
|
||||
"tag": "v1.1.0",
|
||||
"version": "1.1.0",
|
||||
"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-aarch64": { "name": "…", "url": "…", "sha256": "…" },
|
||||
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"bundle": "2026.08.04",
|
||||
"generated": "2026-08-04T16:07:13Z",
|
||||
"protocol": 3,
|
||||
"link": {
|
||||
"repo": "RunicGateway/link",
|
||||
"tag": "v1.1.0",
|
||||
"version": "1.1.0",
|
||||
"protocol": 3,
|
||||
"assets": {
|
||||
"linux-x86_64": {
|
||||
"name": "uo-link-sidecar-linux-x86_64",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-linux-x86_64",
|
||||
"sha256": "27d491efda3fc6859dd38da9b2aa3b97b5fdf1dc5fc488a8916bb88b03443ad9"
|
||||
},
|
||||
"windows-x86_64": {
|
||||
"name": "uo-link-sidecar-windows-x86_64.exe",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v1.1.0/uo-link-sidecar-windows-x86_64.exe",
|
||||
"sha256": "fbefd886af0355bf128f1f4c65657b772a58b128d32438061adb0069978e0b8f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"overlay": {
|
||||
"repo": "RunicGateway/servuo-plugins",
|
||||
"tag": "v0.1.1",
|
||||
"version": "0.1.1",
|
||||
"commit": "3a52abbd77047e7c94883934533edcfef3ede555",
|
||||
"protocol": 3,
|
||||
"servuo": {
|
||||
"min_version": "57.4",
|
||||
"patches_verified_against": "57.4"
|
||||
},
|
||||
"asset": {
|
||||
"name": "runicgateway-overlay-0.1.1.tar.gz",
|
||||
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v0.1.1/runicgateway-overlay-0.1.1.tar.gz",
|
||||
"sha256": "75dc6d6ce08322b753a30303b3b2df6f15cf1e84658507d97430af44ec4d34d7"
|
||||
}
|
||||
}
|
||||
}
|
||||
485
src/backup.rs
Normal file
485
src/backup.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
//! Copies of what a run is about to overwrite (PLAN.md §5.3).
|
||||
//!
|
||||
//! ## Scoped by what cannot be fetched again
|
||||
//!
|
||||
//! Most of what this installer writes is replaceable: the sidecar binary and every overlay file are
|
||||
//! re-downloadable and hash-named in the bundle, and the sidecar's database is a cache with a schema
|
||||
//! — `link`'s `store.rs` creates every table `IF NOT EXISTS` and every one of them holds shard state
|
||||
//! the sweeps repopulate. Backing those up would be bulk with no recovery value, and the bulk is not
|
||||
//! free: it would bury the two things that matter.
|
||||
//!
|
||||
//! What a run can destroy irrecoverably is short:
|
||||
//!
|
||||
//! 1. **The operator's own edits to a file the overlay owns.** `Bridge.cfg` is deliberately kept
|
||||
//! (PLAN.md §5 Phase 1), but every `.cs` file and `Scripts.csproj` is overwritten
|
||||
//! *unconditionally and by design* — so the one place this tool knowingly discards work is the
|
||||
//! one place it should keep a copy first.
|
||||
//! 2. **A stock ServUO file the patch tier edits.** `patches/originals/` already holds the
|
||||
//! pre-*tier* copy and is never overwritten, which is the right revert target; it is not a
|
||||
//! record of what the file looked like *this morning*, after the operator's own later edits.
|
||||
//! 3. **`sidecar.toml`**, whose token the website already holds. Mint a new one and the site's
|
||||
//! saved configuration starts answering `401` with nothing on the sidecar to explain why.
|
||||
//!
|
||||
//! ## What decides whether a backup happens
|
||||
//!
|
||||
//! **Whether this run is about to overwrite something** — not which verb was typed and not whether
|
||||
//! a prior record exists. PLAN.md §5.3 framed it as "`update`, and `install` over an existing
|
||||
//! record", on the reasoning that a first install overwrites nothing. That reasoning does not
|
||||
//! survive contact with `INSTALL.md` Appendix A2, which documents deploying the overlay **by hand**:
|
||||
//! a first `install` over such a tree finds `.cs` files that differ, plans them as `Change`, and
|
||||
//! overwrites them with no record anywhere of what was there. So the test is the direct one, and a
|
||||
//! genuine first install onto a clean tree still writes nothing because there is nothing to copy.
|
||||
//!
|
||||
//! ## Restoring is printed, not done
|
||||
//!
|
||||
//! Same rule as the uninstall report, and for the same reason: the installer cannot know what has
|
||||
//! changed since, and a restore that puts an old `.cs` file back over a newer overlay eats work
|
||||
//! rather than saving it. The path and the manifest are what this module hands over.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::paths::Layout;
|
||||
use crate::util;
|
||||
|
||||
/// How many backup directories survive. Older ones are pruned as new ones are written.
|
||||
///
|
||||
/// An unbounded directory of ServUO source copies on a shard host is its own support problem, and
|
||||
/// the value of an old backup falls off a cliff: what an operator reaches for is "before this
|
||||
/// upgrade", occasionally "before the one before". Three is that, plus one.
|
||||
pub const KEEP: usize = 3;
|
||||
|
||||
/// The `schema` written into `manifest.json`, so a future reader can tell shapes apart.
|
||||
const SCHEMA: u32 = 1;
|
||||
|
||||
/// Why a file was copied. Recorded per entry, because "what did this upgrade touch" is answered
|
||||
/// very differently by an overlay file and by a stock ServUO file the patch tier edited.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Reason {
|
||||
/// An overlay file whose on-disk content the sync is about to replace.
|
||||
OverlayChange,
|
||||
/// A stock ServUO file the patch tier is about to edit.
|
||||
PatchTarget,
|
||||
/// A companion `.cs` the tier copies in, which already existed in the tree.
|
||||
PatchCompanion,
|
||||
/// `sidecar.toml` — the token the website holds.
|
||||
SidecarConfig,
|
||||
}
|
||||
|
||||
impl Reason {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::OverlayChange => "overlay-change",
|
||||
Self::PatchTarget => "patch-target",
|
||||
Self::PatchCompanion => "patch-companion",
|
||||
Self::SidecarConfig => "sidecar-config",
|
||||
}
|
||||
}
|
||||
|
||||
/// Which sub-directory of the backup the copy lands under.
|
||||
///
|
||||
/// The two roots are kept apart because a path is only meaningful relative to one of them, and
|
||||
/// without the split a state file could collide with a tree file of the same name.
|
||||
fn root_dir(self) -> &'static str {
|
||||
match self {
|
||||
Self::SidecarConfig => "state",
|
||||
_ => "servuo",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Manifest {
|
||||
pub schema: u32,
|
||||
/// RFC 3339, when the backup was taken.
|
||||
pub taken: String,
|
||||
/// `install` or `update` — the verb that displaced these files.
|
||||
pub command: String,
|
||||
pub installer: String,
|
||||
/// The bundle in the record before this run, when there was one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bundle_from: Option<String>,
|
||||
/// The bundle this run is moving to.
|
||||
pub bundle_to: String,
|
||||
pub servuo_root: String,
|
||||
pub files: Vec<Entry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
/// Where the copy sits inside the backup directory, `/`-separated.
|
||||
pub path: String,
|
||||
/// Where it was copied from, absolute, as it was on this host.
|
||||
pub source: String,
|
||||
pub sha256: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
/// One run's backup. Created up front and handed to each stage that writes.
|
||||
///
|
||||
/// **The directory is created lazily, on the first capture.** A run that overwrites nothing must
|
||||
/// leave nothing behind — an empty dated directory per run would be indistinguishable from a
|
||||
/// backup that failed to record anything, and would push real ones out of the retention window.
|
||||
pub struct Session {
|
||||
dir: PathBuf,
|
||||
enabled: bool,
|
||||
started: bool,
|
||||
root: PathBuf,
|
||||
layout: Layout,
|
||||
command: &'static str,
|
||||
bundle_from: Option<String>,
|
||||
bundle_to: String,
|
||||
taken: String,
|
||||
entries: Vec<Entry>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// `enabled` is false for `--verify` (a dry run must not create state, the same rule that keeps
|
||||
/// it away from `--print-config`) and for `--no-backup`.
|
||||
pub fn new(
|
||||
layout: &Layout,
|
||||
root: &Path,
|
||||
command: &'static str,
|
||||
bundle_from: Option<String>,
|
||||
bundle_to: String,
|
||||
enabled: bool,
|
||||
) -> Self {
|
||||
let taken = chrono::Utc::now();
|
||||
Self {
|
||||
// Colons are not legal in a Windows path component, so the stamp is the basic ISO 8601
|
||||
// form. It still sorts lexicographically, which is what the pruning relies on.
|
||||
dir: layout
|
||||
.backups_dir()
|
||||
.join(taken.format("%Y%m%dT%H%M%SZ").to_string()),
|
||||
enabled,
|
||||
started: false,
|
||||
root: root.to_path_buf(),
|
||||
layout: layout.clone(),
|
||||
command,
|
||||
bundle_from,
|
||||
bundle_to,
|
||||
taken: taken.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True once something has actually been copied.
|
||||
pub fn has_entries(&self) -> bool {
|
||||
!self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// Copies `source` into this backup, if it exists and backups are enabled.
|
||||
///
|
||||
/// A file that does not exist is not an error and not an entry: the caller asks for anything it
|
||||
/// *may* be about to overwrite, and "there was nothing there" is the common answer on a first
|
||||
/// install.
|
||||
pub fn capture(&mut self, source: &Path, reason: Reason) -> Result<()> {
|
||||
if !self.enabled || !source.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let rel = self.relative_to_root(source, reason);
|
||||
let dest = self.dir.join(reason.root_dir()).join(&rel);
|
||||
if dest.exists() {
|
||||
// Two stages can name the same file — a patch target that is also a companion path, or
|
||||
// a re-entrant caller. First copy wins: it is the one taken furthest from any write.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !self.started {
|
||||
fs::create_dir_all(&self.dir).with_context(|| {
|
||||
format!("cannot create the backup directory {}", self.dir.display())
|
||||
})?;
|
||||
self.started = true;
|
||||
}
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
}
|
||||
|
||||
fs::copy(source, &dest).with_context(|| {
|
||||
format!(
|
||||
"cannot back up {} before overwriting it. Re-run with --no-backup to proceed \
|
||||
without a copy",
|
||||
source.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
self.entries.push(Entry {
|
||||
path: format!(
|
||||
"{}/{}",
|
||||
reason.root_dir(),
|
||||
rel.replace(std::path::MAIN_SEPARATOR, "/")
|
||||
),
|
||||
source: source.display().to_string(),
|
||||
sha256: util::sha256_file(source)?,
|
||||
reason: reason.as_str().to_string(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Writes `manifest.json` and prunes older backups. Returns the directory when one was written.
|
||||
///
|
||||
/// The manifest is written **last**, so a directory carrying one is a complete backup. Pruning
|
||||
/// only considers directories that have one, for the same reason: a run interrupted mid-copy
|
||||
/// must not be able to evict a good backup by being newer than it.
|
||||
pub fn finish(mut self) -> Result<Option<PathBuf>> {
|
||||
if !self.started {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
self.entries.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
let manifest = Manifest {
|
||||
schema: SCHEMA,
|
||||
taken: self.taken.clone(),
|
||||
command: self.command.to_string(),
|
||||
installer: env!("CARGO_PKG_VERSION").to_string(),
|
||||
bundle_from: self.bundle_from.clone(),
|
||||
bundle_to: self.bundle_to.clone(),
|
||||
servuo_root: self.root.display().to_string(),
|
||||
files: self.entries.clone(),
|
||||
};
|
||||
let body = serde_json::to_string_pretty(&manifest)? + "\n";
|
||||
util::write_atomic(&manifest_path(&self.dir), body.as_bytes())
|
||||
.context("cannot write the backup manifest")?;
|
||||
|
||||
prune(&self.layout, KEEP)?;
|
||||
Ok(Some(self.dir.clone()))
|
||||
}
|
||||
|
||||
/// The path a captured file takes inside the backup, relative to the root it belongs to.
|
||||
fn relative_to_root(&self, source: &Path, reason: Reason) -> String {
|
||||
let rel = match reason.root_dir() {
|
||||
"servuo" => source.strip_prefix(&self.root).unwrap_or(source),
|
||||
_ => source
|
||||
.strip_prefix(&self.layout.state_dir)
|
||||
.unwrap_or(source),
|
||||
};
|
||||
// An absolute path outside the root it was filed under would escape the backup directory
|
||||
// when joined. Falling back to the file name keeps the copy inside; the manifest still
|
||||
// records exactly where it came from.
|
||||
if rel.is_absolute() || rel.as_os_str().is_empty() {
|
||||
return source
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "file".to_string());
|
||||
}
|
||||
rel.to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_path(dir: &Path) -> PathBuf {
|
||||
dir.join("manifest.json")
|
||||
}
|
||||
|
||||
/// Every complete backup on this host, newest first.
|
||||
pub fn list(layout: &Layout) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = match fs::read_dir(layout.backups_dir()) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| manifest_path(p).is_file())
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
// The stamp is fixed-width and zero-padded, so lexicographic order is chronological order.
|
||||
dirs.sort();
|
||||
dirs.reverse();
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Reads one backup's manifest.
|
||||
pub fn read_manifest(dir: &Path) -> Result<Manifest> {
|
||||
let body = fs::read_to_string(manifest_path(dir))
|
||||
.with_context(|| format!("cannot read {}", manifest_path(dir).display()))?;
|
||||
serde_json::from_str(&body)
|
||||
.with_context(|| format!("{} is not a backup manifest", manifest_path(dir).display()))
|
||||
}
|
||||
|
||||
/// Removes all but the `keep` newest complete backups.
|
||||
pub fn prune(layout: &Layout, keep: usize) -> Result<()> {
|
||||
for old in list(layout).into_iter().skip(keep) {
|
||||
fs::remove_dir_all(&old)
|
||||
.with_context(|| format!("cannot remove the old backup {}", old.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deletes every backup. Reached only from `uninstall --purge`, alongside the config, the database
|
||||
/// and the cached patch set — they are all the same kind of thing: the only offline record of what
|
||||
/// was here before.
|
||||
pub fn remove_all(layout: &Layout) -> Result<()> {
|
||||
let dir = layout.backups_dir();
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).with_context(|| format!("cannot remove {}", dir.display()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::util::TempDir;
|
||||
|
||||
fn layout_in(root: &Path) -> Layout {
|
||||
let mut layout = crate::paths::layout();
|
||||
layout.state_dir = root.join("state");
|
||||
layout
|
||||
}
|
||||
|
||||
fn write(path: &Path, body: &str) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, body).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_overwrites_nothing_leaves_nothing_behind() {
|
||||
let tmp = TempDir::new("backup-empty").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
let root = tmp.path().join("ServUO");
|
||||
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
|
||||
// Nothing on disk to copy — the common first-install case.
|
||||
session
|
||||
.capture(
|
||||
&root.join("Scripts/Custom/Bridge/BridgeLink.cs"),
|
||||
Reason::OverlayChange,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(session.finish().unwrap().is_none());
|
||||
assert!(
|
||||
!layout.backups_dir().exists(),
|
||||
"an empty dated directory would be indistinguishable from a failed backup"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_captured_file_is_copied_verbatim_and_recorded() {
|
||||
let tmp = TempDir::new("backup-capture").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
let root = tmp.path().join("ServUO");
|
||||
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
|
||||
write(&source, "the operator's own edit\n");
|
||||
write(&layout.sidecar_config(), "[web]\nauth_token = \"secret\"\n");
|
||||
|
||||
let mut session = Session::new(
|
||||
&layout,
|
||||
&root,
|
||||
"update",
|
||||
Some("2026.08.04".into()),
|
||||
"2026.08.05".into(),
|
||||
true,
|
||||
);
|
||||
session.capture(&source, Reason::OverlayChange).unwrap();
|
||||
session
|
||||
.capture(&layout.sidecar_config(), Reason::SidecarConfig)
|
||||
.unwrap();
|
||||
let dir = session.finish().unwrap().expect("a backup was taken");
|
||||
|
||||
let copy = dir.join("servuo/Scripts/Custom/Bridge/BridgeLink.cs");
|
||||
assert_eq!(
|
||||
fs::read_to_string(©).unwrap(),
|
||||
"the operator's own edit\n"
|
||||
);
|
||||
assert!(dir.join("state/sidecar.toml").is_file());
|
||||
|
||||
let manifest = read_manifest(&dir).unwrap();
|
||||
assert_eq!(manifest.command, "update");
|
||||
assert_eq!(manifest.bundle_from.as_deref(), Some("2026.08.04"));
|
||||
assert_eq!(manifest.files.len(), 2);
|
||||
let overlay = manifest
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.reason == "overlay-change")
|
||||
.unwrap();
|
||||
// The path inside the backup is always `/`-separated, so a manifest written on Windows
|
||||
// reads the same as one written on Linux.
|
||||
assert_eq!(overlay.path, "servuo/Scripts/Custom/Bridge/BridgeLink.cs");
|
||||
assert_eq!(overlay.sha256, util::sha256_file(&source).unwrap());
|
||||
assert!(overlay.source.contains("BridgeLink.cs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_copy_of_a_file_wins() {
|
||||
// Two stages can name the same path. The earlier capture is the one taken furthest from
|
||||
// any write, so a later one must not overwrite it with content that has already changed.
|
||||
let tmp = TempDir::new("backup-twice").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
let root = tmp.path().join("ServUO");
|
||||
let source = root.join("Server/EventSink.cs");
|
||||
write(&source, "before\n");
|
||||
|
||||
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), true);
|
||||
session.capture(&source, Reason::PatchTarget).unwrap();
|
||||
write(&source, "after\n");
|
||||
session.capture(&source, Reason::PatchTarget).unwrap();
|
||||
let dir = session.finish().unwrap().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join("servuo/Server/EventSink.cs")).unwrap(),
|
||||
"before\n"
|
||||
);
|
||||
assert_eq!(read_manifest(&dir).unwrap().files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_sessions_write_nothing() {
|
||||
let tmp = TempDir::new("backup-off").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
let root = tmp.path().join("ServUO");
|
||||
let source = root.join("Scripts/Custom/Bridge/BridgeLink.cs");
|
||||
write(&source, "content\n");
|
||||
|
||||
let mut session = Session::new(&layout, &root, "install", None, "2026.08.05".into(), false);
|
||||
session.capture(&source, Reason::OverlayChange).unwrap();
|
||||
assert!(session.finish().unwrap().is_none());
|
||||
assert!(!layout.backups_dir().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pruning_keeps_the_newest_and_ignores_incomplete_directories() {
|
||||
let tmp = TempDir::new("backup-prune").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
for stamp in ["20260801T000000Z", "20260802T000000Z", "20260803T000000Z"] {
|
||||
write(
|
||||
&layout.backups_dir().join(stamp).join("manifest.json"),
|
||||
"{\"schema\":1}",
|
||||
);
|
||||
}
|
||||
// A run interrupted before its manifest was written. It must neither be listed nor be able
|
||||
// to evict a complete backup by being newer.
|
||||
write(
|
||||
&layout.backups_dir().join("20260804T000000Z/servuo/x.cs"),
|
||||
"half a copy\n",
|
||||
);
|
||||
|
||||
assert_eq!(list(&layout).len(), 3);
|
||||
prune(&layout, 2).unwrap();
|
||||
|
||||
let kept: Vec<String> = list(&layout)
|
||||
.iter()
|
||||
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert_eq!(kept, vec!["20260803T000000Z", "20260802T000000Z"]);
|
||||
assert!(
|
||||
layout.backups_dir().join("20260804T000000Z").exists(),
|
||||
"an incomplete directory is left for a human to look at, not silently deleted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_removes_every_backup() {
|
||||
let tmp = TempDir::new("backup-purge").unwrap();
|
||||
let layout = layout_in(tmp.path());
|
||||
write(
|
||||
&layout.backups_dir().join("20260801T000000Z/manifest.json"),
|
||||
"{\"schema\":1}",
|
||||
);
|
||||
remove_all(&layout).unwrap();
|
||||
assert!(!layout.backups_dir().exists());
|
||||
// Removing what is not there is not an error: `uninstall --purge` runs on hosts that never
|
||||
// took a backup.
|
||||
remove_all(&layout).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,13 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
|
||||
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
|
||||
///
|
||||
/// They live on a **branch of their own**, not on `main`, and at its root. `main` is protected, so
|
||||
/// the unattended compose job cannot push there — a pre-receive hook declines it, which is not
|
||||
/// something a nightly cron can resolve. Everything the original choice was for survives the move:
|
||||
/// a reviewable diff, a git history of the compat matrix, and a plain anonymous URL.
|
||||
const BUNDLE_BASE: &str =
|
||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
|
||||
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
|
||||
|
||||
/// The only `schema` this build understands.
|
||||
const SUPPORTED_SCHEMA: u32 = 1;
|
||||
@@ -43,8 +48,10 @@ pub struct LinkComponent {
|
||||
pub tag: String,
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
|
||||
/// the installer runs on both, so a single hash could only ever describe one of them.
|
||||
/// Keyed by platform (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`) — link publishes a
|
||||
/// binary per target and the installer runs on each, so a single hash could only ever describe
|
||||
/// one of them. The set grows over time, so a bundle is not expected to carry every key this
|
||||
/// binary knows about: an older one pinned with `--bundle` predates arm64 entirely.
|
||||
pub assets: BTreeMap<String, Asset>,
|
||||
}
|
||||
|
||||
@@ -85,7 +92,10 @@ impl Bundle {
|
||||
let key = platform_key()?;
|
||||
self.link.assets.get(key).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"bundle {} has no uo-link binary for {key} (it has: {})",
|
||||
"bundle {} has no uo-link binary for {key} (it has: {}).\n\
|
||||
Bundles published before uo-link built for this platform cannot gain one \
|
||||
retroactively — they are kept unchanged so `--bundle` stays reproducible. \
|
||||
Run without `--bundle` to take the current one.",
|
||||
self.bundle,
|
||||
self.link
|
||||
.assets
|
||||
@@ -102,12 +112,16 @@ impl Bundle {
|
||||
pub fn platform_key() -> Result<&'static str> {
|
||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => Ok("linux-x86_64"),
|
||||
// Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the
|
||||
// sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a
|
||||
// Windows-on-arm box or a Mac.
|
||||
("linux", "aarch64") => Ok("linux-aarch64"),
|
||||
("windows", "x86_64") => Ok("windows-x86_64"),
|
||||
// arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats
|
||||
// failing later with a missing-key error that reads like a corrupt bundle.
|
||||
// Naming the platforms that do exist beats failing later with a missing-key error that
|
||||
// reads like a corrupt bundle.
|
||||
(os, arch) => bail!(
|
||||
"no Runic Gateway build exists for {os}/{arch}. \
|
||||
The released components target linux-x86_64 and windows-x86_64."
|
||||
The released components target linux-x86_64, linux-aarch64 and windows-x86_64."
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -177,9 +191,13 @@ pub fn parse(body: &str) -> Result<Bundle> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
|
||||
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
|
||||
const CURRENT: &str = include_str!("../bundles/current.json");
|
||||
/// The first published bundle, verbatim. Using a real document rather than a hand-written
|
||||
/// stand-in is the point: it is what CI actually emits.
|
||||
///
|
||||
/// It is a frozen copy rather than a live include, because published bundles moved off `main`
|
||||
/// onto a branch this checkout does not carry. Frozen is the honest shape anyway — a test that
|
||||
/// silently re-targeted whatever CI published last would change meaning without a commit.
|
||||
const CURRENT: &str = include_str!("../tests/fixtures/published-bundle.json");
|
||||
|
||||
#[test]
|
||||
fn the_published_bundle_parses() {
|
||||
@@ -190,18 +208,56 @@ mod tests {
|
||||
assert_eq!(bundle.link.version, "1.1.0");
|
||||
assert_eq!(bundle.overlay.version, "0.1.1");
|
||||
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
|
||||
assert_eq!(bundle.link.assets.len(), 2);
|
||||
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_platforms_have_a_sidecar_binary() {
|
||||
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
|
||||
// host's binary would fail an install after the overlay had already been deployed.
|
||||
fn every_platform_the_bundle_names_is_well_formed() {
|
||||
// A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release
|
||||
// (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it
|
||||
// rather than on anything being wrong.
|
||||
let bundle = parse(CURRENT).unwrap();
|
||||
let asset = bundle.sidecar_asset().unwrap();
|
||||
assert_eq!(asset.sha256.len(), 64);
|
||||
assert!(asset.url.contains(&bundle.link.tag));
|
||||
for required in ["linux-x86_64", "windows-x86_64"] {
|
||||
let asset = bundle
|
||||
.link
|
||||
.assets
|
||||
.get(required)
|
||||
.unwrap_or_else(|| panic!("bundle carries no {required} binary"));
|
||||
assert_eq!(asset.sha256.len(), 64);
|
||||
assert!(asset.url.contains(&bundle.link.tag));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hosts_binary_either_resolves_or_says_why_not() {
|
||||
// On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise
|
||||
// fail an install after the overlay had already been deployed. On a host whose platform
|
||||
// postdates the bundle (an arm64 box reading the first published one), it must fail with
|
||||
// the reason, since every bundle is kept unchanged forever so `--bundle` stays
|
||||
// reproducible and therefore cannot gain a key retroactively.
|
||||
let bundle = parse(CURRENT).unwrap();
|
||||
match bundle.sidecar_asset() {
|
||||
Ok(asset) => {
|
||||
assert_eq!(asset.sha256.len(), 64);
|
||||
assert!(asset.url.contains(&bundle.link.tag));
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains(platform_key().unwrap()), "{msg}");
|
||||
assert!(msg.contains("--bundle"), "{msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_host_is_a_platform_the_components_are_built_for() {
|
||||
// `cargo test` running at all means the host is one the crate compiles on, so a refusal
|
||||
// here is a build target the release workflows have not caught up with.
|
||||
let key = platform_key().unwrap();
|
||||
assert!(
|
||||
["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key),
|
||||
"unexpected platform key {key}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
16
src/cli.rs
16
src/cli.rs
@@ -81,8 +81,11 @@ pub struct Cli {
|
||||
pub site_url: Option<String>,
|
||||
/// `--yes`: assume the default answer to every prompt.
|
||||
pub assume_yes: bool,
|
||||
/// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`.
|
||||
/// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set
|
||||
/// and every backup.
|
||||
pub purge: bool,
|
||||
/// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3).
|
||||
pub no_backup: bool,
|
||||
}
|
||||
|
||||
impl Default for Cli {
|
||||
@@ -98,6 +101,7 @@ impl Default for Cli {
|
||||
site_url: None,
|
||||
assume_yes: false,
|
||||
purge: false,
|
||||
no_backup: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,8 +142,13 @@ Options:
|
||||
On uninstall it means yes: that prompt
|
||||
defaults to no, and typing `uninstall
|
||||
--yes` is not an accident.
|
||||
--purge uninstall. Also delete sidecar.toml and
|
||||
uo-link.db, which are otherwise kept.
|
||||
--no-backup install, update. Do not copy the files
|
||||
this run is about to overwrite. They are
|
||||
otherwise saved under <state>/backups/,
|
||||
newest 3 kept.
|
||||
--purge uninstall. Also delete sidecar.toml,
|
||||
uo-link.db, the cached patch set and every
|
||||
backup, all of which are otherwise kept.
|
||||
-V, --version Print the installer version and exit.
|
||||
-h, --help Print this help and exit.
|
||||
|
||||
@@ -182,6 +191,7 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
|
||||
"--verify" => cli.verify = true,
|
||||
"--yes" | "-y" => cli.assume_yes = true,
|
||||
"--purge" => cli.purge = true,
|
||||
"--no-backup" => cli.no_backup = true,
|
||||
"--patches" => cli.patches = PatchChoice::Yes,
|
||||
"--no-patches" => cli.patches = PatchChoice::No,
|
||||
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
|
||||
|
||||
@@ -158,6 +158,9 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
// ── The bundle ───────────────────────────────────────────────────────────
|
||||
rows.push(bundle_row(&record));
|
||||
|
||||
// ── Backups ──────────────────────────────────────────────────────────────
|
||||
rows.push(backup_row(&layout));
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────────
|
||||
println!();
|
||||
for row in &rows {
|
||||
@@ -673,6 +676,35 @@ fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>)
|
||||
/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way
|
||||
/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it
|
||||
/// would report a working deployment as broken.
|
||||
/// The most recent backup, so "can I go back?" is answerable without knowing the layout.
|
||||
///
|
||||
/// Always `✓`, never a failure: having no backup is the correct state on a host that has never
|
||||
/// overwritten anything, and a shard that is running fine does not become broken because nothing
|
||||
/// has displaced a file yet.
|
||||
fn backup_row(layout: &paths::Layout) -> Row {
|
||||
let backups = crate::backup::list(layout);
|
||||
let Some(newest) = backups.first() else {
|
||||
return Row::ok("Backups", "none taken — no run has replaced a file yet");
|
||||
};
|
||||
let detail = match crate::backup::read_manifest(newest) {
|
||||
Ok(manifest) => format!(
|
||||
"{} — {} file(s) replaced by {} to bundle {}",
|
||||
manifest.taken,
|
||||
manifest.files.len(),
|
||||
manifest.command,
|
||||
manifest.bundle_to
|
||||
),
|
||||
// A directory with an unreadable manifest is still a directory of the operator's files, so
|
||||
// it is reported rather than skipped.
|
||||
Err(_) => format!("{} — manifest unreadable", newest.display()),
|
||||
};
|
||||
Row::ok("Backups", detail).note(format!(
|
||||
"{} kept in {}",
|
||||
backups.len(),
|
||||
layout.backups_dir().display()
|
||||
))
|
||||
}
|
||||
|
||||
fn bundle_row(record: &InstallRecord) -> Row {
|
||||
let url = bundle::url_for(None);
|
||||
let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) {
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::record::{
|
||||
};
|
||||
use crate::servuo::ServUoRoot;
|
||||
use crate::util::TempDir;
|
||||
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
||||
use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
|
||||
|
||||
/// Which verb is driving the pipeline.
|
||||
///
|
||||
@@ -185,6 +185,23 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||
let summary = overlay::summarize(&planned);
|
||||
|
||||
// ── Backup ───────────────────────────────────────────────────────────────
|
||||
// Created before the first write and handed to every stage that overwrites, so each copy is
|
||||
// taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily:
|
||||
// a run that displaces nothing leaves nothing behind.
|
||||
let mut backup = backup::Session::new(
|
||||
&layout,
|
||||
&root.path,
|
||||
if mode.is_update() {
|
||||
"update"
|
||||
} else {
|
||||
"install"
|
||||
},
|
||||
prior.as_ref().map(|p| p.bundle.tag.clone()),
|
||||
bundle.bundle.clone(),
|
||||
!cli.verify && !cli.no_backup,
|
||||
);
|
||||
|
||||
ui::heading("Overlay sync");
|
||||
let lines = overlay::render(&planned);
|
||||
if lines.is_empty() {
|
||||
@@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
summary.add, summary.change, summary.unchanged, summary.kept
|
||||
);
|
||||
} else {
|
||||
// `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what
|
||||
// would replace it, and `KeptOperatorModified` is not written at all — copying those three
|
||||
// would bury the files that are actually being displaced.
|
||||
for file in planned
|
||||
.iter()
|
||||
.filter(|f| f.action == overlay::Action::Change)
|
||||
{
|
||||
backup.capture(&file.dst, backup::Reason::OverlayChange)?;
|
||||
}
|
||||
overlay::apply(&planned)?;
|
||||
// "deployed" is claimed only when something actually moved. A run that copied nothing
|
||||
// reporting "deployed" would read as a fresh install to anyone skimming the output.
|
||||
@@ -246,8 +272,18 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
.as_ref()
|
||||
.map(|p| p.patch_records())
|
||||
.unwrap_or_default(),
|
||||
&mut backup,
|
||||
)?;
|
||||
|
||||
// The config joins a backup that is already being taken; it is never the reason for one. The
|
||||
// installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that
|
||||
// a restored set of files comes with the token that matches them, rather than an operator
|
||||
// restoring a tree and then finding the website pointed at a token that has moved on.
|
||||
if backup.has_entries() {
|
||||
backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?;
|
||||
}
|
||||
let backup_dir = backup.finish()?;
|
||||
|
||||
// ── The sidecar and its service ──────────────────────────────────────────
|
||||
let sidecar = install_sidecar(
|
||||
cli,
|
||||
@@ -294,6 +330,19 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
// Named after the record rather than at the moment it was taken, because that is where an
|
||||
// operator looks when a run has finished and something is wrong. Restoring is theirs to do:
|
||||
// the installer cannot know what has changed since, and putting an old `.cs` file back over a
|
||||
// newer overlay eats work rather than saving it.
|
||||
if let Some(dir) = &backup_dir {
|
||||
println!("\n Backed up {}", dir.display());
|
||||
println!(
|
||||
" the files this run replaced, with a manifest naming each one.\n\
|
||||
\x20 The newest {} backups are kept; `uninstall --purge` removes them.",
|
||||
backup::KEEP
|
||||
);
|
||||
}
|
||||
|
||||
// ── Closing notes ────────────────────────────────────────────────────────
|
||||
println!();
|
||||
if cli.verify {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
|
||||
//! name. Nothing an operator sees changes.
|
||||
|
||||
pub mod backup;
|
||||
pub mod bundle;
|
||||
pub mod cli;
|
||||
pub mod diff;
|
||||
|
||||
10
src/paths.rs
10
src/paths.rs
@@ -68,6 +68,16 @@ impl Layout {
|
||||
self.patches_dir().join("originals")
|
||||
}
|
||||
|
||||
/// `/etc/runicgateway/backups` — one dated directory per run that overwrote something
|
||||
/// (PLAN.md §5.3).
|
||||
///
|
||||
/// Beside the cached patch set rather than inside it: both survive an uninstall and both go
|
||||
/// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy
|
||||
/// of what the *release* shipped.
|
||||
pub fn backups_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("backups")
|
||||
}
|
||||
|
||||
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
|
||||
/// layout, where no service is registered at all.
|
||||
pub fn systemd_unit(&self) -> PathBuf {
|
||||
|
||||
105
src/service.rs
105
src/service.rs
@@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
|
||||
pub const SERVICE_USER: &str = "runicgateway";
|
||||
/// What both platforms show a human.
|
||||
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
|
||||
/// The first `link` release whose sidecar speaks the Windows SCM startup protocol, and so the
|
||||
/// oldest one that can be started as a service at all. Named only in the 1053 diagnosis; nothing
|
||||
/// enforces it, because the Linux side has no such floor and a version gate on an installed binary
|
||||
/// would refuse deployments that are working.
|
||||
const MIN_SERVICE_SIDECAR: &str = "v1.2.0";
|
||||
|
||||
/// Which service manager this host has — or why it has none this installer can drive.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -307,6 +312,57 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
|
||||
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
|
||||
}
|
||||
|
||||
/// What to tell the operator when `sc.exe start` fails.
|
||||
///
|
||||
/// Pure and tested on both platforms, because the *wrong* explanation here is expensive. This
|
||||
/// originally blamed every failure on the config file — "a service that exits immediately usually
|
||||
/// cannot read its config" — which for the one error code that actually shows up sends the reader
|
||||
/// to inspect a file that is almost certainly fine.
|
||||
///
|
||||
/// **1053 is not a crash.** It is the SCM giving up after 30 seconds waiting for the service
|
||||
/// process to call `StartServiceCtrlDispatcher` and identify itself. The process starts, runs, and
|
||||
/// is very likely serving traffic; it simply never had the conversation the SCM required. A sidecar
|
||||
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
|
||||
/// config — so the config is the last thing to look at, not the first.
|
||||
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
|
||||
let command = crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]);
|
||||
match code {
|
||||
1053 => format!(
|
||||
"`{command}` failed with 1053 — the service did not respond to the start request in \
|
||||
time.\n\n This is a handshake failure, not a crash: Windows waited 30 seconds for \
|
||||
the process to identify itself to the service control manager. The usual cause is a \
|
||||
sidecar built before the service support was added, which runs perfectly in the \
|
||||
foreground and can never start as a service. Check its version:\n\n \
|
||||
\"{binary}\" --version\n\n and confirm it is at least {MIN_SERVICE_SIDECAR}. To \
|
||||
see whether the sidecar itself is healthy, run it in the foreground — if that works, \
|
||||
the binary is the problem, not the configuration:\n\n \"{binary}\" --config \
|
||||
\"{config}\"",
|
||||
binary = binary.display(),
|
||||
config = config.display(),
|
||||
),
|
||||
// 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.
|
||||
1069 => format!(
|
||||
"`{command}` failed with 1069 — the service could not log on as {account}.\n\n \
|
||||
That account is a virtual service account created by the SCM itself and has no \
|
||||
password, so this is a local policy forbidding them rather than a bad credential. \
|
||||
Register the service by hand against an account this host allows — INSTALL.md \
|
||||
Appendix A4.",
|
||||
account = windows_service_account(),
|
||||
),
|
||||
_ => format!(
|
||||
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
|
||||
(System, source \"Service Control Manager\"), and `sc query {WINDOWS_SERVICE}` for \
|
||||
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 \"{}\"",
|
||||
config.display(),
|
||||
binary.display(),
|
||||
config.display(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
|
||||
let bin_path = windows_bin_path(binary, config);
|
||||
@@ -373,13 +429,11 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
|
||||
// 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!(
|
||||
"`{}` failed with exit code {}. Check the Windows event log; a service that exits \
|
||||
immediately usually cannot read its config: {}",
|
||||
crate::util::command_line("sc.exe", &["start", WINDOWS_SERVICE]),
|
||||
anyhow::bail!(windows_start_failure(
|
||||
start.status.code().unwrap_or(-1),
|
||||
config.display()
|
||||
);
|
||||
binary,
|
||||
config
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Outcome::Registered {
|
||||
@@ -994,6 +1048,45 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_1053_is_diagnosed_as_a_handshake_not_a_bad_config() {
|
||||
// The regression this guards: 1053 used to be reported as "a service that exits immediately
|
||||
// usually cannot read its config", which is the one thing it almost never is. A reader who
|
||||
// follows that sentence goes and stares at a config file that is fine.
|
||||
let msg = windows_start_failure(
|
||||
1053,
|
||||
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
|
||||
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
|
||||
);
|
||||
assert!(msg.contains("1053"), "{msg}");
|
||||
assert!(msg.contains("handshake"), "{msg}");
|
||||
assert!(!msg.contains("cannot read its config"), "{msg}");
|
||||
// It has to name the two things that actually resolve it: check the version, and prove the
|
||||
// binary is healthy by running it in the foreground.
|
||||
assert!(msg.contains("--version"), "{msg}");
|
||||
assert!(msg.contains(MIN_SERVICE_SIDECAR), "{msg}");
|
||||
assert!(msg.contains("uo-link-sidecar.exe"), "{msg}");
|
||||
assert!(msg.contains("sidecar.toml"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_logon_failure_points_at_policy_not_a_password() {
|
||||
let msg = windows_start_failure(1069, Path::new("bin.exe"), Path::new("c.toml"));
|
||||
assert!(msg.contains("NT SERVICE\\RunicGatewayLink"), "{msg}");
|
||||
assert!(msg.contains("policy"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unrecognized_code_still_says_how_to_see_the_real_error() {
|
||||
// The fallback must not pretend to know the cause; it must hand over the two places the
|
||||
// cause is actually written down.
|
||||
let msg = windows_start_failure(5, Path::new("bin.exe"), Path::new("c.toml"));
|
||||
assert!(msg.contains("exit code 5"), "{msg}");
|
||||
assert!(msg.contains("event log"), "{msg}");
|
||||
assert!(msg.contains("sc query RunicGatewayLink"), "{msg}");
|
||||
assert!(msg.contains("--config"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_manual_steps_are_a_complete_recipe() {
|
||||
// This text is all an operator gets on a host the installer cannot drive, so it has to name
|
||||
|
||||
20
src/tier.rs
20
src/tier.rs
@@ -95,6 +95,7 @@ pub fn run(
|
||||
declared: Option<&Tier>,
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
backup: &mut crate::backup::Session,
|
||||
) -> Result<Outcome> {
|
||||
let declared_tier = Tier::resolve(declared);
|
||||
if declared_tier.features.is_empty() {
|
||||
@@ -131,7 +132,7 @@ pub fn run(
|
||||
),
|
||||
);
|
||||
announce_new_features(&declared_tier, &tier);
|
||||
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported);
|
||||
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup);
|
||||
}
|
||||
|
||||
match consent(cli, root, supported, &tier)? {
|
||||
@@ -157,7 +158,7 @@ pub fn run(
|
||||
}
|
||||
}
|
||||
|
||||
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
|
||||
apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup)
|
||||
}
|
||||
|
||||
/// The subset of a release's tier that a previous run actually applied.
|
||||
@@ -322,6 +323,7 @@ fn apply_tier(
|
||||
layout: &paths::Layout,
|
||||
prior: &[FeatureRecord],
|
||||
supported: bool,
|
||||
backup: &mut crate::backup::Session,
|
||||
) -> Result<Outcome> {
|
||||
let previous = patch::index_records(prior);
|
||||
let mut records: Vec<FeatureRecord> = Vec::new();
|
||||
@@ -353,7 +355,7 @@ fn apply_tier(
|
||||
}
|
||||
|
||||
if !cli.verify {
|
||||
write_feature(root, unpacked, feature, &resolved, layout)?;
|
||||
write_feature(root, unpacked, feature, &resolved, layout, backup)?;
|
||||
}
|
||||
applied_patches += resolved.len();
|
||||
|
||||
@@ -521,9 +523,18 @@ fn write_feature(
|
||||
feature: &Feature,
|
||||
resolved: &[Resolved],
|
||||
layout: &paths::Layout,
|
||||
backup: &mut crate::backup::Session,
|
||||
) -> Result<()> {
|
||||
for r in resolved {
|
||||
if let Resolution::Applicable { edits, .. } = &r.resolution {
|
||||
// `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the
|
||||
// right thing to revert to. It is not a copy of what this file looked like before *this*
|
||||
// run, though — on a second tier pass the operator's own later edits are only in the
|
||||
// backup (PLAN.md §5.3).
|
||||
backup.capture(
|
||||
&patch::join(&root.path, &r.target),
|
||||
crate::backup::Reason::PatchTarget,
|
||||
)?;
|
||||
let original = patch::join(&layout.patch_originals_dir(), &r.target);
|
||||
if !original.exists() {
|
||||
write_atomic(&original, &r.content)
|
||||
@@ -542,6 +553,9 @@ fn write_feature(
|
||||
for companion in &feature.companions {
|
||||
let src = patch::join(unpacked, &companion.file);
|
||||
let dst = patch::join(&root.path, &companion.install_to);
|
||||
// Copied unconditionally, like every other `.cs` the overlay owns — so an operator who
|
||||
// edited one loses it here unless a copy is taken first.
|
||||
backup.capture(&dst, crate::backup::Reason::PatchCompanion)?;
|
||||
if let Some(parent) = dst.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||
|
||||
@@ -121,11 +121,24 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
||||
|
||||
if cli.purge {
|
||||
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
|
||||
} else if layout.patches_dir().exists() {
|
||||
done.push(format!(
|
||||
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
|
||||
layout.patches_dir().display()
|
||||
));
|
||||
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
|
||||
} else {
|
||||
if layout.patches_dir().exists() {
|
||||
done.push(format!(
|
||||
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
|
||||
layout.patches_dir().display()
|
||||
));
|
||||
}
|
||||
// Same rule and the same reason as the patch cache: a backup is the only copy of what this
|
||||
// host had before an upgrade replaced it, and it outlives the deployment that took it.
|
||||
let backups = crate::backup::list(&layout);
|
||||
if !backups.is_empty() {
|
||||
done.push(format!(
|
||||
"kept {} — {} backup(s) of files earlier runs replaced",
|
||||
layout.backups_dir().display(),
|
||||
backups.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
remove_file(&record_path, &mut done, &mut problems);
|
||||
|
||||
@@ -181,6 +194,7 @@ fn print_intent(
|
||||
println!(" · {}", layout.install_record().display());
|
||||
if purge {
|
||||
println!(" · {} [--purge]", layout.patches_dir().display());
|
||||
println!(" · {} [--purge]", layout.backups_dir().display());
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -199,6 +213,14 @@ fn print_intent(
|
||||
" · {} (cached patches and pre-patch originals)",
|
||||
layout.patches_dir().display()
|
||||
);
|
||||
let backups = crate::backup::list(layout);
|
||||
if !backups.is_empty() {
|
||||
println!(
|
||||
" · {} ({} backup(s) of files earlier runs replaced)",
|
||||
layout.backups_dir().display(),
|
||||
backups.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = record;
|
||||
println!();
|
||||
@@ -241,6 +263,7 @@ fn render_report(
|
||||
|
||||
render_overlay_section(&mut out, record);
|
||||
render_patch_section(&mut out, record, layout, purge);
|
||||
render_backup_section(&mut out, layout, purge);
|
||||
|
||||
let _ = writeln!(
|
||||
out,
|
||||
@@ -373,6 +396,50 @@ fn render_patch_section(
|
||||
}
|
||||
}
|
||||
|
||||
/// The backups earlier runs took, since this report is the durable record of what was left behind.
|
||||
///
|
||||
/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by
|
||||
/// the time this report is read the run that took it is long out of the scrollback.
|
||||
fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) {
|
||||
let backups = crate::backup::list(layout);
|
||||
if backups.is_empty() {
|
||||
return;
|
||||
}
|
||||
if purge {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"
|
||||
── Backups ──────────────────────────────────────────────────────────────────
|
||||
|
||||
{} backup(s) of files earlier runs replaced were removed by --purge.
|
||||
",
|
||||
backups.len()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"
|
||||
── Backups ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Copies of the files earlier runs replaced, newest first. These are kept:
|
||||
"
|
||||
);
|
||||
for dir in &backups {
|
||||
let count = crate::backup::read_manifest(dir)
|
||||
.map(|m| m.files.len())
|
||||
.unwrap_or(0);
|
||||
let _ = writeln!(out, " {} ({} file(s))", dir.display(), count);
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"
|
||||
Each carries a manifest.json naming where every file came from. Restoring is yours to
|
||||
do — this tool will not put an old file back over a newer one. `--purge` removes them.
|
||||
"
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders one cached patch's added and removed lines, indented for the report.
|
||||
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
|
||||
let path = layout.patches_dir().join(format!("{name}.patch"));
|
||||
|
||||
Reference in New Issue
Block a user