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.
|
# byte-identical.
|
||||||
#
|
#
|
||||||
# ── Where it is published, and why not as a release ──────────────────────────
|
# ── 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
|
# current.json the bundle the installer uses by default
|
||||||
# bundles/bundle-<tag>.json every bundle ever published, kept for --bundle
|
# 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:
|
# 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/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
|
||||||
|
#
|
||||||
|
# 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
|
# 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
|
||||||
@@ -30,8 +38,8 @@
|
|||||||
# intermittently resolve to a release containing no installer binary. Committing
|
# 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.
|
# 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.)
|
||||||
#
|
#
|
||||||
# ── 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 and servuo-plugins' release workflows
|
||||||
@@ -86,14 +94,44 @@ jobs:
|
|||||||
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.
|
||||||
|
# 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
|
- name: Install jq and curl
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -173,22 +211,33 @@ jobs:
|
|||||||
|
|
||||||
# Map link's binaries onto platform keys. The pattern is asserted, not
|
# Map link's binaries onto platform keys. The pattern is asserted, not
|
||||||
# assumed: an unrecognized asset name is a hard failure so that adding
|
# 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
|
# a target to link's release.yml (macOS, a Windows arm64) surfaces here
|
||||||
# red run, rather than being silently dropped from every bundle.
|
# 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
|
: > work/link-platforms.tsv
|
||||||
while IFS="$(printf '\t')" read -r NAME URL; do
|
while IFS="$(printf '\t')" read -r NAME URL; do
|
||||||
[ -n "$NAME" ] || continue
|
[ -n "$NAME" ] || continue
|
||||||
case "$NAME" in
|
case "$NAME" in
|
||||||
*-linux-x86_64) PLAT=linux-x86_64 ;;
|
*-linux-x86_64) PLAT=linux-x86_64 ;;
|
||||||
|
*-linux-aarch64) PLAT=linux-aarch64 ;;
|
||||||
*-windows-x86_64.exe) PLAT=windows-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." ;;
|
*) 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
|
esac
|
||||||
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
|
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
|
||||||
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
|
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
|
||||||
done < work/link/asset-list.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 \
|
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
|
done
|
||||||
|
|
||||||
# The overlay release carries exactly one artifact: the tarball.
|
# 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
|
# commit a dated duplicate of the same matrix forever. Compare only
|
||||||
# what the installer would actually act on.
|
# what the installer would actually act on.
|
||||||
CHANGED=true
|
CHANGED=true
|
||||||
if [ -f bundles/current.json ]; then
|
if [ -f published/current.json ]; then
|
||||||
if jq -S 'del(.bundle, .generated)' bundles/current.json > work/old-content.json \
|
if jq -S 'del(.bundle, .generated)' published/current.json > work/old-content.json \
|
||||||
&& jq -S '.' work/content.json > work/new-content.json \
|
&& jq -S '.' work/content.json > work/new-content.json \
|
||||||
&& cmp -s work/old-content.json work/new-content.json; then
|
&& cmp -s work/old-content.json work/new-content.json; then
|
||||||
CHANGED=false
|
CHANGED=false
|
||||||
@@ -340,7 +389,7 @@ jobs:
|
|||||||
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
if [ "$CHANGED" = false ]; then
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -350,15 +399,14 @@ jobs:
|
|||||||
# always names exactly one matrix and `--bundle` stays reproducible.
|
# always names exactly one matrix and `--bundle` stays reproducible.
|
||||||
BASE="$(date -u +%Y.%m.%d)"
|
BASE="$(date -u +%Y.%m.%d)"
|
||||||
TAG="$BASE"; N=1
|
TAG="$BASE"; N=1
|
||||||
while [ -f "bundles/bundle-${TAG}.json" ]; do
|
while [ -f "published/bundle-${TAG}.json" ]; do
|
||||||
N=$((N+1)); TAG="${BASE}.${N}"
|
N=$((N+1)); TAG="${BASE}.${N}"
|
||||||
done
|
done
|
||||||
|
|
||||||
mkdir -p bundles
|
|
||||||
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||||
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
|
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
|
||||||
work/content.json > "bundles/bundle-${TAG}.json"
|
work/content.json > "published/bundle-${TAG}.json"
|
||||||
cp "bundles/bundle-${TAG}.json" bundles/current.json
|
cp "published/bundle-${TAG}.json" published/current.json
|
||||||
|
|
||||||
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||||
echo "==> composed bundle ${TAG}"
|
echo "==> composed bundle ${TAG}"
|
||||||
@@ -387,9 +435,11 @@ 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.)
|
||||||
: > work/stale-warnings.md
|
: > work/stale-warnings.md
|
||||||
|
|
||||||
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
|
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
|
||||||
@@ -470,27 +520,25 @@ jobs:
|
|||||||
# 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
|
cd published
|
||||||
|
git add -A
|
||||||
git commit -m "chore(bundle): publish ${TAG} (link ${{ steps.resolve.outputs.link_tag }}, overlay ${{ steps.resolve.outputs.overlay_tag }}, protocol ${{ steps.protocol.outputs.protocol }}) [skip ci]"
|
git commit -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
|
# Two runs can compose at once — a component release dispatches this
|
||||||
# to the branch the installer reads its raw URLs from. release.yml
|
# while the nightly cron is mid-flight — so losing the race is normal
|
||||||
# pushes its version-bump commit to the same branch, so losing the
|
# rather than exceptional. Rebase and retry once instead of failing and
|
||||||
# race is normal rather than exceptional — rebase and retry once
|
# leaving the bundle unpublished until tomorrow. Every file here is a
|
||||||
# instead of failing and leaving the bundle unpublished until the
|
# bundle nobody else edits, and a bundle tag names exactly one matrix,
|
||||||
# next cron. Only bundles/ is touched here, so a rebase over a bump
|
# so a rebase cannot conflict.
|
||||||
# commit cannot conflict.
|
if ! git push origin bundles; then
|
||||||
if ! git push origin "HEAD:main"; then
|
echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once"
|
||||||
echo "::warning::push rejected (main moved during compose) — rebasing and retrying once"
|
git fetch origin bundles
|
||||||
git fetch origin main
|
git rebase origin/bundles
|
||||||
git rebase origin/main
|
git push origin bundles
|
||||||
git push origin "HEAD:main"
|
|
||||||
fi
|
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
|
- name: Job summary
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -66,11 +66,15 @@ env:
|
|||||||
BIN: runicgateway-installer
|
BIN: runicgateway-installer
|
||||||
LINUX_TARGET: x86_64-unknown-linux-gnu
|
LINUX_TARGET: x86_64-unknown-linux-gnu
|
||||||
WINDOWS_TARGET: x86_64-pc-windows-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:
|
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') }}"
|
||||||
@@ -237,14 +241,19 @@ jobs:
|
|||||||
echo "Release credentials present."
|
echo "Release credentials present."
|
||||||
|
|
||||||
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
|
# ── 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' }}
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
run: |
|
run: |
|
||||||
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 \
|
||||||
@@ -254,6 +263,7 @@ jobs:
|
|||||||
export PATH="${HOME}/.cargo/bin:${PATH}"
|
export PATH="${HOME}/.cargo/bin:${PATH}"
|
||||||
rustup component add rustfmt
|
rustup component add rustfmt
|
||||||
rustup target add "${WINDOWS_TARGET}"
|
rustup target add "${WINDOWS_TARGET}"
|
||||||
|
rustup target add "${ARM64_TARGET}"
|
||||||
|
|
||||||
- name: Set the crate version to match the release
|
- name: Set the crate version to match the release
|
||||||
if: ${{ steps.plan.outputs.release == 'true' }}
|
if: ${{ steps.plan.outputs.release == 'true' }}
|
||||||
@@ -291,6 +301,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.
|
||||||
@@ -299,38 +321,54 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
|
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"
|
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
|
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,7 +403,7 @@ jobs:
|
|||||||
| jq -r '.id')"
|
| jq -r '.id')"
|
||||||
echo "Created release ${TAG} (id=${REL_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}" \
|
curl -sSf -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}" >/dev/null
|
||||||
|
|||||||
50
README.md
50
README.md
@@ -27,7 +27,13 @@ never restarts the shard.
|
|||||||
|
|
||||||
## Status
|
## 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
|
The design of record is
|
||||||
[`installer/PLAN.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/PLAN.md)
|
[`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 |
|
| 0 — prerequisites in the other repos | ✅ merged |
|
||||||
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
|
||||||
| 2 — uo-link install + service registration | next |
|
| 2 — uo-link install + service registration | ✅ on `edge` |
|
||||||
| 3 — the opt-in stock-file patch tier | |
|
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
|
||||||
| 4 — `doctor`, `update`, `uninstall` | |
|
| 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
|
**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
|
`main`, so nothing lands there until the whole tool is worth handing to an
|
||||||
is not something to hand an operator. Phases 1 and 2 land on `edge`; the
|
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
|
||||||
`edge → main` cutover cuts the first release. PRs into `edge` run the same gates
|
the same gates as PRs into `main`.
|
||||||
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)
|
[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`.
|
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/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. |
|
| [RunicGateway/docs](https://gitea.whitlocktech.com/RunicGateway/docs) | All project documentation, including the installer plan. |
|
||||||
|
|
||||||
## Planned commands
|
## Commands
|
||||||
|
|
||||||
| Command | What it does |
|
| 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
|
- **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
|
exit code and silently reloads the previous `Scripts.dll`, so diagnostics verify
|
||||||
post-boot state rather than trusting a clean boot.
|
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.
|
- **The audience is public** — any ServUO operator, not only shards we run.
|
||||||
|
|
||||||
## Build & 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
|
cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test
|
||||||
```
|
```
|
||||||
|
|
||||||
`RUNICGATEWAY_STATE_DIR` relocates `install.json` (normally `/etc/runicgateway`
|
`RUNICGATEWAY_STATE_DIR` relocates **everything the installer writes** — state,
|
||||||
or `%ProgramData%\RunicGateway`), which is how a run is tested without root.
|
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:
|
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.
|
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 |
|
| 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
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -56,8 +69,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": "…" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 —
|
/// 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).
|
/// 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 =
|
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.
|
/// The only `schema` this build understands.
|
||||||
const SUPPORTED_SCHEMA: u32 = 1;
|
const SUPPORTED_SCHEMA: u32 = 1;
|
||||||
@@ -43,8 +48,10 @@ pub struct LinkComponent {
|
|||||||
pub tag: String,
|
pub tag: String,
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub protocol: u32,
|
pub protocol: u32,
|
||||||
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
|
/// Keyed by platform (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`) — link publishes a
|
||||||
/// the installer runs on both, so a single hash could only ever describe one of them.
|
/// 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>,
|
pub assets: BTreeMap<String, Asset>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +92,10 @@ impl Bundle {
|
|||||||
let key = platform_key()?;
|
let key = platform_key()?;
|
||||||
self.link.assets.get(key).ok_or_else(|| {
|
self.link.assets.get(key).ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
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.bundle,
|
||||||
self.link
|
self.link
|
||||||
.assets
|
.assets
|
||||||
@@ -102,12 +112,16 @@ impl Bundle {
|
|||||||
pub fn platform_key() -> Result<&'static str> {
|
pub fn platform_key() -> Result<&'static str> {
|
||||||
match (std::env::consts::OS, std::env::consts::ARCH) {
|
match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||||
("linux", "x86_64") => Ok("linux-x86_64"),
|
("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"),
|
("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
|
// Naming the platforms that do exist beats failing later with a missing-key error that
|
||||||
// failing later with a missing-key error that reads like a corrupt bundle.
|
// reads like a corrupt bundle.
|
||||||
(os, arch) => bail!(
|
(os, arch) => bail!(
|
||||||
"no Runic Gateway build exists for {os}/{arch}. \
|
"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 {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
|
/// The first published bundle, verbatim. Using a real document rather than a hand-written
|
||||||
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
|
/// stand-in is the point: it is what CI actually emits.
|
||||||
const CURRENT: &str = include_str!("../bundles/current.json");
|
///
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn the_published_bundle_parses() {
|
fn the_published_bundle_parses() {
|
||||||
@@ -190,19 +208,57 @@ mod tests {
|
|||||||
assert_eq!(bundle.link.version, "1.1.0");
|
assert_eq!(bundle.link.version, "1.1.0");
|
||||||
assert_eq!(bundle.overlay.version, "0.1.1");
|
assert_eq!(bundle.overlay.version, "0.1.1");
|
||||||
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
|
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"));
|
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn both_platforms_have_a_sidecar_binary() {
|
fn every_platform_the_bundle_names_is_well_formed() {
|
||||||
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
|
// A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release
|
||||||
// host's binary would fail an install after the overlay had already been deployed.
|
// (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 bundle = parse(CURRENT).unwrap();
|
||||||
let asset = bundle.sidecar_asset().unwrap();
|
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_eq!(asset.sha256.len(), 64);
|
||||||
assert!(asset.url.contains(&bundle.link.tag));
|
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]
|
#[test]
|
||||||
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
fn a_newer_schema_is_refused_rather_than_guessed_at() {
|
||||||
|
|||||||
16
src/cli.rs
16
src/cli.rs
@@ -81,8 +81,11 @@ pub struct Cli {
|
|||||||
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,
|
||||||
/// `--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,
|
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 {
|
impl Default for Cli {
|
||||||
@@ -98,6 +101,7 @@ impl Default for Cli {
|
|||||||
site_url: None,
|
site_url: None,
|
||||||
assume_yes: false,
|
assume_yes: false,
|
||||||
purge: false,
|
purge: false,
|
||||||
|
no_backup: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,8 +142,13 @@ Options:
|
|||||||
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
|
||||||
--yes` is not an accident.
|
--yes` is not an accident.
|
||||||
--purge uninstall. Also delete sidecar.toml and
|
--no-backup install, update. Do not copy the files
|
||||||
uo-link.db, which are otherwise kept.
|
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.
|
-V, --version Print the installer version and exit.
|
||||||
-h, --help Print this help 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,
|
"--verify" => cli.verify = true,
|
||||||
"--yes" | "-y" => cli.assume_yes = true,
|
"--yes" | "-y" => cli.assume_yes = true,
|
||||||
"--purge" => cli.purge = true,
|
"--purge" => cli.purge = true,
|
||||||
|
"--no-backup" => cli.no_backup = true,
|
||||||
"--patches" => cli.patches = PatchChoice::Yes,
|
"--patches" => cli.patches = PatchChoice::Yes,
|
||||||
"--no-patches" => cli.patches = PatchChoice::No,
|
"--no-patches" => cli.patches = PatchChoice::No,
|
||||||
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
|
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
|
||||||
|
|||||||
@@ -158,6 +158,9 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
// ── The bundle ───────────────────────────────────────────────────────────
|
// ── The bundle ───────────────────────────────────────────────────────────
|
||||||
rows.push(bundle_row(&record));
|
rows.push(bundle_row(&record));
|
||||||
|
|
||||||
|
// ── Backups ──────────────────────────────────────────────────────────────
|
||||||
|
rows.push(backup_row(&layout));
|
||||||
|
|
||||||
// ── Report ───────────────────────────────────────────────────────────────
|
// ── Report ───────────────────────────────────────────────────────────────
|
||||||
println!();
|
println!();
|
||||||
for row in &rows {
|
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
|
/// 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
|
/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it
|
||||||
/// would report a working deployment as broken.
|
/// 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 {
|
fn bundle_row(record: &InstallRecord) -> Row {
|
||||||
let url = bundle::url_for(None);
|
let url = bundle::url_for(None);
|
||||||
let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) {
|
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::servuo::ServUoRoot;
|
||||||
use crate::util::TempDir;
|
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.
|
/// 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 planned = overlay::plan(&unpacked, &root.path, prior_files)?;
|
||||||
let summary = overlay::summarize(&planned);
|
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");
|
ui::heading("Overlay sync");
|
||||||
let lines = overlay::render(&planned);
|
let lines = overlay::render(&planned);
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
@@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
|
|||||||
summary.add, summary.change, summary.unchanged, summary.kept
|
summary.add, summary.change, summary.unchanged, summary.kept
|
||||||
);
|
);
|
||||||
} else {
|
} 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)?;
|
overlay::apply(&planned)?;
|
||||||
// "deployed" is claimed only when something actually moved. A run that copied nothing
|
// "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.
|
// 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()
|
.as_ref()
|
||||||
.map(|p| p.patch_records())
|
.map(|p| p.patch_records())
|
||||||
.unwrap_or_default(),
|
.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 ──────────────────────────────────────────
|
// ── The sidecar and its service ──────────────────────────────────────────
|
||||||
let sidecar = install_sidecar(
|
let sidecar = install_sidecar(
|
||||||
cli,
|
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 ────────────────────────────────────────────────────────
|
// ── Closing notes ────────────────────────────────────────────────────────
|
||||||
println!();
|
println!();
|
||||||
if cli.verify {
|
if cli.verify {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
|
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
|
||||||
//! name. Nothing an operator sees changes.
|
//! name. Nothing an operator sees changes.
|
||||||
|
|
||||||
|
pub mod backup;
|
||||||
pub mod bundle;
|
pub mod bundle;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
|||||||
10
src/paths.rs
10
src/paths.rs
@@ -68,6 +68,16 @@ impl Layout {
|
|||||||
self.patches_dir().join("originals")
|
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
|
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
|
||||||
/// layout, where no service is registered at all.
|
/// layout, where no service is registered at all.
|
||||||
pub fn systemd_unit(&self) -> PathBuf {
|
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";
|
pub const SERVICE_USER: &str = "runicgateway";
|
||||||
/// What both platforms show a human.
|
/// What both platforms show a human.
|
||||||
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
|
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.
|
/// Which service manager this host has — or why it has none this installer can drive.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[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())
|
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)]
|
#[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);
|
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.
|
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
|
||||||
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
|
||||||
if !start.status.success() && start.status.code() != Some(1056) {
|
if !start.status.success() && start.status.code() != Some(1056) {
|
||||||
anyhow::bail!(
|
anyhow::bail!(windows_start_failure(
|
||||||
"`{}` 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]),
|
|
||||||
start.status.code().unwrap_or(-1),
|
start.status.code().unwrap_or(-1),
|
||||||
config.display()
|
binary,
|
||||||
);
|
config
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Outcome::Registered {
|
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]
|
#[test]
|
||||||
fn the_manual_steps_are_a_complete_recipe() {
|
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
|
// 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>,
|
declared: Option<&Tier>,
|
||||||
layout: &paths::Layout,
|
layout: &paths::Layout,
|
||||||
prior: &[FeatureRecord],
|
prior: &[FeatureRecord],
|
||||||
|
backup: &mut crate::backup::Session,
|
||||||
) -> Result<Outcome> {
|
) -> Result<Outcome> {
|
||||||
let declared_tier = Tier::resolve(declared);
|
let declared_tier = Tier::resolve(declared);
|
||||||
if declared_tier.features.is_empty() {
|
if declared_tier.features.is_empty() {
|
||||||
@@ -131,7 +132,7 @@ pub fn run(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
announce_new_features(&declared_tier, &tier);
|
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)? {
|
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.
|
/// The subset of a release's tier that a previous run actually applied.
|
||||||
@@ -322,6 +323,7 @@ fn apply_tier(
|
|||||||
layout: &paths::Layout,
|
layout: &paths::Layout,
|
||||||
prior: &[FeatureRecord],
|
prior: &[FeatureRecord],
|
||||||
supported: bool,
|
supported: bool,
|
||||||
|
backup: &mut crate::backup::Session,
|
||||||
) -> Result<Outcome> {
|
) -> Result<Outcome> {
|
||||||
let previous = patch::index_records(prior);
|
let previous = patch::index_records(prior);
|
||||||
let mut records: Vec<FeatureRecord> = Vec::new();
|
let mut records: Vec<FeatureRecord> = Vec::new();
|
||||||
@@ -353,7 +355,7 @@ fn apply_tier(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !cli.verify {
|
if !cli.verify {
|
||||||
write_feature(root, unpacked, feature, &resolved, layout)?;
|
write_feature(root, unpacked, feature, &resolved, layout, backup)?;
|
||||||
}
|
}
|
||||||
applied_patches += resolved.len();
|
applied_patches += resolved.len();
|
||||||
|
|
||||||
@@ -521,9 +523,18 @@ fn write_feature(
|
|||||||
feature: &Feature,
|
feature: &Feature,
|
||||||
resolved: &[Resolved],
|
resolved: &[Resolved],
|
||||||
layout: &paths::Layout,
|
layout: &paths::Layout,
|
||||||
|
backup: &mut crate::backup::Session,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
for r in resolved {
|
for r in resolved {
|
||||||
if let Resolution::Applicable { edits, .. } = &r.resolution {
|
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);
|
let original = patch::join(&layout.patch_originals_dir(), &r.target);
|
||||||
if !original.exists() {
|
if !original.exists() {
|
||||||
write_atomic(&original, &r.content)
|
write_atomic(&original, &r.content)
|
||||||
@@ -542,6 +553,9 @@ fn write_feature(
|
|||||||
for companion in &feature.companions {
|
for companion in &feature.companions {
|
||||||
let src = patch::join(unpacked, &companion.file);
|
let src = patch::join(unpacked, &companion.file);
|
||||||
let dst = patch::join(&root.path, &companion.install_to);
|
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() {
|
if let Some(parent) = dst.parent() {
|
||||||
std::fs::create_dir_all(parent)
|
std::fs::create_dir_all(parent)
|
||||||
.with_context(|| format!("cannot create {}", parent.display()))?;
|
.with_context(|| format!("cannot create {}", parent.display()))?;
|
||||||
|
|||||||
@@ -121,12 +121,25 @@ pub fn run(cli: &Cli) -> Result<i32> {
|
|||||||
|
|
||||||
if cli.purge {
|
if cli.purge {
|
||||||
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
|
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
|
||||||
} else if layout.patches_dir().exists() {
|
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
|
||||||
|
} else {
|
||||||
|
if layout.patches_dir().exists() {
|
||||||
done.push(format!(
|
done.push(format!(
|
||||||
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
|
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
|
||||||
layout.patches_dir().display()
|
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);
|
remove_file(&record_path, &mut done, &mut problems);
|
||||||
|
|
||||||
for line in &done {
|
for line in &done {
|
||||||
@@ -181,6 +194,7 @@ fn print_intent(
|
|||||||
println!(" · {}", layout.install_record().display());
|
println!(" · {}", layout.install_record().display());
|
||||||
if purge {
|
if purge {
|
||||||
println!(" · {} [--purge]", layout.patches_dir().display());
|
println!(" · {} [--purge]", layout.patches_dir().display());
|
||||||
|
println!(" · {} [--purge]", layout.backups_dir().display());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
@@ -199,6 +213,14 @@ fn print_intent(
|
|||||||
" · {} (cached patches and pre-patch originals)",
|
" · {} (cached patches and pre-patch originals)",
|
||||||
layout.patches_dir().display()
|
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;
|
let _ = record;
|
||||||
println!();
|
println!();
|
||||||
@@ -241,6 +263,7 @@ fn render_report(
|
|||||||
|
|
||||||
render_overlay_section(&mut out, record);
|
render_overlay_section(&mut out, record);
|
||||||
render_patch_section(&mut out, record, layout, purge);
|
render_patch_section(&mut out, record, layout, purge);
|
||||||
|
render_backup_section(&mut out, layout, purge);
|
||||||
|
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
out,
|
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.
|
/// 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> {
|
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
|
||||||
let path = layout.patches_dir().join(format!("{name}.patch"));
|
let path = layout.patches_dir().join(format!("{name}.patch"));
|
||||||
|
|||||||
Reference in New Issue
Block a user