50 Commits

Author SHA1 Message Date
3a3676a22d Merge pull request 'fix(rust): drop the stray spaces in the running-server handoff line' (#34) from fix/rust-handoff-spaces into main
All checks were successful
Release installer / release (push) Successful in 4m57s
sync-project-tree / sync (push) Successful in -1m31s
Compose bundle / compose (push) Successful in -1m29s
Reviewed-on: #34
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 15:41:23 +00:00
9d73367e63 test(rust): pin the running-server handoff line whole
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m0s
The existing assertions checked fragments ("doctor --game rust
--server-id alpha"), which the stray-space version also contained. The
whole first line is now compared, so a wrapped source line cannot put
its indent back into the sentence. Fails against the pre-fix literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 10:14:22 -05:00
d61f46ffb6 fix(rust): drop the stray spaces in the running-server handoff line
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 57s
The D156 line printed "confirms it                  connected." — a
wrapped source line left a run of spaces inside the string literal.
Found by the phase 18 step 7 re-walk on Debian 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 10:09:33 -05:00
dc3d360cf2 Merge pull request 'fix(release): static musl Linux binaries, so the installer runs on Debian 12 (D158)' (#33) from ci/static-musl-installer into main
All checks were successful
sync-project-tree / sync (push) Successful in 19s
Release installer / release (push) Successful in 7m50s
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 14:53:13 +00:00
9f43260ee7 fix(release): static musl Linux binaries, so the installer runs on Debian 12 (D158)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m1s
The Linux installers were linked against the release runner's glibc and
needed glibc 2.39: v0.3.0 (and v0.2.0) failed on the first line on
Debian 12 (2.36) and Ubuntu 22.04 (2.35) with "GLIBC_2.39 not found" --
found by the Rust phase 18 walk, step 7. Both Linux targets are now
x86_64/aarch64-unknown-linux-musl, built with cargo-zigbuild (zig is the
one C compiler `ring` needs for both musl targets; Ubuntu packages a
musl gcc for x86_64 only), and the package step refuses a Linux binary
that `file` does not call statically linked. Windows is unchanged.

Tried in rust:1-slim-bookworm: both targets build, both are "statically
linked", and the x86_64 binary runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 04:47:07 -05:00
0710a3e33e Merge pull request 'feat(rust): cut edge over to main — --game rust, named instances, schema-2 bundles (rust phase 18)' (#32) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 15s
Release installer / release (push) Successful in 4m55s
Reviewed-on: #32
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 09:15:43 +00:00
0f19fd6413 Merge pull request 'fix(service): start the Windows service after granting it its config (D157)' (#31) from fix/windows-start-after-grant into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m2s
Reviewed-on: #31
2026-09-26 09:00:29 +00:00
70eb073826 fix(service): start the Windows service after granting it its config (D157)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m8s
`sc start` ran inside registration, before `grant_service_access` let the
new virtual account read `sidecar.toml` — which `protect_config` had
locked to SYSTEM and Administrators. The first start died on "Access is
denied (os error 5)" and the service sat STOPPED: the SCM's
restart-on-failure policy never fires for a clean exit with an error
code. A later manual start worked, because by then the grant existed.

Found on the phase 18 walk (step 4, one Rust instance under the SCM),
from the sidecar's own log. The code is shared, so the released ServUO
installer has the same first-start failure; the org lead chose to fix
both here (D157).

Registration no longer starts the service; a new `start_registered`
runs after the grant and refreshes the reported state. A no-op on Linux,
where the service user exists before the config and registration starts
the unit itself.

Walked (Windows, elevated): install -> START_PENDING/running, /health on
the first probe, clean stop and restart, doctor clean, uninstall clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:54:57 -05:00
285f7df17b Merge pull request 'fix(rust): an update on a running server reloads the plugin' (#30) from fix/rust-plugin-reload into edge
Reviewed-on: #30
2026-09-26 08:46:02 +00:00
e5611a01eb fix(service): say why the service account stays, truthfully for Rust too
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
`uninstall --game rust --server-id beta` printed "left the runicgateway
account alone — this installer did not create it" on a host where this
installer had created it, for alpha. A Rust instance's service never owns
the shared account (the last instance's removal decides), so the reason
now reads "it is not this service's to remove" — true for both games.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:45:27 -05:00
397e0b92e9 fix(rust): an update on a running server reloads the plugin
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 52s
The plugin was placed with write_atomic: remove the old file, rename a
.tmp over it. Oxide and Carbon watch the plugins directory and reload on
a change; both saw the remove as a delete and UNLOADED the bridge, and
ignored the rename, so the new file was never loaded. Every `update` (or
re-install) on a running server took the bridge down until the next
boot, silently — the phase 18 walk found it on all three instances and
both frameworks, after the move to v0.1.1.

The plugin is now overwritten in place, which is what an operator's `cp`
does and what both frameworks reload on. write_atomic stays for the
record and the configs, where atomicity is the point.

`update` on the same bundle also no longer says "nothing moved" after it
has just put back a hand-edited plugin — the remedy `doctor` names.

Walked: hand-edit, then `update`, on a running Oxide and a running
Carbon server: replaced, reloaded, reconnected, doctor clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 03:42:42 -05:00
0c07b8a967 Merge pull request 'fix(rust): the handoff says what the run did, not that a link exists (D156)' (#29) from fix/rust-handoff-wording into edge
Reviewed-on: #29
2026-09-26 06:27:30 +00:00
3b2881eb9c fix(rust): the handoff says what the run did, not that a link exists (D156)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 58s
`install --game rust` ended every instance with "Rust server "alpha" is
connected to its sidecar" — printed unconditionally, before the plugin had
dialled anything, on a stopped server where it had not even loaded, and
beside "No service was registered, so nothing is listening yet". The
phase 18 walk read it as a claim and then found beta's plugin connected to
the wrong sidecar.

It now reads "Rust server "alpha" is set up." followed by either "The
plugin loads now; `doctor --game rust --server-id alpha` confirms it
connected." or "The plugin connects when the server next starts.".

The top-level --help no longer describes `install` as the uo-link sidecar
and overlay for both games, and says what `uninstall` removes for Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-26 01:21:32 -05:00
0fad3e3b2d Merge pull request 'feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)' (#28) from feat/phase-18-rust into edge
Reviewed-on: #28
2026-09-26 05:33:51 +00:00
7027a78a23 feat(rust): --game rust, named instances, and schema-2 bundles (phase 18)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 2m7s
Module-rust phase 18, step 5 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D148, D149, D153).

Bundles: ServUO is read at schema 2 from v2/servuo/ and lowered into the
schema-1 model. Schema 1 at the root is the fallback, so a pin from before
schema 2 still reproduces. Rust bundles are read from v2/rust/. v2 reads use
the contents API, because /raw/ is CDN-cached for six hours.

--game rust runs install, update, doctor and uninstall for Rust servers
(src/rustgame/):
- the framework is detected from its marker files, which were read off both
  rigs; both or neither is refused;
- --server-id names an instance: its own service (runicgateway-rust@<id>, or
  RunicGatewayRust-<id>), config, database and ports;
- the plugin config is written once, with ServerId and Port only. An existing
  one is never rewritten, and one naming another server refuses the run;
- each instance's sidecar.toml is written once with its ports and an absolute
  database path, and the sidecar generates the token into it;
- one binary per host. update moves every instance, and a replaced binary
  restarts all of them;
- doctor checks the plugin file hash, the plugin config's ServerId, the
  required uMod plugins (a warning), the service and /health, and passes when
  the plugin is connected;
- uninstall removes our plugin and keeps its config. --purge also removes the
  sidecar config and database. The last instance takes the binary, the
  template and the record, and the shared user only when no ServUO record
  remains.

service.rs takes the service name as a parameter internally. The ServUO
public API is unchanged.

Finding: Carbon 2.0.259's config.json has no folder keys, so carbon/plugins
and carbon/configs are what the installer uses. The plan expected a moved
directory to be readable there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 23:40:11 -05:00
8e4cbc6406 Merge pull request 'ci(bundle): compose schema 2 for ServUO and Rust beside schema 1 (rust phase 18, step 2)' (#27) from ci/bundle-schema-2 into main
All checks were successful
Release installer / release (push) Successful in 12s
sync-project-tree / sync (push) Successful in 21s
Compose bundle / compose (push) Successful in 15s
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-26 03:54:15 +00:00
4f268f7ffb ci(bundle): compose schema 2 for ServUO and Rust beside schema 1
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m12s
Module-rust phase 18, step 2 of docs/modules/rust/PLAN.md §34.2.7 (D146,
D147). The compose job now writes three streams on the bundles branch:

  current.json         schema 1, ServUO, exactly as before
  v2/servuo/           schema 2, game "servuo"
  v2/rust/             schema 2, game "rust" (Rust-Link + Rust-Plugins)

The gates move into .gitea/scripts/compose-bundles.sh, so they can be run by
hand against the real release API or a mock without a runner. Each game
composes independently: a Rust failure still lets a ServUO bundle publish, and
the run goes red afterwards. A game whose repos have never released composes
nothing and does not fail, which is where Rust is until its cutover.

A ServUO matrix carries the same tag at both schemas, so the first run
publishes v2/servuo/bundle-2026.09.15.json rather than a second name for the
pair. Schema 1 stops being composed on 2027-01-01 and is left frozen.

Tested in debian:12 against the real releases (v2/servuo published as
2026.09.15, schema 1 unchanged, Rust skipped; rerun is a no-op; past the
retirement date schema 1 is frozen) and against a mock Gitea for Rust
(document composed, protocol mismatch refused, missing manifest key refused,
rerun a no-op, ServUO failing independently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 22:49:47 -05:00
f865660c7c Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Asset Bridge cutover, 4 of 5)' (#26) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in -1m23s
Release installer / release (push) Successful in 5m56s
Compose bundle / compose (push) Successful in -1m32s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-09-14 23:11:53 +00:00
2285fff759 Merge pull request 'feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)' (#25) from feat/asset-bridge-p9 into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m10s
Reviewed-on: #25
2026-09-14 22:25:52 +00:00
ead105d5bf feat(doctor): the one host prerequisite the Asset Bridge added (Phase 9a)
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
`libgdiplus`, on Linux hosts only. ServUO targets net48, so on Linux it runs
under Mono, and Mono's `System.Drawing` is a thin layer over that library -- which
sits in the DECODE path, not merely the encode: `Ultima.Frame` writes ARGB1555
through a LockBits pointer, so without it the shard cannot read one sprite out of
the operator's UO client.

docs/link/v8.md §17.2 took all three answers to this rather than one -- the
prerequisite in SHARD_PREREQS.md, a named NO_IMAGING status instead of an error,
and a `doctor` check. The first two shipped in phase 1. This is the third, and
SHARD_PREREQS.md has been claiming it existed since then.

Three things about the row are deliberate:

- **Linux only, and absent elsewhere.** A Windows host ships System.Drawing with
  .NET Framework and has nothing to check, so there is no row rather than a row
  saying "not applicable" on three quarters of the hosts that run this.
- **A warning, never a failure.** Names and the shard's own spawn files have no
  pixels in them, so a host without this library still runs a useful bridge, and
  `doctor`'s exit code -- which monitoring reads -- must not go red over one
  absent feature.
- **`ldconfig -p` first, paths second.** The loader's own cache is the same
  question Mono asks at runtime, so a distro that puts the file somewhere unusual
  answers correctly; the path probe is the fallback for a slim container with no
  ldconfig, and a missing ldconfig degrades to it rather than taking doctor down.

The verdict is split from the detection so the mark and the wording are testable
on a host that has the library and one that does not; all three tests are
Linux-gated, which is where CI runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 13:07:23 -05:00
1996a32153 Merge pull request 'ci(release): sweep every tag for a missing release, not just this run's' (#24) from ci/release-orphan-tag-sweep into main
All checks were successful
Release installer / release (push) Successful in 8s
sync-project-tree / sync (push) Successful in 7s
Compose bundle / compose (push) Successful in -59s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 19:42:02 +00:00
65998692ae ci(release): sweep every tag for a missing release, not just this run's
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m28s
The first commit on this branch said the plan step "CAN recover an orphan, but
only on a run that reaches it". Checking link and servuo-plugins for the same
gaps showed that understated it.

The recovery is VERSION-SCOPED. It computes VERSION from the newest tag plus the
conventional-commit bump, then only checks refs/tags/v${VERSION}. So it recovers
an orphan on the very next run and is useless afterwards: once any releasable
commit lands, the next run computes a NEW version and never looks at the old tag
again. The orphan becomes permanent and silent.

servuo-plugins proved it, and the proof is pointed. Its v0.1.0 had been orphaned
since 2026-08-04 -- tag present, no release, no assets -- while v0.1.1, v0.2.0
and v1.0.0 all published normally. The commit that ADDED the recovery to that
repo was itself typed "fix(release): preflight credentials and recover the
orphaned v0.1.0 tag", so it bumped to v0.1.1, and the run that introduced the
recovery stepped straight past the tag it was written to rescue.

The retry added in the previous commit makes an orphan much less likely, but it
does not make one impossible -- a cancelled job or a dying runner produces the
same state with no 500 anywhere -- and until now nothing would ever have
mentioned it again.

So the plan step now sweeps every v* tag and warns about any without a release.

It WARNS rather than recovers, on the org lead's decision. Publishing an old
version would mean building today's tree and shipping it under a tag whose tree
it is not, which is worse than the inconsistency it fixes; and a routine push
silently republishing ancient history is not a thing this pipeline should be
able to do. Recovery stays limited to the version the run computed.

It also never fails the run. A sweep that can break a good release is a sweep
someone will delete.

Verified by running the loop against the real repositories rather than a stub,
since the only thing worth proving is that it tells a clean repo from a dirty
one:

  link (9 tags): clean
  servuo-plugins (4 tags): :⚠️:Tags with no release: v0.1.0
  installer (2 tags): clean

and again after servuo-plugins#15 deleted that tag, where all three report
clean. Every run block bash -n clean, the YAML parses, and no empty template
token.

Companion PRs: link#33 and servuo-plugins#15.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 13:57:59 -05:00
094da1776b Merge pull request 'ci(release): show the error body, and retry the release POST' (#23) from ci/release-post-retry-and-error-body into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -59s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-24 17:32:27 +00:00
188e6eb882 ci(release): show the error body, and retry the release POST
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m32s
Run 75 built every artifact, pushed tag v0.1.1, then took a 500 from
POST /releases one second later and exited 22. The tag was left orphaned with
no release and no binaries, so the handoff fix in #22 reached no operator until
the workflow was re-run by hand today.

Re-running published the same four assets untouched, via the orphan-tag
recovery the plan step already has. So the 500 was a race with the tag push --
Gitea had not finished processing the pushed tag when the POST arrived -- and
not a bad request.

Two separate gaps made that worse than it needed to be.

`curl -sSf` prints no response body on an error status. All the log carried was
"curl: (22) ... error: 500", so the cause had to be inferred from timestamps
rather than read. Every call in this step now captures the body and prints it
on failure, including the asset uploads.

And nothing retried. The plan step can recover an orphan tag, but only on a run
that reaches it, and a later push with no releasable commits stands down before
it gets there -- which is why this one sat until someone looked. The POST now
retries five times with a 5/10/15/20s backoff.

4xx is deliberately not retried: a bad token or a malformed body will not
improve by being sent again, and retrying would turn a clear failure into a
slow one. A give-up message names the orphan tag and says a re-run republishes.

The asset uploads get the same treatment, because a release whose SHA256SUMS
does not cover every binary it advertises is worse than no release -- that file
is the trust anchor for an unsigned download.

Verified by extracting the step's shell from the YAML and running the loop
against a stubbed curl: first-try success, 500-then-success (the case that
actually happened), two 500s then success, five 500s giving up, 403 and 404
aborting without retrying, and a 000 network failure being retried. bash -n
clean and the YAML parses.

Typed ci(...) rather than fix(...) on purpose: the plan step bumps on feat/fix,
and this changes no binary, so a release here would be an empty one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:54:47 -05:00
9cc109910c Merge pull request 'fix(handoff): print the shard screen's real path' (#22) from fix/admin-shard-path into main
Some checks failed
sync-project-tree / sync (push) Successful in 6s
Release installer / release (push) Failing after 4m3s
Reviewed-on: #22
2026-08-24 16:30:49 +00:00
6da385425e fix(handoff): print the shard screen's real path
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m39s
The end-of-run block told operators to paste the four values at
`<site>/admin/shard`. That page moved when the shard screens became part of the
`uo` module: a module owns one path segment wherever it appears (website
`MODULE_SYSTEM.md` §2.8), so it is `/admin/uo/link`, labelled "Shard (uo-link)".

The old path is worse than a 404. The SPA has no route for it, so it sends the
operator to the dashboard — the link looks like it worked, and the values they
were told to paste have nowhere to go.

- The path is now a named constant, `ADMIN_SHARD_PATH`, carrying why it is not
  the obvious string and the fact that API routes are NOT affected by the module
  namespacing rule (they keep `/api/v1/admin/shard/*`).
- Both handoff tests assert the new path, so this cannot regress quietly.
- The two user-facing labels that name the screen — the `--site-url` help text
  and `update`'s protocol-change instruction — say "Admin → Shard (uo-link)",
  matching what the sidebar actually reads.

Found while writing the runicgateway.com installation journey, by pasting the
printed link into a real deployment and landing on the dashboard.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-24 11:22:45 -05:00
5d4c68eaf5 Merge pull request 'docs(backup): correct why the sidecar database is not backed up (Teams cutover 3/6)' (#21) from edge into main
All checks were successful
Release installer / release (push) Successful in -51s
sync-project-tree / sync (push) Successful in 9s
Compose bundle / compose (push) Successful in 23s
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-19 08:56:06 +00:00
c3771d22f2 Merge pull request 'docs(backup): correct why the sidecar database is not backed up' (#20) from feat/teams-phase1-guild-roster into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m46s
Reviewed-on: #20
2026-08-17 19:28:25 +00:00
6c49217e9c docs(backup): correct why the sidecar database is not backed up
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m49s
backup.rs justified skipping the sidecar's database on two claims. Protocol 4
falsifies one and reveals the other was already wrong.

It said the database is safe because store.rs creates every table IF NOT EXISTS.
That held only while every schema change added a whole table — which, up to and
including Protocol 3.0, every one of them did. Protocol 4 adds a COLUMN to a table
that already exists, which IF NOT EXISTS cannot do, so link now carries a real
migration. A run can change the database's structure, not only its contents.

It also said every table holds state the sweeps repopulate. `events` does not: it
is never pruned, and the website backfills what it missed from GET /history on
every reconnect. So a lost database costs the gap-recovery window for whatever
happened while the site was down. That claim was untrue before this workstream
existed.

The behaviour does not change — the database is still not copied — because the
argument against backing up unbounded bulk survives both corrections: `events`
grows without limit, the migration is transactional and additive, and the website
holds its own durable copy of everything already ingested. Only the reasoning was
wrong, and a wrong reason left in place is what lets the next person extend it to
a case it never covered.

Whether that unbounded table should be pruned or protected belongs to link, on its
own merits, rather than being settled inside a backup policy.

No logic change; docs only.

Refs: docs/website/TEAMS.md Part 12 Phase 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 12:58:24 -05:00
7758724eb5 Merge pull request 'docs(readme): describe a released installer, not an unreleased one' (#19) from docs/installer-first-setup into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in -35s
Compose bundle / compose (push) Successful in 20s
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 21:30:39 +00:00
484f00ddee docs(readme): describe a released installer, not an unreleased one
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m17s
The status section still said "Phases 1 to 4 are built, on edge. Nothing
is released yet" and told visitors the way to install was by hand — both
untrue as of v0.1.0.

- Add an "Install a shard with it" section up top: verify, run, paste the
  four values. That is what a visitor to this repo is here for.
- Status: released, all five phases, both cutover gates recorded.
- Fix a broken link: bundles/current.json is no longer on main, it lives
  on the bundles branch.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:05:56 -05:00
f81cbcdd04 Merge pull request 'fix(release): actually build the arm64 binary it packages' (#18) from fix/release-arm64-build into main
All checks were successful
sync-project-tree / sync (push) Successful in 7s
Release installer / release (push) Successful in 3m39s
Reviewed-on: #18
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 20:35:53 +00:00
007791c4fc fix(release): actually build the arm64 binary it packages
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m56s
The first release attempt failed at packaging:

  cp: cannot stat 'target/aarch64-unknown-linux-gnu/release/runicgateway-installer':
      No such file or directory

installer#10 added linux-aarch64 in three of the four places it belongs — the
rustup target, the `cp` into dist/, and the SHA256SUMS line — but never added a
build step for it. Nothing ever produced the binary, so the run got all the way
to packaging before noticing. No tag or release was created, so a retry is clean.

Two changes:

- Build arm64, with the same linker/CC/AR env pattern the Windows cross build
  already uses.
- Name `libc6-dev-arm64-cross` in the apt install. gcc-aarch64-linux-gnu only
  *recommends* it and this step runs --no-install-recommends, so without it the
  Rust half builds and then `ring` (under ureq's rustls) dies compiling C on a
  missing bits/libc-header-start.h.

Verified by reproducing CI in rust:1-slim-bookworm — the same apt line including
--no-install-recommends, then the same cargo invocation. Builds clean and emits
a 4.6 MB binary at exactly the path the packaging step reads.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:30:55 -05:00
1173a10049 Merge pull request 'Cutover: promote the installer from edge to main' (#17) from edge into main
Some checks failed
Release installer / release (push) Failing after 3m21s
sync-project-tree / sync (push) Successful in -22s
Reviewed-on: #17
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 20:05:42 +00:00
84c1106d58 Merge branch 'main' into edge
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
Brings main's publishing fixes onto edge so the cutover PR is a clean merge:
bundle.yml publishing to the `bundles` branch, release.yml going tag-only, and
the removal of bundles/*.json from main.

One conflict, resolved in favour of edge: main deleted bundles/bundle-2026.08.04.json
while edge had renamed it to tests/fixtures/published-bundle.json. Both changes say
the same thing — published bundles no longer live on main — so the fixture is kept.
It stays frozen at 2026.08.04 on purpose: it is the crate's test input, not a mirror
of what is currently published.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:44:12 -05:00
d6f0bcf2cf Merge pull request 'fix(release): tag only, before the cutover fires this for the first time' (#14) from fix/release-tag-only-installer into main
All checks were successful
Release installer / release (push) Successful in 6s
sync-project-tree / sync (push) Successful in -34s
Reviewed-on: #14
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 19:16:47 +00:00
065edab8cd Merge branch 'main' into fix/release-tag-only-installer
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 4s
2026-08-07 19:16:32 +00:00
8e5258358c Merge pull request 'feat(installer): read bundles from the branch they are published to' (#15) from feat/bundle-branch-url into edge
Reviewed-on: #15
2026-08-07 19:16:01 +00:00
09eafe2911 Merge pull request 'fix(bundle): publish to a bundles branch, and unbreak the stale check' (#13) from ci/bundle-require-aarch64 into main
All checks were successful
Release installer / release (push) Successful in 5s
sync-project-tree / sync (push) Successful in -14s
Reviewed-on: #13
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-07 19:12:36 +00:00
7771e2e68d Merge pull request 'fix(service): diagnose 1053 as a handshake, not a bad config' (#16) from fix/service-start-diagnosis into edge
Reviewed-on: #16
2026-08-07 18:52:53 +00:00
b7d1bbbc78 fix(service): diagnose 1053 as a handshake, not a bad config
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m2s
Every failed `sc.exe start` was reported with "a service that exits immediately
usually cannot read its config", which for the one error code that actually
occurs is the wrong place to look. 1053 is the SCM giving up after 30 seconds
waiting for the process to identify itself; the process started fine and is
very likely serving traffic. A reader who follows the old sentence goes and
stares at a config file that is correct.

Replace it with windows_start_failure(), which names the real cause per code:

- 1053: a handshake failure, almost always a sidecar older than v1.2.0 (the
  first release that speaks the SCM protocol). Says how to check the version,
  and how to prove the binary is healthy by running it in the foreground.
- 1069: the virtual service account was refused, which is local policy rather
  than a bad credential, and points at INSTALL.md Appendix A4.
- anything else: does not guess, and hands over the event log, `sc query` for
  the service's own exit code, and the foreground command.

Pure and tested on both platforms, like windows_bin_path above it, so the text
is covered on the Linux CI runner that never sees an SCM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 13:36:05 -05:00
2787eaadff feat(installer): read bundles from the branch they are published to
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m58s
`main` is protected, so the compose job cannot push a bundle there --
the pre-receive hook declines it, which is not something a nightly cron
can resolve. Bundles now publish to a branch of their own, at its root,
so BUNDLE_BASE follows them. Everything the original choice was for
survives the move: a reviewable diff, a git history of the compat
matrix, and a plain anonymous URL that needs no credentials on the
shard host.

The test's bundle is now a frozen fixture rather than an include of the
published file, which this checkout no longer carries. Frozen is the
honest shape anyway: a test that silently re-targeted whatever CI
published last would change meaning without a commit. It is still a
real CI-emitted document, copied verbatim.

Nothing is released from `edge`, so no shipped binary ever read the old
URL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:20:18 -05:00
ea7e491ba3 fix(release): tag only, before the cutover fires this for the first time
All checks were successful
PR Checks / rust-gates (pull_request) Successful in -35s
The same two faults link/release.yml has, in the copy this repo was
forked from -- and this one has never run at all, so the cutover would
have been its first execution.

An empty template expression written literally in a comment makes the
runner fail to build the "Commit version bump and push tag" step and
skip it WITHOUT failing the job. link carried that for six releases,
which is why its Cargo.toml still says 0.1.0 while its tags reach
v1.1.1; the tags exist because the release API creates one when it
publishes.

And the step pushes to main, which is protected -- the bundle job
proved that today with `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 prerequisites header said `main` must accept a direct push from the
CI user. It does not, and it should not; that line is replaced with the
reason.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:18:42 -05:00
7db58031c7 fix(bundle): publish to a bundles branch, and unbreak the stale check
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 6s
Three things, all found by the first compose run that ever had a bundle
to write.

1. `main` is protected, so the push was declined by the pre-receive
   hook -- twice, since the retry rebases and pushes to the same place.
   Every bundle since v1.1.1 has been composed correctly and thrown
   away. Bundles now go to a `bundles` branch of their own, at its
   root, which needs no protection exception and 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. The header's claim that this push "needs no new
   branch-protection exception" was simply false.

2. A `${{ }}` written literally in a shell comment silently disabled
   the entire stale-component check. The runner scans a step's script
   for template expressions before running it, fails to parse the empty
   one, and skips the step WITHOUT failing the job -- so the dispatch
   that is supposed to fire a component's release workflow has never
   run once. Reworded, with a warning not to write that token in a
   comment again. (link/release.yml and this repo's release.yml carry
   the same bug in their bump-and-tag step; handled separately.)

3. linux-aarch64 is now a REQUIRED platform key, which was step 3 of
   PLAN.md §5.2 and was waiting on link publishing one. v1.1.1 does, so
   from here a dropped target reddens this job instead of vanishing
   from every bundle.

The published bundles are materialized into a worktree at `published/`,
so the ".2 suffix" scan and the idempotence check read what is actually
published rather than a stale copy on main. The branch is created from
an empty-tree root commit on first use, so it carries no history that
has nothing to do with the compat matrix; it has been seeded already
with bundle 2026.08.04, because every bundle is kept forever and the
move must not lose the one that exists.

bundles/*.json is deleted from main -- it is now a stale copy of data
that lives elsewhere, and a wrong "current" is worse than none. The
README stays and documents the branch.

Verified by running the whole job in a container against a bare repo
standing in for the remote: first run creates the branch and publishes
both files with all three asset keys, second and third runs report
"identical to the published current.json -- nothing to publish" and
push nothing, and the stale check now runs and reports both components
as having nothing releasable.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 17:14:57 -05:00
07a2cca5f6 Merge pull request 'feat(installer): back up what a run is about to overwrite' (#11) from feat/backup-before-overwrite into edge
Reviewed-on: #11
2026-08-05 17:53:43 +00:00
10bc2224a6 Merge pull request 'feat(installer): build for and install on linux-aarch64' (#10) from ci/installer-linux-aarch64 into edge
Reviewed-on: #10
2026-08-05 17:53:00 +00:00
ae53546446 Merge pull request 'ci(bundle): recognize a linux-aarch64 link asset' (#9) from ci/bundle-aarch64-key into main
Some checks failed
sync-project-tree / sync (push) Successful in 6s
Release installer / release (push) Successful in -32s
Compose bundle / compose (push) Failing after -15s
Reviewed-on: #9
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-05 17:51:06 +00:00
82900da939 feat(installer): back up what a run is about to overwrite
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 1m59s
PLAN.md §5.3. Before anything is written, every file this run will
replace is copied into <state>/backups/<utc-stamp>/ with a manifest
naming where each came from. --no-backup opts out; --verify takes none.

Scoped by what cannot be fetched again. The sidecar binary and the
overlay files are re-downloadable and hash-named in the bundle, and the
database is a cache with a schema -- link's store.rs creates every table
IF NOT EXISTS over shard state the sweeps repopulate. What a run can
destroy for good is an operator's edits to a deployed .cs file, which
Phase 1 overwrites unconditionally and by design, and sidecar.toml,
whose token the website already holds.

Two deviations from §5.3 as written, both found by building it:

- The trigger is "this run is about to overwrite something", not "an
  update, or an install over an existing record". §5.3 justified the
  latter with "a first install overwrites nothing" -- which is not true
  of a tree deployed by hand per INSTALL.md Appendix A2, a documented
  path. There the first install finds .cs files that differ, plans them
  as Change, and overwrites them with no record anywhere. The direct
  test covers that case and still writes nothing for a genuine first
  install, because there is nothing to copy.
- sidecar.toml joins a backup that is already being taken and is never
  the reason for one. Nothing here rewrites it, so making it a trigger
  would put a dated directory on disk after every no-op update; it is
  copied so a restored set of files comes with the token that matches
  them.

The directory is created lazily and the manifest is written last, so a
directory carrying one is a complete backup -- and pruning only
considers those, so a run interrupted mid-copy cannot evict a good
backup by being newer than it. Three are kept. uninstall keeps them and
names them in its report; --purge removes them, alongside the config,
the database and the cached patch set. doctor reports the newest.

Restoring stays printed rather than done, as the uninstall report is:
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.

Verified live against two scratch ServUO trees built from the real 57.4
files: a clean first install leaving no backups directory at all, an
update after editing a deployed .cs (copy holds the edit, tree gets the
release's file, manifest lists both it and sidecar.toml), a no-op update
taking none, --no-backup and --verify each taking none, a fourth backup
pruning the oldest, doctor's row, uninstall keeping three and listing
them, --purge removing them, and a --patches run capturing the
pre-patch Logging.cs while the two rung-0 patches correctly captured
nothing. fmt, clippy -D warnings and 144 tests on both Linux and
Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:47:25 -05:00
6941925fa5 feat(installer): build for and install on linux-aarch64
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 54s
Step 4 of PLAN.md §5.2, and the half that faces the operator: the
release now cross-compiles aarch64-unknown-linux-gnu, and platform_key()
resolves ("linux","aarch64") to the bundle key link publishes under
instead of refusing the host by name.

Same toolchain shape as the Windows step -- a linker plus a CC/AR pair,
because ring (under ureq's rustls) compiles C and assembly. And the same
packaging trap named in the sums comment: an artifact missing from
SHA256SUMS is one `sha256sum -c` passes over silently, so the new binary
is added to both the sums and the upload list.

Two test changes fall out of the asset map growing a key:

- The exact `assets.len() == 2` assertion is replaced by a check that
  each key CI requires is present and well-formed. An exact count would
  fail on the first bundle that adds arm64 -- reporting correct
  behaviour as a regression.
- The host-binary lookup now accepts either outcome, and says why.
  Bundles are kept unchanged forever so `--bundle` stays reproducible,
  which means one published before arm64 existed can never gain that
  key. On such a host the run must fail with the reason rather than
  something that reads like a corrupt document, so sidecar_asset()'s
  error now says so and the test asserts it.

Verified by cross-building this crate for aarch64 in a
rust:1-slim-bookworm container -- ELF 64-bit LSB pie executable, ARM
aarch64 -- and by running fmt, clippy -D warnings and the tests on both
Linux and the Windows host, since only half of service.rs compiles on
either.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:34:08 -05:00
fd59a74912 ci(bundle): recognize a linux-aarch64 link asset
All checks were successful
PR Checks / rust-gates (pull_request) Successful in 5s
Step 1 of PLAN.md §5.2's four, and it has to be first. Two rules in this
job are strict in opposite directions: an unrecognized link asset name
fails the run, and a missing REQUIRED platform key fails it too. So the
name must be taught before the release that carries it, and the key can
only be required after one exists -- requiring it first would fail every
bundle for as long as the gap lasts.

This is therefore the mapping only. linux-aarch64 is not in REQUIRED
yet; step 3 promotes it once a link release actually ships the binary,
after which a dropped target reddens CI instead of vanishing silently
from every bundle.

The compose step needed no change: it builds the asset map from the
platform TSV, so a third key costs it nothing.

Edited on `main` and deliberately not on `edge`. The compose job runs
from `main`, and leaving `edge`'s copy untouched means the eventual
cutover merge has nothing to conflict over.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 05:22:59 -05:00
33 changed files with 5399 additions and 585 deletions

437
.gitea/scripts/compose-bundles.sh Executable file
View File

@@ -0,0 +1,437 @@
#!/usr/bin/env bash
# Compose every bundle stream from the latest component releases.
#
# Called by .gitea/workflows/bundle.yml, which owns checkout, publishing and the
# stale-component check; this script owns the gates and the documents. It lives
# in a file rather than inline so it can be run by hand against the real release
# API — which is how it is tested — without a runner.
#
# ── The streams (docs/modules/rust/PLAN.md §34.2.2, D146/D147) ───────────────
#
# current.json, bundle-<tag>.json schema 1, ServUO (until SCHEMA1_RETIRES)
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust"
#
# A schema-2 document names ONE game. The two games release on their own
# schedules, and a document naming both would hand a ServUO host a new bundle
# every time a Rust plugin shipped.
#
# Schema 1 is still composed because every installer already in the field reads
# only `current.json` and refuses any schema but 1. After SCHEMA1_RETIRES it is
# left FROZEN at its last bundle rather than deleted, so an old installer still
# resolves something and every bundle-<tag>.json stays pinnable.
#
# ── One matrix, one tag ──────────────────────────────────────────────────────
# A ServUO matrix published at both schemas carries the SAME tag in both, so a
# `--bundle <tag>` an operator wrote down means one pair whichever installer
# reads it. The first run after schema 2 lands is the case this is for: the
# ServUO matrix has not changed, so v2/servuo takes the tag schema 1 already
# gave it rather than inventing a second name for the same pair.
#
# ── Failure is per game ──────────────────────────────────────────────────────
# A Rust release with a protocol mismatch must not stop a ServUO bundle from
# publishing, and the reverse. Each game composes in its own subshell; what
# succeeded is written, and the script exits 1 at the end if anything failed, so
# the run is still red.
#
# A game none of whose components has released yet composes nothing, and that is
# NOT a failure — it is the state Rust is in until its first cutover.
#
# ── Interface ────────────────────────────────────────────────────────────────
# PUBLISHED the bundles-branch worktree (read, and written on change)
# WORK scratch directory
# GITEA_HOST default gitea.whitlocktech.com (GITEA_BASE overrides the whole URL)
# TODAY YYYY-MM-DD, default today UTC (tests override it)
# SCHEMA1_RETIRES YYYY-MM-DD, default 2027-01-01 (PLAN.md §34.4)
#
# Results, for the workflow:
# $WORK/result.env changed=true|false, commit_subject=…
# $WORK/summary.md the job summary's body
# $WORK/stale.tsv <repo slug>\t<released tag>, one per resolved component
set -euo pipefail
: "${PUBLISHED:?PUBLISHED must name the bundles-branch worktree}"
: "${WORK:?WORK must name a scratch directory}"
GITEA_HOST="${GITEA_HOST:-gitea.whitlocktech.com}"
# Overridable so the script can be pointed at a mock Gitea (how the Rust path was
# tested before either Rust repository had released).
GITEA_BASE="${GITEA_BASE:-https://${GITEA_HOST}}"
TODAY="${TODAY:-$(date -u +%Y-%m-%d)}"
SCHEMA1_RETIRES="${SCHEMA1_RETIRES:-2027-01-01}"
SERVUO_LINK_REPO="RunicGateway/link"
SERVUO_OVERLAY_REPO="RunicGateway/servuo-plugins"
RUST_LINK_REPO="RunicGateway/Rust-Link"
RUST_PLUGIN_REPO="RunicGateway/Rust-Plugins"
# Fixed top-level directories inside each payload tarball. Deliberately NOT
# versioned — a versioned prefix would mean parsing the version out of a path in
# order to read the manifest that declares the version.
SERVUO_OVERLAY_PREFIX="runicgateway-overlay"
RUST_PLUGIN_PREFIX="runicgateway-rust-plugin"
mkdir -p "$WORK" "$PUBLISHED"
: > "$WORK/summary.md"
: > "$WORK/stale.tsv"
: > "$WORK/published.txt"
fail() { echo "::error::$*" >&2; exit 1; }
note() { printf -- '%s\n' "$*" >> "$WORK/summary.md"; }
# ── Release resolution ───────────────────────────────────────────────────────
# Read ANONYMOUSLY, on purpose: these are exactly the requests the shipped
# installer and the egg make, from a host with no Gitea credentials. A repo
# flipped to private fails CI here instead of on an operator's machine.
#
# Returns 0 with $WORK/<key>-release.json written, or 2 when the repo has never
# released (Gitea answers /releases/latest with 404). Anything else is fatal.
resolve_latest() {
local slug="$1" key="$2" code tag
code="$(curl -sS -o "$WORK/${key}-release.json" -w '%{http_code}' \
"${GITEA_BASE}/api/v1/repos/${slug}/releases/latest")" || fail "could not reach ${slug}'s releases"
case "$code" in
200) ;;
404) return 2 ;;
*) fail "${slug} /releases/latest answered HTTP ${code}" ;;
esac
tag="$(jq -r '.tag_name' "$WORK/${key}-release.json")"
[ -n "$tag" ] && [ "$tag" != "null" ] || fail "${slug}'s latest release has no tag"
printf '%s\t%s\n' "$slug" "$tag" >> "$WORK/stale.tsv"
echo "==> ${slug} latest: ${tag}"
}
release_tag() { jq -r '.tag_name' "$WORK/$1-release.json"; }
# ── GATE 2 (installer PLAN.md §7.1): assets exist, checksums match ───────────
# Every asset a bundle will reference is downloaded and verified against the
# SHA256SUMS published beside it. SHA256SUMS is the trust anchor for these
# deliberately UNSIGNED artifacts, and the installer and the egg verify against
# the hashes THIS job records; a hash copied from a file nobody checked would
# make the chain decorative.
#
# `sha256sum -c` catches a SHA256SUMS entry with no asset. The reverse — an asset
# with no entry — is checked by name, because -c would pass right over it. The
# `\*?` matches sha256sum's binary-mode marker.
verify_assets() {
local key="$1" dir="$WORK/$1" sums_url name url
rm -rf "$dir"; mkdir -p "$dir"
sums_url="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "$WORK/${key}-release.json")"
[ -n "$sums_url" ] && [ "$sums_url" != "null" ] \
|| fail "${key} release has no SHA256SUMS asset — nothing to verify against"
curl -sSfL -o "${dir}/SHA256SUMS" "$sums_url"
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
"$WORK/${key}-release.json" > "${dir}/asset-list.tsv"
[ -s "${dir}/asset-list.tsv" ] || fail "${key} release carries no assets besides SHA256SUMS"
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
echo " fetching ${key}/${name}"
curl -sSfL -o "${dir}/${name}" "$url"
grep -qE "[ \t]\*?${name}\$" "${dir}/SHA256SUMS" \
|| fail "${key} asset ${name} has no entry in that release's SHA256SUMS"
done < "${dir}/asset-list.tsv"
( cd "$dir" && sha256sum -c SHA256SUMS ) \
|| fail "${key} assets do not match the SHA256SUMS published with them"
echo "==> ${key}: all assets present and verified"
}
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
# One asset as a bundle `{name,url,sha256}` object.
asset_json() {
local key="$1" name="$2" url
url="$(awk -F'\t' -v n="$name" '$1 == n { print $2 }' "$WORK/${key}/asset-list.tsv")"
jq -n --arg name "$name" --arg url "$url" --arg sha "$(sha_of "$WORK/${key}/${name}")" \
'{ name: $name, url: $url, sha256: $sha }'
}
# The single tarball a payload release carries, by name. Exactly one: a second
# .tar.gz would leave the installer guessing which one it deploys.
single_tarball() {
local key="$1" count
count="$(awk -F'\t' '$1 ~ /\.tar\.gz$/' "$WORK/${key}/asset-list.tsv" | wc -l)"
[ "$count" -eq 1 ] || fail "expected exactly 1 .tar.gz in the ${key} release, found ${count}"
awk -F'\t' '$1 ~ /\.tar\.gz$/ { printf "%s", $1 }' "$WORK/${key}/asset-list.tsv"
}
# ── GATE 1 (installer PLAN.md §7.1): both halves speak one protocol ──────────
# The sidecar side is PROTOCOL_VERSION in sidecar/src/main.rs, read at the
# RELEASE TAG — not from the binary, which would mean executing a downloaded
# artifact (and, for older releases, one with no way to answer). Both link and
# Rust-Link keep the constant at that path.
sidecar_protocol() {
local slug="$1" tag="$2" key="$3" p
curl -sSfL -o "$WORK/${key}-main.rs" \
"${GITEA_BASE}/${slug}/raw/tag/${tag}/sidecar/src/main.rs"
p="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' "$WORK/${key}-main.rs" | head -1)"
# Empty means the constant moved. "Could not read" must never read as "matches".
[ -n "$p" ] || fail "could not read PROTOCOL_VERSION from ${slug}@${tag}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
printf '%s' "$p"
}
# Unpack a payload tarball and return the path of its manifest.json, having
# checked the manifest names the version it was released under.
payload_manifest() {
local key="$1" tarball="$2" prefix="$3" tag="$4" m v
rm -rf "$WORK/${key}-x"; mkdir -p "$WORK/${key}-x"
tar -xzf "$WORK/${key}/${tarball}" -C "$WORK/${key}-x"
m="$WORK/${key}-x/${prefix}/manifest.json"
[ -f "$m" ] || fail "the ${key} tarball has no ${prefix}/manifest.json — the installer and the egg resolve it at that exact path"
v="$(jq -r '.version' "$m")"
# A manifest that disagrees with its tag means the release stamped one version
# and tagged another; every record of "what is installed" would then be wrong.
[ "$v" = "${tag#v}" ] || fail "${key} manifest says version ${v} but the release is tagged ${tag}"
printf '%s' "$m"
}
# ── Publishing helpers ───────────────────────────────────────────────────────
# `bundle` and `generated` describe the RUN, not the matrix. Comparing them would
# make every nightly cron look like a change and commit a dated duplicate of the
# same matrix forever, so only what an installer acts on is compared.
same_content() {
local published="$1" content="$2"
[ -f "$published" ] || return 1
cmp -s <(jq -S 'del(.bundle, .generated)' "$published") <(jq -S '.' "$content")
}
# A fresh date tag that names no bundle in any of the given directories. Two
# bundles on one day get .2, .3, … so a tag always names exactly one matrix.
fresh_tag() {
local base tag n=1 d taken
base="$(date -u -d "$TODAY" +%Y.%m.%d)"
tag="$base"
while :; do
taken=false
for d in "$@"; do [ -f "${d}/bundle-${tag}.json" ] && taken=true; done
[ "$taken" = false ] && break
n=$((n + 1)); tag="${base}.${n}"
done
printf '%s' "$tag"
}
# Write <dir>/bundle-<tag>.json and <dir>/current.json from a content document.
# The header keys go first so a person reading the file sees what it is.
publish_doc() {
local dir="$1" tag="$2" content="$3"
mkdir -p "$dir"
jq --arg bundle "$tag" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'(if has("game") then { schema: .schema, game: .game } else { schema: .schema } end)
+ { bundle: $bundle, generated: $generated } + del(.schema, .game)' \
"$content" > "${dir}/bundle-${tag}.json"
cp "${dir}/bundle-${tag}.json" "${dir}/current.json"
local rel="${dir#"$PUBLISHED"}"; rel="${rel#/}"
echo "${rel:+${rel}/}bundle-${tag}.json" >> "$WORK/published.txt"
}
# ── ServUO: schema 1 and v2/servuo ───────────────────────────────────────────
compose_servuo() {
resolve_latest "$SERVUO_LINK_REPO" servuo-link \
|| fail "${SERVUO_LINK_REPO} has no published release"
resolve_latest "$SERVUO_OVERLAY_REPO" servuo-overlay \
|| fail "${SERVUO_OVERLAY_REPO} has no published release"
local link_tag overlay_tag
link_tag="$(release_tag servuo-link)"; overlay_tag="$(release_tag servuo-overlay)"
verify_assets servuo-link
verify_assets servuo-overlay
# link's binaries onto platform keys. An unrecognized name is a hard failure,
# so a new target in link's release.yml reddens this job instead of silently
# vanishing from every bundle; a missing required key fails the same way.
local assets='{}' name url plat
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
case "$name" in
*-linux-x86_64) plat=linux-x86_64 ;;
*-linux-aarch64) plat=linux-aarch64 ;;
*-windows-x86_64.exe) plat=windows-x86_64 ;;
*) fail "unrecognized link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json servuo-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/servuo-link/asset-list.tsv"
for plat in linux-x86_64 linux-aarch64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "link release is missing a ${plat} binary; the installer ships for all three"
done
local tarball manifest overlay_protocol link_protocol
[ "$(wc -l < "$WORK/servuo-overlay/asset-list.tsv")" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS"
tarball="$(single_tarball servuo-overlay)"
manifest="$(payload_manifest servuo-overlay "$tarball" "$SERVUO_OVERLAY_PREFIX" "$overlay_tag")"
overlay_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$SERVUO_LINK_REPO" "$link_tag" servuo-link)"
echo "==> servuo: sidecar ${link_tag} protocol=${link_protocol} | overlay ${overlay_tag} protocol=${overlay_protocol}"
[ "$link_protocol" = "$overlay_protocol" ] || fail \
"PROTOCOL MISMATCH — sidecar ${link_tag} speaks ${link_protocol}, overlay ${overlay_tag} declares ${overlay_protocol}. Refusing a bundle whose shard the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
local common
common="$(jq -n \
--arg link_repo "$SERVUO_LINK_REPO" --arg link_tag "$link_tag" --argjson assets "$assets" \
--arg ov_repo "$SERVUO_OVERLAY_REPO" --arg ov_tag "$overlay_tag" \
--arg ov_commit "$(jq -r '.commit' "$manifest")" \
--arg ov_min "$(jq -r '.servuo.min_version' "$manifest")" \
--arg ov_patched "$(jq -r '.servuo.patches_verified_against' "$manifest")" \
--argjson ov_asset "$(asset_json servuo-overlay "$tarball")" \
--argjson protocol "$link_protocol" \
'{ protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets },
overlay: { repo: $ov_repo, tag: $ov_tag, version: ($ov_tag | ltrimstr("v")),
commit: $ov_commit, protocol: $protocol,
servuo: { min_version: $ov_min, patches_verified_against: $ov_patched },
asset: $ov_asset } }')"
# Schema 1: byte-for-byte the shape every shipped installer parses.
jq '{ schema: 1, protocol: .protocol, link: .sidecar, overlay: .overlay }' \
<<<"$common" > "$WORK/servuo-s1.json"
# Schema 2: the same pair, in the game-neutral shape (§34.2.2). The payload's
# `compat` is schema 1's `servuo` block.
jq '{ schema: 2, game: "servuo", protocol: .protocol, sidecar: .sidecar,
payload: ({ kind: "overlay" } + (.overlay | del(.servuo)) + { compat: .overlay.servuo }) }' \
<<<"$common" > "$WORK/servuo-s2.json"
local s1_active=false s1_changed=false s2_changed=false tag=""
[[ "$TODAY" < "$SCHEMA1_RETIRES" ]] && s1_active=true
if [ "$s1_active" = true ] && ! same_content "$PUBLISHED/current.json" "$WORK/servuo-s1.json"; then
s1_changed=true
fi
same_content "$PUBLISHED/v2/servuo/current.json" "$WORK/servuo-s2.json" || s2_changed=true
if [ "$s1_changed" = false ] && [ "$s2_changed" = false ]; then
note "- **ServUO**: no change (link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol})."
[ "$s1_active" = true ] || note " Schema 1 retired on ${SCHEMA1_RETIRES}; its \`current.json\` stays frozen."
return 0
fi
# One matrix, one tag: when schema 1 already names this pair, v2 borrows its
# tag — unless v2 somehow already holds that tag for different content, since
# a published bundle-<tag>.json is never rewritten.
if [ "$s1_active" = true ] && [ "$s1_changed" = false ] && [ -f "$PUBLISHED/current.json" ]; then
tag="$(jq -r '.bundle' "$PUBLISHED/current.json")"
if [ -f "$PUBLISHED/v2/servuo/bundle-${tag}.json" ]; then tag=""; fi
fi
[ -n "$tag" ] || tag="$(fresh_tag "$PUBLISHED" "$PUBLISHED/v2/servuo")"
[ "$s1_changed" = true ] && publish_doc "$PUBLISHED" "$tag" "$WORK/servuo-s1.json"
[ "$s2_changed" = true ] && publish_doc "$PUBLISHED/v2/servuo" "$tag" "$WORK/servuo-s2.json"
local which=""
[ "$s1_changed" = true ] && which="schema 1"
[ "$s2_changed" = true ] && which="${which:+${which} + }schema 2"
note "- **ServUO**: published \`${tag}\` (${which}) — link \`${link_tag}\`, overlay \`${overlay_tag}\`, protocol ${link_protocol}."
echo "servuo ${tag}" >> "$WORK/published-games.txt"
}
# ── Rust: v2/rust ────────────────────────────────────────────────────────────
compose_rust() {
local rc=0 missing=""
resolve_latest "$RUST_LINK_REPO" rust-link || { rc=$?; [ "$rc" -eq 2 ] && missing="${RUST_LINK_REPO}"; }
rc=0
resolve_latest "$RUST_PLUGIN_REPO" rust-plugin || { rc=$?; [ "$rc" -eq 2 ] && missing="${missing:+${missing} and }${RUST_PLUGIN_REPO}"; }
if [ -n "$missing" ]; then
echo "==> rust: no release yet from ${missing}, so nothing to compose (not a failure)"
note "- **Rust**: skipped — no release yet from ${missing}."
return 0
fi
local link_tag plugin_tag
link_tag="$(release_tag rust-link)"; plugin_tag="$(release_tag rust-plugin)"
verify_assets rust-link
verify_assets rust-plugin
# Rust-Link's release: a binary per platform, the egg's launcher, and the egg
# itself. The egg is verified (gate 2 covers every asset) but is not part of
# the bundle — a panel admin imports it; nothing resolves it from here.
local assets='{}' launcher="" name url plat
while IFS=$'\t' read -r name url; do
[ -n "$name" ] || continue
case "$name" in
*-linux-x86_64) plat=linux-x86_64 ;;
*-windows-x86_64.exe) plat=windows-x86_64 ;;
with-sidecar.sh) launcher="$(asset_json rust-link "$name")"; continue ;;
egg-*.json) continue ;;
*) fail "unrecognized Rust-Link asset '${name}' — teach compose-bundles.sh this name or the bundle would silently omit it." ;;
esac
assets="$(jq --arg p "$plat" --argjson a "$(asset_json rust-link "$name")" '. + { ($p): $a }' <<<"$assets")"
done < "$WORK/rust-link/asset-list.tsv"
# No linux-aarch64: RustDedicated has no arm64 build (D149).
for plat in linux-x86_64 windows-x86_64; do
jq -e --arg p "$plat" 'has($p)' <<<"$assets" >/dev/null \
|| fail "Rust-Link release is missing a ${plat} binary (D149)"
done
[ -n "$launcher" ] || fail "Rust-Link release has no with-sidecar.sh — the egg's startup runs it (§34.2.6)"
local tarball manifest plugin_protocol link_protocol
tarball="$(single_tarball rust-plugin)"
manifest="$(payload_manifest rust-plugin "$tarball" "$RUST_PLUGIN_PREFIX" "$plugin_tag")"
[ -f "$(dirname "$manifest")/RunicGateway.cs" ] \
|| fail "the Rust-Plugins tarball has no ${RUST_PLUGIN_PREFIX}/RunicGateway.cs"
plugin_protocol="$(jq -r '.protocol' "$manifest")"
link_protocol="$(sidecar_protocol "$RUST_LINK_REPO" "$link_tag" rust-link)"
echo "==> rust: sidecar ${link_tag} protocol=${link_protocol} | plugin ${plugin_tag} protocol=${plugin_protocol}"
[ "$link_protocol" = "$plugin_protocol" ] || fail \
"PROTOCOL MISMATCH — Rust-Link ${link_tag} speaks ${link_protocol}, Rust-Plugins ${plugin_tag} declares ${plugin_protocol}. The game link has no 409: a mismatched plugin would mis-parse. Fix: land the matching half and let its release cut, or bump Rust-Plugins/overlay.toml."
jq -n \
--arg link_repo "$RUST_LINK_REPO" --arg link_tag "$link_tag" \
--argjson assets "$assets" --argjson launcher "$launcher" \
--arg p_repo "$RUST_PLUGIN_REPO" --arg p_tag "$plugin_tag" \
--slurpfile m "$manifest" \
--argjson p_asset "$(asset_json rust-plugin "$tarball")" \
--argjson protocol "$link_protocol" \
'{ schema: 2, game: "rust", protocol: $protocol,
sidecar: { repo: $link_repo, tag: $link_tag, version: ($link_tag | ltrimstr("v")),
protocol: $protocol, assets: $assets, launcher: $launcher },
payload: { kind: "plugin", repo: $p_repo, tag: $p_tag, version: ($p_tag | ltrimstr("v")),
commit: $m[0].commit, protocol: $protocol,
compat: { frameworks: { oxide: { min_version: $m[0].min_oxide_version },
carbon: { min_version: $m[0].min_carbon_version } },
requires_plugins: $m[0].requires_plugins },
asset: $p_asset } }' > "$WORK/rust-s2.json"
# A manifest missing a key would compose a `null` the installer then trusts.
jq -e '([.payload.compat.frameworks[].min_version, .payload.commit] | all(type == "string"))
and (.payload.compat.requires_plugins | type == "array")' "$WORK/rust-s2.json" >/dev/null \
|| fail "the Rust-Plugins manifest is missing commit, a framework floor, or requires_plugins"
if same_content "$PUBLISHED/v2/rust/current.json" "$WORK/rust-s2.json"; then
note "- **Rust**: no change (Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol})."
return 0
fi
local tag
tag="$(fresh_tag "$PUBLISHED/v2/rust")"
publish_doc "$PUBLISHED/v2/rust" "$tag" "$WORK/rust-s2.json"
note "- **Rust**: published \`${tag}\` — Rust-Link \`${link_tag}\`, Rust-Plugins \`${plugin_tag}\`, protocol ${link_protocol}."
echo "rust ${tag}" >> "$WORK/published-games.txt"
}
# ── Run both, independently ──────────────────────────────────────────────────
: > "$WORK/published-games.txt"
FAILED=""
for game in servuo rust; do
echo "────────── ${game} ──────────"
# NOT `if ! ( … )`: a subshell in a condition runs with errexit OFF, so an
# unchecked failed download inside it would sail on. Capture the status instead.
set +e
( set -e; "compose_${game}" )
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
FAILED="${FAILED:+${FAILED}, }${game}"
note "- **${game}**: **FAILED** — see the log. Nothing was published for it."
fi
done
CHANGED=false
[ -s "$WORK/published.txt" ] && CHANGED=true
{
echo "changed=${CHANGED}"
echo "commit_subject=chore(bundle): publish $(paste -sd ',' "$WORK/published-games.txt" | sed 's/,/, /g') [skip ci]"
} > "$WORK/result.env"
echo "────────── result ──────────"
cat "$WORK/summary.md"
[ "$CHANGED" = true ] && { echo "files:"; sed 's/^/ /' "$WORK/published.txt"; }
[ -z "$FAILED" ] || fail "compose failed for: ${FAILED}"

View File

@@ -1,42 +1,64 @@
# Compose and publish the bundle manifest.
# Compose and publish the bundle manifests.
#
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3).
# This is Phase 0 item 3 of docs/installer/PLAN.md (§7.1–§7.3), extended by
# module-rust phase 18 (docs/modules/rust/PLAN.md §34.2.2) to a second game.
#
# ── What a bundle is ─────────────────────────────────────────────────────────
# The bundle IS the compat matrix. The installer does not hardcode component
# versions and does not resolve "latest" at run time; it fetches one small JSON
# document naming an exact, protocol-checked combination of a uo-link release
# and a servuo-plugins overlay release, and installs that. Because the bundle is
# document naming an exact, protocol-checked combination of a sidecar release
# and a game-side payload release, and installs that. Because the bundle is
# data, a new sidecar release regenerates ~30 lines of JSON and leaves the
# installer binary untouched: operators do not re-download the installer to pick
# up a sidecar patch, and this repo does not accumulate releases whose code is
# byte-identical.
#
# ── Where it is published, and why not as a release ──────────────────────────
# Bundles are COMMITTED to this repo under bundles/:
# ── The streams ──────────────────────────────────────────────────────────────
# Bundles are COMMITTED to this repo, on their own `bundles` branch:
#
# bundles/current.json the bundle the installer uses by default
# bundles/bundle-<tag>.json every bundle ever published, kept for --bundle
# current.json, bundle-<tag>.json schema 1, ServUO: link + overlay
# v2/servuo/current.json, bundle-<tag>.json schema 2, game "servuo"
# v2/rust/current.json, bundle-<tag>.json schema 2, game "rust": Rust-Link + Rust-Plugins
#
# so the installer's two fetches are plain anonymous raw URLs on a public repo:
# Schema 2 (D146) names ONE game, with a `game` discriminant and a `payload`
# that is an overlay for ServUO and a plugin for Rust. Schema 1 (D147) is still
# composed beside it because every installer in the field reads only the root
# `current.json` and refuses any other schema; on 2027-01-01 it stops being
# composed and is left frozen, never deleted, so old installers still resolve
# and every bundle-<tag>.json stays pinnable. A ServUO matrix carries the same
# tag at both schemas.
#
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json
# The installer's fetches are plain anonymous raw URLs on a public repo:
#
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
# https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
#
# A BRANCH, not `main`, because `main` is protected and this job is unattended:
# the pre-receive hook declines a push from CI, which is not a thing a nightly
# cron can resolve. Publishing to a branch of its own keeps everything the
# original choice was for — a reviewable diff, a git history of the compat
# matrix, plain raw URLs, no auth on the shard host — and needs no protection
# exception.
#
# The obvious alternative — one Gitea release per bundle — was rejected because
# it collides with this repo's own product. release.yml publishes the installer
# BINARIES as v* releases, and `/releases/latest` returns whichever release is
# newest regardless of kind; interleaving bundle releases would make "latest"
# intermittently resolve to a release containing no installer binary. Committing
# also gets a reviewable diff and a git history of the compat matrix for free.
# intermittently resolve to a release containing no installer binary.
#
# The push to `main` needs no new branch-protection exception: release.yml's
# version-bump commit already requires REGISTRY_USER to be able to push here.
# `main` is never pushed to by this workflow. (release.yml does not push to it
# either — it tags and lets the release API do the rest.)
#
# ── Where the logic lives ────────────────────────────────────────────────────
# .gitea/scripts/compose-bundles.sh holds the gates and the documents, so it can
# be run by hand against the real release API (or a mock) without a runner. This
# file owns what needs the runner: checkout, the stale-component dispatch, and
# the push.
#
# ── Triggers (PLAN.md §7.2) ──────────────────────────────────────────────────
# workflow_dispatch — POSTed by link's and servuo-plugins' release workflows
# as their final step, so a new release recomposes the
# bundle immediately.
# workflow_dispatch — POSTed by link's, servuo-plugins', Rust-Link's and
# Rust-Plugins' release workflows as their final step,
# so a new release recomposes the bundle immediately.
# schedule (nightly) — recomputes from whatever the latest releases actually
# are, so a missed or failed dispatch self-heals instead
# of silently pinning operators to a stale sidecar.
@@ -47,11 +69,11 @@
# ── Prerequisites (Settings → Actions → Secrets on RunicGateway/installer) ───
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository`. It needs write
# on THIS repo (to push the bundle commit) and on
# RunicGateway/link + RunicGateway/servuo-plugins (to fire
# their release workflows for the stale case below). A token
# without the latter degrades to a warning, not a failure —
# the bundle it composes is still valid.
# on THIS repo (to push the bundle commit) and on the four
# component repos (to fire their release workflows for the
# stale case below). A token without the latter degrades to
# a warning, not a failure — the bundle it composes is still
# valid.
#
# The bundle commit carries `[skip ci]`, so it does not re-trigger release.yml.
@@ -65,7 +87,7 @@ on:
# Two component releases landing together dispatch this twice. Serialize rather
# than cancel: a cancelled run is a bundle that never got composed, and the
# second run would otherwise race the first on the push to main.
# second run would otherwise race the first on the push.
concurrency:
group: compose-bundle
cancel-in-progress: false
@@ -73,27 +95,49 @@ concurrency:
env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/installer
LINK_REPO: RunicGateway/link
OVERLAY_REPO: RunicGateway/servuo-plugins
# Fixed top-level directory inside the overlay tarball. Deliberately NOT
# versioned (servuo-plugins/.gitea/workflows/release.yml) — a versioned prefix
# would mean parsing the version out of a path in order to read the manifest
# that declares the version.
OVERLAY_PREFIX: runicgateway-overlay
jobs:
compose:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Full history: the push step rebases onto main if release.yml's version
# bump landed while this job was composing, and a depth-1 clone has no
# base to rebase onto.
- name: Check out the bundles directory
# Full history: the publish step rebases onto the bundles branch if another
# run landed while this one was composing, and a depth-1 clone has no base
# to rebase onto.
- name: Check out the repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# The published bundles live on their own branch (see the header), so they
# are materialized into a worktree rather than being part of the checkout.
# The compose script reads and writes `published/`, so the ".2 suffix"
# scan and the idempotence check both see what is actually published.
- name: Materialize the bundles branch
run: |
set -euo pipefail
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
# `prune` matters on a re-run in an existing checkout: removing the
# directory leaves the worktree registered, and `worktree add` then
# refuses the path. CI checks out fresh every time, so this only shows
# up when driving the job by hand — which is how it is tested.
rm -rf published
git worktree prune
if git ls-remote --exit-code --heads origin bundles >/dev/null 2>&1; then
git fetch origin bundles
git worktree add -B bundles published origin/bundles
echo "==> bundles branch: $(find published -name 'bundle-*.json' | wc -l) published bundle(s)"
else
# First run. A root commit with an empty tree gives the worktree a
# branch to sit on without inheriting main's history, which has
# nothing to do with the compat matrix.
EMPTY_TREE="$(git hash-object -t tree /dev/null)"
ROOT="$(git commit-tree "$EMPTY_TREE" -m 'chore(bundle): start the bundles branch')"
git worktree add -B bundles published "$ROOT"
echo "==> bundles branch does not exist yet; it will be created by the first publish"
fi
- name: Install jq and curl
run: |
set -euo pipefail
@@ -102,266 +146,27 @@ jobs:
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
# ── Resolve the two component releases ───────────────────────────────
# Read ANONYMOUSLY, on purpose. These are exactly the requests the shipped
# installer makes on an operator's machine, which has no Gitea credentials
# (PLAN.md §1: no git and no token on the shard host). Authenticating here
# would hide a repo flipped to private until an operator hit it; this way
# the visibility regression fails CI instead.
- name: Resolve the latest release of each component
id: resolve
run: |
set -euo pipefail
mkdir -p work
for pair in "link:${LINK_REPO}" "overlay:${OVERLAY_REPO}"; do
KEY="${pair%%:*}"; SLUG="${pair#*:}"
curl -sSfL -o "work/${KEY}-release.json" \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/releases/latest"
TAG="$(jq -r '.tag_name' "work/${KEY}-release.json")"
[ -n "$TAG" ] && [ "$TAG" != "null" ] || { echo "::error::${SLUG} has no published release"; exit 1; }
echo "${KEY}_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "${KEY}_version=${TAG#v}" >> "$GITHUB_OUTPUT"
echo "==> ${SLUG} latest: ${TAG}"
done
# ── GATE 2 (PLAN.md §7.1): assets exist, checksums match ─────────────
# Every asset the bundle will reference is downloaded and verified against
# the SHA256SUMS the publishing repo shipped beside it. This is not
# ceremony: SHA256SUMS is the trust anchor for these deliberately UNSIGNED
# artifacts (PLAN.md §3), and the installer will verify against the hashes
# THIS job records. A hash copied from a file nobody checked would make the
# whole chain decorative.
# ── Resolve, gate and compose every stream ───────────────────────────
# Release resolution, gate 2 (every asset present and matching its
# SHA256SUMS), gate 1 (the sidecar's PROTOCOL_VERSION at its release tag
# against the payload's declared protocol) and the documents all live in
# the script. Each game composes independently: a Rust failure still lets
# a ServUO bundle publish, and this step goes red afterwards. A game whose
# repos have never released composes nothing, and that is not a failure.
#
# `sha256sum -c` without --ignore-missing fails when SHA256SUMS names a
# file the release does not actually carry. The reverse — an asset with no
# SHA256SUMS entry — is checked separately below, because -c would not
# notice it.
- name: 'Gate 2: download assets and verify checksums'
id: assets
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
for KEY in link overlay; do
DIR="work/${KEY}"; mkdir -p "$DIR"
SUMS_URL="$(jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url' "work/${KEY}-release.json")"
[ -n "$SUMS_URL" ] && [ "$SUMS_URL" != "null" ] \
|| fail "${KEY} release has no SHA256SUMS asset — nothing to verify against"
curl -sSfL -o "${DIR}/SHA256SUMS" "$SUMS_URL"
jq -r '.assets[] | select(.name != "SHA256SUMS") | "\(.name)\t\(.browser_download_url)"' \
"work/${KEY}-release.json" > "${DIR}/asset-list.tsv"
[ -s "${DIR}/asset-list.tsv" ] || fail "${KEY} release carries no assets besides SHA256SUMS"
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
echo " fetching ${KEY}/${NAME}"
curl -sSfL -o "${DIR}/${NAME}" "$URL"
# An asset absent from SHA256SUMS is unverifiable, and `-c` below
# would pass right over it. The `\*?` is not paranoia: sha256sum
# marks binary mode by prefixing the path with `*` instead of the
# two-space text separator, so a naive match on " ${NAME}" would
# miss every entry on a host that defaults to binary mode.
grep -qE "[ \t]\*?${NAME}\$" "${DIR}/SHA256SUMS" \
|| fail "${KEY} asset ${NAME} has no entry in that release's SHA256SUMS"
done < "${DIR}/asset-list.tsv"
( cd "$DIR" && sha256sum -c SHA256SUMS ) \
|| fail "${KEY} assets do not match the SHA256SUMS published with them"
echo "==> ${KEY}: all assets present and verified"
done
# Map link's binaries onto platform keys. The pattern is asserted, not
# assumed: an unrecognized asset name is a hard failure so that adding
# a target to link's release.yml (aarch64, macOS) surfaces here as a
# red run, rather than being silently dropped from every bundle.
: > work/link-platforms.tsv
while IFS="$(printf '\t')" read -r NAME URL; do
[ -n "$NAME" ] || continue
case "$NAME" in
*-linux-x86_64) PLAT=linux-x86_64 ;;
*-windows-x86_64.exe) PLAT=windows-x86_64 ;;
*) fail "unrecognized link asset '${NAME}' — bundle.yml does not know what platform to file it under. Teach it this name or the bundle would silently omit the asset." ;;
esac
printf '%s\t%s\t%s\t%s\n' "$PLAT" "$NAME" "$URL" \
"$(sha256sum "work/link/${NAME}" | cut -d' ' -f1)" >> work/link-platforms.tsv
done < work/link/asset-list.tsv
for REQUIRED in linux-x86_64 windows-x86_64; do
grep -q "^${REQUIRED}$(printf '\t')" work/link-platforms.tsv \
|| fail "link release is missing a ${REQUIRED} binary; the installer ships for both"
done
# The overlay release carries exactly one artifact: the tarball.
OVERLAY_COUNT="$(wc -l < work/overlay/asset-list.tsv)"
[ "$OVERLAY_COUNT" -eq 1 ] \
|| fail "expected exactly 1 overlay asset besides SHA256SUMS, found ${OVERLAY_COUNT}"
OVERLAY_NAME="$(cut -f1 work/overlay/asset-list.tsv)"
case "$OVERLAY_NAME" in
*.tar.gz) ;;
*) fail "overlay asset '${OVERLAY_NAME}' is not the .tar.gz the installer expects" ;;
esac
{
echo "overlay_name=${OVERLAY_NAME}"
echo "overlay_url=$(cut -f2 work/overlay/asset-list.tsv)"
echo "overlay_sha=$(sha256sum "work/overlay/${OVERLAY_NAME}" | cut -d' ' -f1)"
} >> "$GITHUB_OUTPUT"
# ── GATE 1 (PLAN.md §7.1): the two halves speak the same protocol ────
# This is the check the whole bundle exists for. The sidecar rejects a
# protocol mismatch with 409 rather than mis-parsing, so a mismatched pair
# is not a subtle bug — it is a shard that emits into a void. Catching it
# here costs one HTTP GET; catching it on an operator's box costs them an
# evening.
#
# The two sides are read from genuinely different places because they ARE
# genuinely different:
#
# overlay — manifest.json inside the tarball. The C# plugin announces no
# version on the wire and none is queryable before ServUO
# boots (PLAN.md §2.6), so this hand-maintained declaration is
# the only statement of it that exists.
# sidecar — PROTOCOL_VERSION in sidecar/src/main.rs, read at the RELEASE
# TAG. Not from the binary: `--print-config` would answer, but
# only for releases from v1.1.0 on (it did not exist before
# Phase 0.2), and `--bundle <tag>` has to be able to recompose
# an older bundle. Reading the tag the release was built from
# works uniformly, needs no execution of a downloaded
# artifact, and does not provision a throwaway config and
# print its auth token into a CI log.
- name: 'Gate 1: sidecar and overlay protocol versions agree'
id: protocol
run: |
set -euo pipefail
fail() { echo "::error::$*"; exit 1; }
LINK_TAG="${{ steps.resolve.outputs.link_tag }}"
OVERLAY_TAG="${{ steps.resolve.outputs.overlay_tag }}"
tar -xzf "work/overlay/${{ steps.assets.outputs.overlay_name }}" -C work
MANIFEST="work/${OVERLAY_PREFIX}/manifest.json"
[ -f "$MANIFEST" ] || fail "the overlay tarball has no ${OVERLAY_PREFIX}/manifest.json — the installer resolves it at that exact path"
OVERLAY_PROTOCOL="$(jq -r '.protocol' "$MANIFEST")"
OVERLAY_COMMIT="$(jq -r '.commit' "$MANIFEST")"
MANIFEST_VERSION="$(jq -r '.version' "$MANIFEST")"
# A manifest that disagrees with the tag it shipped under means the
# release workflow stamped one version and tagged another; every
# downstream record of "what is installed" would then be wrong.
[ "$MANIFEST_VERSION" = "${OVERLAY_TAG#v}" ] \
|| fail "overlay manifest says version ${MANIFEST_VERSION} but the release is tagged ${OVERLAY_TAG}"
curl -sSfL -o work/main.rs \
"https://${GITEA_HOST}/${LINK_REPO}/raw/tag/${LINK_TAG}/sidecar/src/main.rs"
LINK_PROTOCOL="$(sed -nE 's/^[[:space:]]*pub const PROTOCOL_VERSION[^=]*=[[:space:]]*([0-9]+).*/\1/p' work/main.rs | head -1)"
# Empty means the constant moved or was renamed. Fail loudly: silently
# treating "could not read" as "matches" is how a mismatched pair ships.
[ -n "$LINK_PROTOCOL" ] \
|| fail "could not read PROTOCOL_VERSION from ${LINK_REPO}@${LINK_TAG}:sidecar/src/main.rs — has the constant moved? Gate 1 cannot be skipped."
echo "==> sidecar ${LINK_TAG} protocol=${LINK_PROTOCOL} | overlay ${OVERLAY_TAG} protocol=${OVERLAY_PROTOCOL}"
[ "$LINK_PROTOCOL" = "$OVERLAY_PROTOCOL" ] || fail \
"PROTOCOL MISMATCH — sidecar ${LINK_TAG} speaks ${LINK_PROTOCOL}, overlay ${OVERLAY_TAG} declares ${OVERLAY_PROTOCOL}. Refusing to publish a bundle that would install a shard whose events the sidecar rejects with 409. Fix: land the matching half and let its release cut, or bump servuo-plugins/overlay.toml."
{
echo "protocol=${LINK_PROTOCOL}"
echo "overlay_commit=${OVERLAY_COMMIT}"
echo "overlay_min_servuo=$(jq -r '.servuo.min_version' "$MANIFEST")"
echo "overlay_patched_against=$(jq -r '.servuo.patches_verified_against' "$MANIFEST")"
} >> "$GITHUB_OUTPUT"
# ── Compose ──────────────────────────────────────────────────────────
# Note the shape difference from PLAN.md §7.1's sketch: `link` carries a
# per-platform asset map rather than one sha256. link publishes a Linux
# binary and a Windows .exe, and the installer runs on both — a single
# hash could only ever have described one of them.
- name: Compose bundle.json
# The outputs are written even when the script fails, so the publish step
# (on `always()`) can still ship the game that did compose.
- name: Compose the bundles
id: compose
run: |
set -euo pipefail
LINK_ASSETS="$(jq -R -s '
split("\n") | map(select(length > 0)) | map(split("\t"))
| map({ (.[0]): { name: .[1], url: .[2], sha256: .[3] } }) | add
' work/link-platforms.tsv)"
jq -n \
--arg link_repo "${LINK_REPO}" \
--arg link_tag "${{ steps.resolve.outputs.link_tag }}" \
--arg link_version "${{ steps.resolve.outputs.link_version }}" \
--argjson link_assets "${LINK_ASSETS}" \
--arg ov_repo "${OVERLAY_REPO}" \
--arg ov_tag "${{ steps.resolve.outputs.overlay_tag }}" \
--arg ov_version "${{ steps.resolve.outputs.overlay_version }}" \
--arg ov_commit "${{ steps.protocol.outputs.overlay_commit }}" \
--arg ov_name "${{ steps.assets.outputs.overlay_name }}" \
--arg ov_url "${{ steps.assets.outputs.overlay_url }}" \
--arg ov_sha "${{ steps.assets.outputs.overlay_sha }}" \
--arg ov_min "${{ steps.protocol.outputs.overlay_min_servuo }}" \
--arg ov_patched "${{ steps.protocol.outputs.overlay_patched_against }}" \
--argjson protocol "${{ steps.protocol.outputs.protocol }}" \
'{
schema: 1,
protocol: $protocol,
link: {
repo: $link_repo,
tag: $link_tag,
version: $link_version,
protocol: $protocol,
assets: $link_assets
},
overlay: {
repo: $ov_repo,
tag: $ov_tag,
version: $ov_version,
commit: $ov_commit,
protocol: $protocol,
servuo: {
min_version: $ov_min,
patches_verified_against: $ov_patched
},
asset: { name: $ov_name, url: $ov_url, sha256: $ov_sha }
}
}' > work/content.json
echo "----- composed content -----"
cat work/content.json
# Idempotence. `bundle` and `generated` are metadata ABOUT this run, so
# comparing them would make every nightly cron look like a change and
# commit a dated duplicate of the same matrix forever. Compare only
# what the installer would actually act on.
CHANGED=true
if [ -f bundles/current.json ]; then
if jq -S 'del(.bundle, .generated)' bundles/current.json > work/old-content.json \
&& jq -S '.' work/content.json > work/new-content.json \
&& cmp -s work/old-content.json work/new-content.json; then
CHANGED=false
fi
rc=0
PUBLISHED="$PWD/published" WORK="$PWD/work" GITEA_HOST="$GITEA_HOST" \
bash .gitea/scripts/compose-bundles.sh || rc=$?
if [ -f work/result.env ]; then
grep '^changed=' work/result.env >> "$GITHUB_OUTPUT"
fi
echo "changed=${CHANGED}" >> "$GITHUB_OUTPUT"
if [ "$CHANGED" = false ]; then
echo "==> identical to bundles/current.json — nothing to publish."
exit 0
fi
# Bundle tags are dates (PLAN.md §7.1). Two bundles on one day — a
# sidecar release in the morning and an overlay release in the
# afternoon is the normal way that happens — get .2, .3, … so a tag
# always names exactly one matrix and `--bundle` stays reproducible.
BASE="$(date -u +%Y.%m.%d)"
TAG="$BASE"; N=1
while [ -f "bundles/bundle-${TAG}.json" ]; do
N=$((N+1)); TAG="${BASE}.${N}"
done
mkdir -p bundles
jq --arg bundle "$TAG" --arg generated "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{ schema: .schema, bundle: $bundle, generated: $generated } + del(.schema)' \
work/content.json > "bundles/bundle-${TAG}.json"
cp "bundles/bundle-${TAG}.json" bundles/current.json
echo "bundle_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "==> composed bundle ${TAG}"
exit "$rc"
# ── Stale-component check: dispatch, don't wait (PLAN.md §7.3) ───────
# Each component self-releases on merge to its own main, so by the time
@@ -376,10 +181,14 @@ jobs:
# counts would report every README fix as a stuck release and re-dispatch
# a workflow that correctly declines to run, every single night.
#
# This runs even when the bundle is unchanged: an unchanged bundle is the
# exact symptom of a component release that never happened.
# This runs even when the bundle is unchanged, or a game failed: an
# unchanged bundle is the exact symptom of a component release that never
# happened. work/stale.tsv names each component the compose resolved; a
# repo that has never released is absent from it, and has no release to
# be stale against.
- name: Check for components with unreleased work, and dispatch them
id: stale
if: always()
continue-on-error: true
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -387,14 +196,17 @@ jobs:
set -euo pipefail
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')"
# Warnings go to a FILE, not a step output. The job summary below
# reads it with `cat`; interpolating a multi-line `${{ }}` value into
# reads it with `cat`; interpolating a multi-line template value into
# a shell string there would let any character in a commit-derived
# message change what that script does.
# message change what that script does. (Do not write that token
# literally in a comment: the runner parses it, fails, and silently
# skips the whole step.)
mkdir -p work
: > work/stale-warnings.md
[ -f work/stale.tsv ] || exit 0
for pair in "${LINK_REPO}:${{ steps.resolve.outputs.link_tag }}" \
"${OVERLAY_REPO}:${{ steps.resolve.outputs.overlay_tag }}"; do
SLUG="${pair%:*}"; TAG="${pair##*:}"
while IFS="$(printf '\t')" read -r SLUG TAG; do
[ -n "$SLUG" ] || continue
if ! curl -sSfL -o work/compare.json \
"https://${GITEA_HOST}/api/v1/repos/${SLUG}/compare/${TAG}...main"; then
echo "::warning::could not compare ${SLUG} ${TAG}...main; skipping its stale check"
@@ -433,7 +245,7 @@ jobs:
else
echo "==> ${SLUG}: nothing releasable after ${TAG}"
fi
done
done < work/stale.tsv
# ── Publish ──────────────────────────────────────────────────────────
# Preflighted for the same reason the release workflows are: actions/
@@ -441,8 +253,11 @@ jobs:
# config, so a push can succeed on that leftover even with the secrets
# empty. That makes "the push worked" no evidence at all that the repo is
# configured, and the failure surfaces somewhere less obvious later.
#
# `always()`: when one game failed and the other composed, the one that
# composed still ships. The compose step has already made the run red.
- name: Verify publish credentials are configured
if: ${{ steps.compose.outputs.changed == 'true' }}
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
@@ -452,45 +267,48 @@ jobs:
[ -n "$(printf '%s' "${REGISTRY_USER:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_USER"
[ -n "$(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" ] || MISSING="${MISSING} REGISTRY_TOKEN"
if [ -n "$MISSING" ]; then
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit to main."
echo "::error::Missing Actions secret(s):${MISSING}. Set them under Settings → Actions → Secrets on ${REPO}. REGISTRY_TOKEN needs write:repository to push the bundle commit."
exit 1
fi
echo "Publish credentials present."
- name: Commit and push the bundle
if: ${{ steps.compose.outputs.changed == 'true' }}
- name: Commit and push the bundles
if: ${{ always() && steps.compose.outputs.changed == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
TAG="${{ steps.compose.outputs.bundle_tag }}"
# Secrets can arrive with a trailing newline depending on how they were
# pasted, and a stray CR/LF corrupts the remote URL ("credential url
# cannot be parsed").
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin "https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add bundles
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 subject is the script's ("publish servuo 2026.09.27, rust
# 2026.09.27 [skip ci]"), read from a file rather than interpolated
# from a template value, so nothing in it can change this script.
grep '^commit_subject=' work/result.env | cut -d= -f2- > work/commit-msg.txt
# The checkout is a detached snapshot of main; push the commit at HEAD
# to the branch the installer reads its raw URLs from. release.yml
# pushes its version-bump commit to the same branch, so losing the
# race is normal rather than exceptional — rebase and retry once
# instead of failing and leaving the bundle unpublished until the
# next cron. Only bundles/ is touched here, so a rebase over a bump
# commit cannot conflict.
if ! git push origin "HEAD:main"; then
echo "::warning::push rejected (main moved during compose) — rebasing and retrying once"
git fetch origin main
git rebase origin/main
git push origin "HEAD:main"
cd published
git add -A
git commit -F ../work/commit-msg.txt
# Two runs can compose at once — a component release dispatches this
# while the nightly cron is mid-flight — so losing the race is normal
# rather than exceptional. Rebase and retry once instead of failing and
# leaving the bundle unpublished until tomorrow. Every file here is a
# bundle nobody else edits, and a bundle tag names exactly one matrix,
# so a rebase cannot conflict.
if ! git push origin bundles; then
echo "::warning::push rejected (the bundles branch moved during compose) — rebasing and retrying once"
git fetch origin bundles
git rebase origin/bundles
git push origin bundles
fi
echo "==> published bundles/bundle-${TAG}.json and bundles/current.json"
echo "==> published on the bundles branch:"
sed 's/^/ /' ../work/published.txt
- name: Job summary
if: always()
@@ -499,16 +317,17 @@ jobs:
{
echo "## Bundle compose"
echo
echo "| Component | Release | Protocol |"
echo "|---|---|---|"
echo "| uo-link sidecar | \`${{ steps.resolve.outputs.link_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo "| servuo-plugins overlay | \`${{ steps.resolve.outputs.overlay_tag }}\` | ${{ steps.protocol.outputs.protocol }} |"
echo
case "${{ steps.compose.outputs.changed }}" in
true) echo "**Published \`${{ steps.compose.outputs.bundle_tag }}\`** → \`bundles/current.json\`" ;;
false) echo "No change — \`bundles/current.json\` already names this combination." ;;
*) echo "Compose did not complete — see the failing step above." ;;
esac
if [ -s work/summary.md ]; then
cat work/summary.md
else
echo "Compose did not complete — see the failing step above."
fi
if [ -s work/published.txt ]; then
echo
echo "Written to the bundles branch:"
echo
sed 's/^/- /' work/published.txt
fi
if [ -s work/stale-warnings.md ]; then
echo
echo "### ⚠ Components with unreleased work"

View File

@@ -43,11 +43,11 @@
# Prerequisites (Settings → Actions → Secrets on RunicGateway/installer):
# REGISTRY_USER — Gitea username the token below belongs to
# REGISTRY_TOKEN — Gitea access token with `write:repository`, so it can push
# the bump commit + tag and create the release.
# Also: `main` must accept a direct push from that user (disable branch
# protection for it, or add it as an exception) — the bump commit lands on main.
# the release tag and create the release.
#
# The bump commit carries `[skip ci]`, so it does not re-trigger this workflow.
# `main` needs NO push exception: this workflow tags and publishes, and never
# writes to a branch. Keeping it that way is deliberate — a first release that
# depends on a write to a protected branch fails at the worst possible moment.
name: Release installer
@@ -64,13 +64,21 @@ env:
GITEA_HOST: gitea.whitlocktech.com
REPO: RunicGateway/installer
BIN: runicgateway-installer
LINUX_TARGET: x86_64-unknown-linux-gnu
# Both Linux binaries are STATIC (musl), D158. Linked against the runner's glibc
# they needed glibc 2.39 and would not start on Debian 12 or Ubuntu 22.04 — the
# hosts an operator is most likely to have (Rust phase 18 walk, step 7). The
# sidecar the installer deploys is already static for the same reason (D149).
LINUX_TARGET: x86_64-unknown-linux-musl
WINDOWS_TARGET: x86_64-pc-windows-gnu
# The installer has to run wherever the sidecar it installs can run, and link
# publishes an arm64 Linux binary from v1.2.0 (PLAN.md §5.2, step 4 of 4).
ARM64_TARGET: aarch64-unknown-linux-musl
jobs:
release:
runs-on: ubuntu-latest
# Don't loop on our own bump commit (belt-and-suspenders with [skip ci]).
# Vestigial since this workflow stopped writing a bump commit, and kept as
# belt-and-braces in case one ever returns.
# Quoted because the expression contains a colon (`chore(release):`), which an
# unquoted YAML scalar would misparse as a mapping value.
if: "${{ !contains(github.event.head_commit.message, 'chore(release): bump version') }}"
@@ -161,6 +169,39 @@ jobs:
fi
fi
# ── Orphan sweep ────────────────────────────────────────────────
#
# The check above is VERSION-SCOPED: it only ever asks about the one
# version this run computed. That is enough to recover an orphan on
# the very next run, and useless afterwards — once any releasable
# commit lands, the next run computes a NEW version, never looks at
# the old tag again, and the orphan becomes permanent and silent.
#
# servuo-plugins v0.1.0 is the proof, and the proof is pointed: the
# commit that ADDED the recovery above was itself typed
# `fix(release): ... recover the orphaned v0.1.0 tag`, so it bumped to
# v0.1.1 — and the run that introduced the recovery stepped straight
# past the tag it was written to rescue. That tag is still orphaned.
#
# So every v* tag is checked, and anything missing a release is
# WARNED about. Deliberately not recovered: publishing an old version
# would mean building today's tree and shipping it under a tag whose
# tree it is not, which is worse than the inconsistency it fixes.
# A human decides whether to recover or drop it.
#
# Never fails the run. A sweep that can break a good release is a
# sweep someone will delete.
ORPHANS=""
for T in $(git tag -l 'v*' --sort=-v:refname); do
T_HTTP="$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $(printf '%s' "${REGISTRY_TOKEN:-}" | tr -d '\r\n')" \
"https://${GITEA_HOST}/api/v1/repos/${REPO}/releases/tags/${T}" || echo 000)"
[ "$T_HTTP" = "404" ] && ORPHANS="${ORPHANS} ${T}"
done
if [ -n "${ORPHANS}" ]; then
echo "::warning::Tags with no release:${ORPHANS} — a run failed after tagging. Publish or delete them; this job will not do either."
fi
# Changelog range. A recovery run has nothing after the tag, so
# summarize what the tag itself contains rather than emitting an empty
# list: the range that produced it, i.e. previous-tag..this-tag.
@@ -237,14 +278,20 @@ jobs:
echo "Release credentials present."
# ── RUST ADAPTER: toolchain + cross-compile deps ─────────────────────
- name: Install Rust toolchain, Windows target, and MinGW linker
- name: Install Rust toolchain, cross targets, and their linkers
if: ${{ steps.plan.outputs.release == 'true' }}
run: |
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
$SUDO apt-get update
# `ring` (under ureq's rustls) compiles C, so each Linux target needs a C compiler that
# targets musl. Ubuntu packages one for x86_64 only; zig (via cargo-zigbuild) is one
# compiler for both, so both Linux builds go through it. python3-pip installs it;
# `file` is for the static-link check after the build.
$SUDO apt-get install -y --no-install-recommends \
build-essential gcc-mingw-w64-x86-64 curl ca-certificates git jq
build-essential gcc-mingw-w64-x86-64 python3-pip file \
curl ca-certificates git jq
pip3 install --quiet --break-system-packages ziglang cargo-zigbuild
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
@@ -253,7 +300,9 @@ jobs:
echo "${HOME}/.cargo/bin" >> "$GITHUB_PATH"
export PATH="${HOME}/.cargo/bin:${PATH}"
rustup component add rustfmt
rustup target add "${LINUX_TARGET}"
rustup target add "${WINDOWS_TARGET}"
rustup target add "${ARM64_TARGET}"
- name: Set the crate version to match the release
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -279,9 +328,9 @@ jobs:
run: cargo test --locked
# ── RUST ADAPTER: build both targets ─────────────────────────────────
- name: cargo build --release (Linux)
- name: cargo build --release (Linux, static musl)
if: ${{ steps.plan.outputs.release == 'true' }}
run: cargo build --release --locked --target "${LINUX_TARGET}"
run: cargo zigbuild --release --locked --target "${LINUX_TARGET}"
- name: cargo build --release (Windows, cross via MinGW)
if: ${{ steps.plan.outputs.release == 'true' }}
@@ -291,6 +340,14 @@ jobs:
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
run: cargo build --release --locked --target "${WINDOWS_TARGET}"
# The installer has to run wherever the sidecar it installs can run, and link publishes an
# arm64 Linux binary (PLAN.md §5.2). Without this step the target is installed and the
# artifact is packaged, but nothing ever builds it — which is exactly how the first release
# attempt failed, at `cp: cannot stat target/aarch64-unknown-linux-gnu/release/...`.
- name: cargo build --release (Linux arm64, static musl)
if: ${{ steps.plan.outputs.release == 'true' }}
run: cargo zigbuild --release --locked --target "${ARM64_TARGET}"
# ── RUST ADAPTER: package artifacts (+ checksums) ────────────────────
# SHA256SUMS is the trust anchor for these unsigned binaries (PLAN.md §3),
# so it ships with every release and the docs lead with the verify command.
@@ -299,38 +356,61 @@ jobs:
run: |
set -euo pipefail
cp "target/${LINUX_TARGET}/release/${BIN}" "dist/${BIN}-linux-x86_64"
cp "target/${ARM64_TARGET}/release/${BIN}" "dist/${BIN}-linux-aarch64"
cp "target/${WINDOWS_TARGET}/release/${BIN}.exe" "dist/${BIN}-windows-x86_64.exe"
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
# A "static" binary that is quietly dynamic fails only on the operator's host, on the
# first line, with a glibc version error — so it is refused here instead (D158).
for f in "dist/${BIN}-linux-x86_64" "dist/${BIN}-linux-aarch64"; do
if ! file "$f" | grep -q 'statically linked'; then
echo "::error::$f is not statically linked: $(file -b "$f")"; exit 1
fi
done
# Every artifact must be listed: `sha256sum -c` passes silently over a
# file the sums do not mention, and an operator verifying a download
# would get a pass on a binary nobody vouched for.
( cd dist && sha256sum "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" > SHA256SUMS )
ls -l dist && echo "----" && cat dist/SHA256SUMS
# ── RELEASE ENGINE: commit the bump, tag, push ───────────────────────
- name: Commit version bump and push tag
# Tag only — `main` is never pushed to.
#
# This step used to commit the version bump back to main first, and it has
# never executed in any repo that carries it: an EMPTY template expression
# written literally in the comment below (the `$`+`{{ }}` token, spelled
# out here for that reason) makes the runner fail to build the script and
# skip the step WITHOUT failing the job. link/release.yml carried the same
# bug for six releases, which is why its Cargo.toml still says 0.1.0 while
# its tags reach v1.1.1 — the release API creates the tag when it
# publishes, so the pipeline worked by accident.
#
# It also would have been declined if it had run: `main` is protected, and
# the bundle job proved that on 2026-08-05 (`pre-receive hook declined`).
# A first release must not depend on a write to a protected branch.
#
# So the tag is the version, as in servuo-plugins. The version is still
# written into Cargo.toml before building, so a released binary
# self-reports correctly; it is simply not committed back. The next
# version is computed from the newest tag, never from the file.
- name: Push the release tag
if: ${{ steps.plan.outputs.release == 'true' }}
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.plan.outputs.version }}"
TAG="${{ steps.plan.outputs.tag }}"
# Secrets can arrive with a trailing newline (depending on how they were
# pasted); a stray CR/LF corrupts the remote URL ("credential url cannot
# be parsed"). Strip line breaks before building the URL. Passing them via
# env (not inline ${{ }}) also keeps a newline from breaking this script.
# be parsed"). Strip line breaks before building the URL. They are passed
# via env rather than interpolated into this script, so a newline cannot
# break it — do NOT write a template token literally in a comment here,
# or the runner will skip this step without failing the job.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
git config user.name "installer-ci"
git config user.email "ci@whitlocktech.com"
git remote set-url origin \
"https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${REPO}.git"
git add Cargo.toml Cargo.lock
if ! git diff --cached --quiet; then
git commit -m "chore(release): bump version to ${TAG} [skip ci]"
git push origin "HEAD:main"
else
echo "Version unchanged (first release) — no bump commit needed."
fi
# The tag may already exist when finishing a run that died after
# tagging (see the plan step). `git tag` on an existing name fails
# under `set -e`; pushing an identical existing tag is a harmless
@@ -357,17 +437,74 @@ jobs:
# corrupt the Authorization header.
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
REL_ID="$(curl -sSf -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')" \
| jq -r '.id')"
PAYLOAD="$(jq -n --arg tag "$TAG" --arg body "$BODY" \
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')"
# This POST is the step that orphaned tag v0.1.1 (run 75): it landed one
# second after the tag push and Gitea answered 500, having not finished
# processing the pushed tag. Re-running the workflow published the same
# four assets untouched, so the failure was a race, not a bad request.
#
# Two things went wrong there, and both are fixed here.
#
# 1. `curl -sSf` prints NO response body on an error status, so all the
# log carried was "curl: (22) ... error: 500" and the cause had to be
# inferred from timestamps. Capture the body and print it.
# 2. Nothing retried, so a transient 5xx became a permanent orphan tag.
# The plan step CAN recover one, but only on a run that reaches it --
# and a later push with no releasable commits stands down before it
# gets there, so in practice the tag sits until a human notices.
#
# 4xx is deliberately NOT retried: a bad token or a malformed body does
# not improve by being sent again, and retrying only turns a clear
# failure into a slow one.
REL_ID=""
for attempt in 1 2 3 4 5; do
HTTP="$(curl -s -o /tmp/rel.json -w '%{http_code}' -X POST "${API}/releases" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" || echo 000)"
if [ "$HTTP" = "201" ] || [ "$HTTP" = "200" ]; then
REL_ID="$(jq -r '.id' /tmp/rel.json)"
break
fi
echo "::warning::POST /releases attempt ${attempt} returned HTTP ${HTTP}"
echo "--- response body ---"
cat /tmp/rel.json || true
echo
echo "---------------------"
case "$HTTP" in
4*) echo "::error::HTTP ${HTTP} is a client error - not retrying."; exit 1 ;;
esac
if [ "$attempt" = 5 ]; then
echo "::error::POST /releases still failing after 5 attempts. Tag ${TAG} is pushed but has no release."
echo "::error::Re-run this workflow - the plan step detects the orphan tag and republishes it."
exit 1
fi
sleep $(( attempt * 5 ))
done
if [ -z "$REL_ID" ] || [ "$REL_ID" = "null" ]; then
echo "::error::Release created but no id came back; refusing to upload assets blind."
exit 1
fi
echo "Created release ${TAG} (id=${REL_ID})"
for f in "${BIN}-linux-x86_64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
curl -sSf -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
for f in "${BIN}-linux-x86_64" "${BIN}-linux-aarch64" "${BIN}-windows-x86_64.exe" SHA256SUMS; do
# Same treatment. An upload that fails quietly leaves a release whose
# SHA256SUMS does not cover every binary it advertises, which is worse
# than no release at all -- that file IS the trust anchor.
HTTP="$(curl -s -o /tmp/asset.json -w '%{http_code}' -X POST "${API}/releases/${REL_ID}/assets?name=${f}" \
-H "Authorization: token ${CI_TOKEN}" \
-F "attachment=@dist/${f}" >/dev/null
-F "attachment=@dist/${f}" || echo 000)"
if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
echo "::error::uploading ${f} returned HTTP ${HTTP}"
cat /tmp/asset.json || true
exit 1
fi
echo " uploaded ${f}"
done

1
Cargo.lock generated
View File

@@ -402,6 +402,7 @@ name = "runicgateway-installer"
version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"chrono",
"flate2",
"serde",

View File

@@ -45,6 +45,10 @@ sha2 = "0.11"
# host, which is the whole point of shipping the plugin as a release tarball.
sha1 = "0.11"
# Schema-2 bundles are read through Gitea's contents API, which returns the file
# base64-encoded — the /raw/ route is CDN-cached for hours (bundle.rs). Pure Rust.
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

111
README.md
View File

@@ -25,9 +25,48 @@ It also **does not replace ServUO startup behavior.** ServUO keeps running throu
its existing release/start scripts; the installer never writes a launcher and
never restarts the shard.
## Install a shard with it
Grab a binary and `SHA256SUMS` from the
[releases page](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
verify the checksum, and run it as Administrator/root against a **stopped** shard:
```bash
sha256sum -c SHA256SUMS --ignore-missing
chmod +x runicgateway-installer-linux-x86_64
sudo ./runicgateway-installer-linux-x86_64 install
```
```powershell
# Windows, from an elevated PowerShell
.\runicgateway-installer-windows-x86_64.exe install
```
It ends by printing the four values to paste into **Admin → Shard** on your site.
The full operator guide — what it asks, where it writes, the patch tier, day-two
commands and troubleshooting — is
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md).
Prefer to place everything yourself, or on a host that cannot run the binary?
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`, and stays
supported.
## Status
**Phases 1 to 4 are built, on the `edge` branch. Nothing is released yet.**
**Released.** All five phases are built and the `edge → main` cutover (#17) cut
the first release, [`v0.1.0`](https://gitea.whitlocktech.com/RunicGateway/installer/releases),
publishing `linux-x86_64`, `linux-aarch64` and `windows-x86_64.exe` with
`SHA256SUMS`.
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ released |
| 2 — uo-link install + service registration | ✅ released |
| 3 — the opt-in stock-file patch tier | ✅ released |
| 4 — `doctor`, `update`, `uninstall` | ✅ released |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | ✅ released |
The binary does everything
[`installer/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md)
@@ -40,39 +79,18 @@ The design of record is
in the docs repo: phases, locked decisions, and the Phase 0 prerequisites in other
repos (a `servuo-plugins` release workflow, a non-interactive config read-back in
`link`, and the bundle-manifest CI here), all of which have landed —
[`bundles/current.json`](bundles/current.json) names the current protocol-checked
sidecar + overlay combination, recomposed on every component release and nightly
(see [`bundles/README.md`](bundles/README.md)).
[`bundles/current.json`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json)
names the current protocol-checked sidecar + overlay combination, recomposed on
every component release and nightly (see [`bundles/README.md`](bundles/README.md)).
| Phase | State |
|---|---|
| 0 — prerequisites in the other repos | ✅ merged |
| 1 — installer core: bundle resolution, ServUO detection, overlay sync, `install.json` | ✅ on `edge` |
| 2 — uo-link install + service registration | ✅ on `edge` |
| 3 — the opt-in stock-file patch tier | ✅ on `edge` |
| 4 — `doctor`, `update`, `uninstall` | ✅ on `edge` |
| 5 — packaging polish: Linux `aarch64`, backup before overwrite | in progress |
**Why `edge`:** `release.yml` publishes an installer binary on every push to
`main`, so nothing lands there until the whole tool is worth handing to an
operator. The `edge → main` cutover cuts the first release. PRs into `edge` run
the same gates as PRs into `main`.
**What the cutover is waiting on**, per PLAN.md §5:
1. **Phase 5**, packaging polish — deliberately *before* the first release rather
than after it, because it changes the release layout, and shipping first would
mean a first release immediately superseded by the next. There is no `.deb`
and no MSI: both would give the sidecar binary, its service unit and its
service account a second owner beside this tool.
2. **The Windows SCM half verified on a real host.** `sc create`, the virtual
service account, the failure actions and the token-file ACL have never been
executed anywhere. Running the *systemd* half for real is what turned up a bug
no unit test had, so this is not a formality.
Until the cutover, the way to install is by hand —
[INSTALL.md Appendix A](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/installer/INSTALL.md#appendix-a--installing-by-hand)
is the same deployment done with `curl`, `tar` and `systemctl`.
**`main` publishes.** `release.yml` cuts a release from every push to `main`, which
is why the crate was integrated on `edge` until it was worth handing to an
operator. Both cutover gates were met first: Phase 5 (its scope settled as **no
`.deb` and no MSI** — either would give the sidecar binary, its service unit and
its service account a second owner beside this tool), and the **Windows SCM half
verified on a real host**. That second one earned its place: `sc start` failed
with 1053 on its first real run and needed a sidecar fix (link#29) before it
passed 13/13.
## Related repos
@@ -93,6 +111,33 @@ is the same deployment done with `curl`, `tar` and `systemctl`.
| `update` | Resolve the current bundle manifest, then update the sidecar (replace + restart) and the overlay (re-sync + tell the operator to restart ServUO). |
| `uninstall` | Remove only what the installer exclusively owns. It **never edits the ServUO tree** — it prints the overlay files to delete and the patch hunks to revert, and leaves that call to the operator. |
### Rust: `--game rust`
The same four verbs set up **Rust servers** (Oxide or Carbon) from a schema-2 Rust bundle — a
Rust-Link sidecar and a Rust-Plugins plugin, checked by CI to speak one protocol. A Rust host
commonly runs several servers, so each is a **named instance** with its own service, config,
database and ports; every instance shares one sidecar binary, and `update` moves them together.
```bash
sudo ./runicgateway-installer-linux-x86_64 install --game rust --rust /srv/rust --server-id main
```
- The framework is **detected**, never asked, and the plugin goes to `oxide/plugins/` or
`carbon/plugins/`. A tree with both, or neither, is refused.
- `--server-id` is the id the website knows the server by. Where the plugin has no config yet the
installer writes one holding just `ServerId` and `Port`; an existing one is never rewritten, and
one naming another server refuses the run.
- Services: `runicgateway-rust@<id>.service` (a systemd template) or `RunicGatewayRust-<id>`.
Records: `rust/install.json`, beside and separate from ServUO's.
- `doctor` checks the framework, the plugin file, the plugin config's `ServerId`, the required uMod
plugins (reported, never installed), the service, and `/health` through to *plugin connected*.
- `uninstall` removes the plugin file and keeps its config — that file is the website's.
A Pterodactyl panel uses the **egg** instead, published with each
[Rust-Link release](https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases). The Rust
operator guide is
[`rust-link/INSTALL.md`](https://gitea.whitlocktech.com/RunicGateway/docs/src/branch/main/rust-link/INSTALL.md).
## Design constraints worth knowing up front
- **Releases are unsigned.** `SHA256SUMS` is the trust anchor; SmartScreen and

View File

@@ -3,35 +3,68 @@
**These files are generated. Do not edit them by hand.**
A *bundle* names one exact, protocol-checked combination of the two components the installer
deploys — a `uo-link` sidecar release and a `servuo-plugins` overlay release. The installer does not
deploys for one game: a sidecar release and a game-side payload release. For ServUO that is `link`
and a `servuo-plugins` overlay; for Rust it is `Rust-Link` and a `Rust-Plugins` plugin. The installer does not
hardcode versions and does not resolve "latest" at run time; it fetches one of these documents and
installs what it names. **The bundle is the compat matrix.**
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml), which composes
They are written by [`.gitea/workflows/bundle.yml`](../.gitea/workflows/bundle.yml) (the logic is
in [`.gitea/scripts/compose-bundles.sh`](../.gitea/scripts/compose-bundles.sh)), which composes
one whenever a component publishes a release (dispatched by that release's own workflow) and
nightly, so a missed dispatch self-heals. A run that finds nothing changed writes nothing.
See `docs/installer/PLAN.md` §7 for the design.
See `docs/installer/PLAN.md` §7 for the design, and `docs/modules/rust/PLAN.md` §34.2.2 for
schema 2.
## Layout
## Where they live: the `bundles` branch
**The JSON documents are not in this directory.** They are published to a branch of their own,
[`bundles`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles). There is one
stream per game and schema, each a directory holding the same two kinds of file:
| Directory | Stream |
|---|---|
| *(root)* | Schema 1, ServUO. What every installer up to v0.2.x reads. **Retires 2027-01-01** |
| `v2/servuo/` | Schema 2, `"game": "servuo"` |
| `v2/rust/` | Schema 2, `"game": "rust"` |
| File | What it is |
|---|---|
| `current.json` | The bundle the installer uses by default. Always a copy of the newest `bundle-*.json`. |
| `bundle-<tag>.json` | Every bundle ever published, kept forever so `--bundle <tag>` stays reproducible. |
**Schema 1 retires on 2027-01-01.** Until then it is composed exactly as before, beside schema 2, so
every installer already in the field keeps updating. After that date it stops being composed and is
left **frozen at its last bundle, never deleted**: an old installer still resolves something, and
every `bundle-<tag>.json` stays pinnable. A ServUO matrix carries the **same tag** at both schemas.
A schema-2 document names one game rather than both, because the games release on their own
schedules: a document naming both would hand a ServUO host a new bundle every time a Rust plugin
shipped. A game none of whose repos has released composes nothing, and that is not a failure.
Tags are UTC dates — `2026.08.04`. A second bundle on the same day (a sidecar release in the
morning, an overlay release in the afternoon) becomes `2026.08.04.2`, so one tag always names
exactly one matrix.
**Why a branch rather than `main`.** `main` is protected and this job is unattended: the pre-receive
hook declines a push from CI, which is not something a nightly cron can resolve. A branch of its own
keeps everything the original choice was for — a reviewable diff, a git history of the compat
matrix, plain anonymous raw URLs, no credentials on the shard host — and needs no protection
exception. Whitelisting a scheduled job for pushes to the default branch would buy nothing this does
not.
This directory keeps the documentation, because that is what belongs on `main`: the branch carries
data, and only data.
## How the installer fetches these
Plain anonymous `GET`s against a public repo. The shard host gets no git and no Gitea credentials
(`PLAN.md` §1), so nothing here may require auth:
```
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/bundle-2026.08.04.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/bundle-2026.08.04.json
https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/v2/rust/current.json
```
Bundles are committed rather than published as Gitea releases because this repo's *own* releases are
@@ -39,7 +72,7 @@ the installer binaries, and `/releases/latest` returns whichever release is newe
kind — interleaving the two would make "latest" intermittently resolve to a release containing no
installer binary.
## Schema
## Schema 1 (ServUO, retiring)
`schema` is the version of *this document's* shape, and is unrelated to `protocol` (the uo-link wire
protocol) or to either component's release version. All three move independently.
@@ -56,8 +89,9 @@ protocol) or to either component's release version. All three move independently
"tag": "v1.1.0",
"version": "1.1.0",
"protocol": 3,
"assets": { // per-platform: the installer runs on both
"assets": { // per-platform: the installer runs on each
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
"linux-aarch64": { "name": "…", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
}
},
@@ -90,3 +124,48 @@ rejects a protocol mismatch with `409` rather than mis-parsing, so a mismatched
emitting into a void. CI refuses to publish one: it reads `PROTOCOL_VERSION` from the sidecar's
source at its release tag and the declared `protocol` from the overlay tarball's `manifest.json`, and
fails if they differ. The top-level `protocol` is that agreed value.
## Schema 2
The same document for either game: a `game` discriminant, a `sidecar`, and a `payload` whose `kind`
says what it is. A reader switches on `game`, and refuses a `schema` it does not know.
```jsonc
{
"schema": 2,
"game": "rust", // or "servuo"
"bundle": "2026.09.27",
"generated": "2026-09-27T…Z",
"protocol": 12,
"sidecar": {
"repo": "RunicGateway/Rust-Link", "tag": "v…", "version": "…", "protocol": 12,
"assets": { // Rust: no linux-aarch64 — RustDedicated has no arm64 build
"linux-x86_64": { "name": "…", "url": "…", "sha256": "…" },
"windows-x86_64": { "name": "…", "url": "…", "sha256": "…" }
},
"launcher": { "name": "with-sidecar.sh", "url": "…", "sha256": "…" } // Rust only: the egg's startup
},
"payload": {
"kind": "plugin", // "overlay" for ServUO
"repo": "RunicGateway/Rust-Plugins", "tag": "v…", "version": "…", "commit": "…",
"protocol": 12,
"compat": { // per game; for ServUO it is schema 1's `servuo` block
"frameworks": { "oxide": { "min_version": "2.0.7585" },
"carbon": { "min_version": "2.0.259" } },
"requires_plugins": ["Kits", "ZoneManager"]
},
"asset": { "name": "runicgateway-rust-plugin-….tar.gz", "url": "…", "sha256": "…" }
}
}
```
A ServUO schema-2 document is schema 1's content re-shaped: `link` becomes `sidecar` (no
`launcher`), `overlay` becomes a `payload` of `"kind": "overlay"`, and its `servuo` block becomes
`compat` (`min_version`, `patches_verified_against`).
The two gates hold for both games. For Rust, gate 1 reads `PROTOCOL_VERSION` from `Rust-Link`'s
`sidecar/src/main.rs` at its release tag and `protocol` from the `manifest.json` inside the plugin
tarball (`runicgateway-rust-plugin/manifest.json`). The stakes are higher there than for ServUO: the
Rust game link has no `409`, so a mismatched plugin would mis-parse rather than be refused.

View File

@@ -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"
}
}
}

502
src/backup.rs Normal file
View File

@@ -0,0 +1,502 @@
//! 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 overwhelmingly a
//! projection of shard state that the sweeps repopulate. Backing it up would be bulk with little
//! recovery value, and the bulk is not free: it would bury the two things that matter.
//!
//! That reasoning used to be stated two ways that are no longer true, and the correction is worth
//! keeping rather than quietly deleting:
//!
//! - It said the database is safe because `store.rs` creates every table `IF NOT EXISTS`. That held
//! only while every schema change added a whole *table*. Protocol 4 adds a *column* to a table
//! that already exists, which `IF NOT EXISTS` cannot do, so `link` now carries a real migration
//! (`PRAGMA user_version` steps). A run can therefore change the database's structure, not just
//! its contents.
//! - It said every table holds state the sweeps repopulate. `events` does not: it is never pruned,
//! and the website backfills the events it missed from `GET /history` on every reconnect. So a
//! lost database costs the gap-recovery window for anything that happened while the site was down.
//!
//! The decision is unchanged — this still does not copy the database — because the argument against
//! backing up unbounded bulk survives both corrections: `events` grows without limit, the migration
//! is transactional and additive, and the website holds its own durable copy of everything it has
//! already ingested. Only the *reason* was wrong. Whether that table should be pruned or protected
//! is a question for `link`, on its own merits, not something to settle inside a backup policy.
//!
//! What a run can destroy irrecoverably is short:
//!
//! 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(&copy).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();
}
}

View File

@@ -14,16 +14,19 @@
//! half-understood.
use anyhow::{bail, Context, Result};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Bundles are plain files in this repo, served by Gitea's raw endpoint over anonymous HTTPS —
/// the shard host has no Gitea account and needs no git client (PLAN.md §1, §7.1).
///
/// They live on a **branch of their own**, not on `main`, and at its root. `main` is protected, so
/// the unattended compose job cannot push there — a pre-receive hook declines it, which is not
/// something a nightly cron can resolve. Everything the original choice was for survives the move:
/// a reviewable diff, a git history of the compat matrix, and a plain anonymous URL.
const BUNDLE_BASE: &str =
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles";
/// The only `schema` this build understands.
const SUPPORTED_SCHEMA: u32 = 1;
"https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Bundle {
@@ -43,8 +46,10 @@ pub struct LinkComponent {
pub tag: String,
pub version: String,
pub protocol: u32,
/// Keyed by platform (`linux-x86_64`, `windows-x86_64`) — link publishes a binary per OS and
/// the installer runs on both, so a single hash could only ever describe one of them.
/// Keyed by platform (`linux-x86_64`, `linux-aarch64`, `windows-x86_64`) — link publishes a
/// binary per target and the installer runs on each, so a single hash could only ever describe
/// one of them. The set grows over time, so a bundle is not expected to carry every key this
/// binary knows about: an older one pinned with `--bundle` predates arm64 entirely.
pub assets: BTreeMap<String, Asset>,
}
@@ -85,7 +90,10 @@ impl Bundle {
let key = platform_key()?;
self.link.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"bundle {} has no uo-link binary for {key} (it has: {})",
"bundle {} has no uo-link binary for {key} (it has: {}).\n\
Bundles published before uo-link built for this platform cannot gain one \
retroactively — they are kept unchanged so `--bundle` stays reproducible. \
Run without `--bundle` to take the current one.",
self.bundle,
self.link
.assets
@@ -102,17 +110,24 @@ impl Bundle {
pub fn platform_key() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Ok("linux-x86_64"),
// Ampere/Graviton and Pi-class hosts (PLAN.md §5.2). Linux only: the shard dials the
// sidecar out on loopback, so the pair has to be co-located, and no ServUO host is a
// Windows-on-arm box or a Mac.
("linux", "aarch64") => Ok("linux-aarch64"),
("windows", "x86_64") => Ok("windows-x86_64"),
// arm64 is not buildable today (PLAN.md §2.6) and macOS is not a target. Saying so beats
// failing later with a missing-key error that reads like a corrupt bundle.
// Naming the platforms that do exist beats failing later with a missing-key error that
// reads like a corrupt bundle.
(os, arch) => bail!(
"no Runic Gateway build exists for {os}/{arch}. \
The released components target linux-x86_64 and windows-x86_64."
The released components target linux-x86_64, linux-aarch64 and windows-x86_64."
),
}
}
/// URL of the current bundle, or of a specific one when `--bundle <tag>` pins it.
/// URL of a schema-1 bundle: the current one, or a specific one when `--bundle <tag>` pins it.
///
/// Schema 1 is ServUO only and stops being composed on 2027-01-01 (docs/modules/rust/PLAN.md
/// §34.4). This build reads it only as a fallback — see [`fetch`].
pub fn url_for(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("{BUNDLE_BASE}/bundle-{tag}.json"),
@@ -120,33 +135,137 @@ pub fn url_for(tag: Option<&str>) -> String {
}
}
/// Fetches and validates a bundle.
/// Which game a schema-2 bundle describes, and so which stream it is read from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
ServUo,
Rust,
}
impl Game {
/// The `game` discriminant in the document, and the directory under `v2/`.
pub fn as_str(self) -> &'static str {
match self {
Self::ServUo => "servuo",
Self::Rust => "rust",
}
}
}
/// The Gitea **contents** API for the `bundles` branch.
///
/// Not the `/raw/` route [`BUNDLE_BASE`] names: that one is answered by the CDN with
/// `Cache-Control: public, max-age=21600`, so a bundle read through it can be hours behind the
/// branch — an `install` run right after a release would install the release before it. The
/// contents API is `private, must-revalidate` and always answers with the branch as it is. It
/// returns the file base64-encoded inside a JSON envelope, which [`fetch_v2`] unwraps.
const CONTENTS_BASE: &str =
"https://gitea.whitlocktech.com/api/v1/repos/RunicGateway/installer/contents";
/// The file name of a bundle in its stream.
fn file_name(tag: Option<&str>) -> String {
match tag {
Some(tag) => format!("bundle-{tag}.json"),
None => "current.json".to_string(),
}
}
/// The address a person opens to read a schema-2 bundle — what `install.json` records. The same
/// document [`fetch_v2`] reads; a support question about "which bundle?" wants a link that opens.
pub fn v2_url(game: Game, tag: Option<&str>) -> String {
format!("{BUNDLE_BASE}/v2/{}/{}", game.as_str(), file_name(tag))
}
/// Fetches a schema-2 document's text from its game's stream.
fn fetch_v2(game: Game, tag: Option<&str>) -> Result<String> {
let url = format!(
"{CONTENTS_BASE}/v2/{}/{}?ref=bundles",
game.as_str(),
file_name(tag)
);
let envelope = crate::net::get_text(&url)?;
#[derive(Deserialize)]
struct Contents {
content: String,
}
let contents: Contents = serde_json::from_str(&envelope)
.context("the bundles branch answered with something that is not a file")?;
// Gitea wraps the base64 at 76 columns; the decoder wants it whole.
let packed: String = contents
.content
.chars()
.filter(|c| !c.is_whitespace())
.collect();
let bytes = base64::engine::general_purpose::STANDARD
.decode(packed)
.context("the bundle file is not valid base64")?;
String::from_utf8(bytes).context("the bundle file is not UTF-8")
}
/// Fetches and validates a ServUO bundle.
///
/// **Schema 2 first, schema 1 as the fallback** (PLAN.md §34.2.2, D147). The current bundle and any
/// pin published since schema 2 are read from `v2/servuo/`; a `--bundle <tag>` from before it exists
/// only at schema 1, and is read from the root and lifted into the same model, so a pin somebody
/// wrote down still reproduces. The fallback also covers the current bundle, which costs nothing
/// while both are composed and means this build never depends on the new stream alone.
///
/// The returned URL is the document that was actually used.
pub fn fetch(tag: Option<&str>) -> Result<(Bundle, String)> {
let url = url_for(tag);
let body = crate::net::get_text(&url).with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so check the tag \
against {BUNDLE_BASE}/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
let v2 = fetch_v2(Game::ServUo, tag);
let (body, url) = match v2 {
Ok(body) => (body, v2_url(Game::ServUo, tag)),
Err(v2_error) => {
let url = url_for(tag);
let body = crate::net::get_text(&url)
.map_err(|_| v2_error)
.with_context(|| match tag {
Some(tag) => format!(
"cannot read bundle {tag}. Every published bundle is kept forever, so \
check the tag against {BUNDLE_BASE}/v2/servuo/"
),
None => "cannot read the current bundle manifest".to_string(),
})?;
(body, url)
}
};
let bundle = parse(&body)?;
Ok((bundle, url))
}
/// Parses a bundle document and applies the checks that must hold before anything is downloaded.
/// Parses a ServUO bundle, schema 1 or 2, and applies the checks that must hold before anything is
/// downloaded.
///
/// Schema 2 is **lowered** into the schema-1 model rather than the other way round: every ServUO
/// code path, and `install.json`, already speaks it, and the two documents carry exactly the same
/// facts (`sidecar` is `link`, the `overlay` payload's `compat` is its `servuo` block). A ServUO
/// deployment therefore records the same thing whichever schema it was resolved from.
pub fn parse(body: &str) -> Result<Bundle> {
let bundle: Bundle = serde_json::from_str(body)
let head: SchemaHead = serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?;
if bundle.schema != SUPPORTED_SCHEMA {
bail!(
"bundle {} declares schema {} and this installer understands {SUPPORTED_SCHEMA}. \
let bundle: Bundle = match head.schema {
1 => serde_json::from_str(body)
.context("the bundle manifest is not in the shape this installer understands")?,
2 => {
if head.game.as_deref() != Some(Game::ServUo.as_str()) {
bail!(
"bundle {} describes game {:?}, not ServUO. Pass --game rust to install a \
Rust bundle.",
head.bundle,
head.game.unwrap_or_default()
);
}
let v2: ServUoBundleV2 = serde_json::from_str(body).context(
"the schema-2 ServUO bundle is not in the shape this installer understands",
)?;
v2.lower()
}
other => bail!(
"bundle {} declares schema {other} and this installer understands 1 and 2. \
Update the installer — the bundle format changed.",
bundle.bundle,
bundle.schema
);
}
head.bundle,
),
};
// Gate 1 already ran in CI (§7.1), where a mismatch stops a bundle from being published at all.
// Re-checking here costs nothing and covers the case CI cannot: a hand-edited or truncated
@@ -173,13 +292,213 @@ pub fn parse(body: &str) -> Result<Bundle> {
Ok(bundle)
}
/// Just enough of any bundle to decide how to read the rest of it.
#[derive(Deserialize)]
struct SchemaHead {
schema: u32,
#[serde(default)]
game: Option<String>,
#[serde(default)]
bundle: String,
}
/// A schema-2 ServUO bundle as published. Read only to be [lowered](ServUoBundleV2::lower).
#[derive(Deserialize)]
struct ServUoBundleV2 {
bundle: String,
generated: String,
protocol: u32,
sidecar: LinkComponent,
payload: OverlayPayloadV2,
}
#[derive(Deserialize)]
struct OverlayPayloadV2 {
kind: String,
repo: String,
tag: String,
version: String,
commit: String,
protocol: u32,
compat: ServUoCompat,
asset: Asset,
}
impl ServUoBundleV2 {
fn lower(self) -> Bundle {
// `kind` is informational here: `game` already said ServUO, and a ServUO payload is an
// overlay by definition. Kept in the struct so a document missing it fails to parse.
let _ = self.payload.kind;
Bundle {
schema: 2,
bundle: self.bundle,
generated: self.generated,
protocol: self.protocol,
link: self.sidecar,
overlay: OverlayComponent {
repo: self.payload.repo,
tag: self.payload.tag,
version: self.payload.version,
commit: self.payload.commit,
protocol: self.payload.protocol,
servuo: self.payload.compat,
asset: self.payload.asset,
},
}
}
}
// ── Rust ─────────────────────────────────────────────────────────────────────
/// A schema-2 Rust bundle: one exact Rust-Link release and one Rust-Plugins release, checked by CI
/// to speak the same protocol (docs/modules/rust/PLAN.md §34.2.2).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustBundle {
pub schema: u32,
pub game: String,
pub bundle: String,
pub generated: String,
pub protocol: u32,
pub sidecar: RustSidecar,
pub payload: PluginPayload,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustSidecar {
pub repo: String,
pub tag: String,
pub version: String,
pub protocol: u32,
/// `linux-x86_64` and `windows-x86_64`. No arm64: RustDedicated has no arm64 build (D149).
pub assets: BTreeMap<String, Asset>,
/// The egg's launcher. The installer does not use it — a host runs the sidecar as a service —
/// but it is part of the pair CI checked, so it is carried rather than dropped.
pub launcher: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginPayload {
pub kind: String,
pub repo: String,
pub tag: String,
pub version: String,
pub commit: String,
pub protocol: u32,
pub compat: PluginCompat,
/// One tarball: `runicgateway-rust-plugin/{RunicGateway.cs, manifest.json}`.
pub asset: Asset,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginCompat {
/// `oxide` and `carbon`, each with the oldest build the plugin is known good on. Printed by
/// `doctor` beside a failure; never measured from a DLL (§34.2.3).
pub frameworks: BTreeMap<String, FrameworkFloor>,
/// Third-party plugins the features expect. Reported, never installed (D153).
pub requires_plugins: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FrameworkFloor {
pub min_version: String,
}
impl RustBundle {
/// The sidecar binary for this host. Resolved before anything is written, like ServUO's.
pub fn sidecar_asset(&self) -> Result<&Asset> {
let key = platform_key()?;
self.sidecar.assets.get(key).ok_or_else(|| {
anyhow::anyhow!(
"Rust bundle {} has no rust-link binary for {key} (it has: {}). RustDedicated \
itself runs on linux-x86_64 and windows-x86_64 only.",
self.bundle,
self.sidecar
.assets
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)
})
}
}
/// Fetches and validates a Rust bundle: the current one, or `--bundle <tag>`.
pub fn fetch_rust(tag: Option<&str>) -> Result<(RustBundle, String)> {
let body = fetch_v2(Game::Rust, tag).with_context(|| match tag {
Some(tag) => format!(
"cannot read Rust bundle {tag}. Every published bundle is kept forever, so check the \
tag against {BUNDLE_BASE}/v2/rust/"
),
None => "cannot read the current Rust bundle. If Rust-Link or Rust-Plugins has never \
released, there is none yet."
.to_string(),
})?;
Ok((parse_rust(&body)?, v2_url(Game::Rust, tag)))
}
/// Parses a Rust bundle and applies the checks that must hold before anything is downloaded.
pub fn parse_rust(body: &str) -> Result<RustBundle> {
let head: SchemaHead = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
if head.schema != 2 {
bail!(
"Rust bundle {} declares schema {} and this installer reads Rust bundles at schema 2",
head.bundle,
head.schema
);
}
if head.game.as_deref() != Some(Game::Rust.as_str()) {
bail!(
"bundle {} describes game {:?}, not Rust",
head.bundle,
head.game.unwrap_or_default()
);
}
let bundle: RustBundle = serde_json::from_str(body)
.context("the Rust bundle is not in the shape this installer understands")?;
// Gate 1 again, for a document that never went through CI. It matters more here than for
// ServUO: the Rust game link has no 409, so a mismatched plugin mis-parses instead of being
// refused.
if bundle.sidecar.protocol != bundle.payload.protocol
|| bundle.protocol != bundle.sidecar.protocol
{
bail!(
"Rust bundle {} is internally inconsistent: bundle protocol {}, sidecar {}, plugin {}",
bundle.bundle,
bundle.protocol,
bundle.sidecar.protocol,
bundle.payload.protocol
);
}
if bundle.payload.kind != "plugin" {
bail!(
"Rust bundle {} carries a {:?} payload; this installer deploys a plugin",
bundle.bundle,
bundle.payload.kind
);
}
if bundle.payload.asset.url.is_empty() || bundle.payload.asset.sha256.is_empty() {
bail!(
"Rust bundle {} names a plugin asset with no URL or checksum",
bundle.bundle
);
}
Ok(bundle)
}
#[cfg(test)]
mod tests {
use super::*;
/// The first published bundle, verbatim from `bundles/current.json`. Using the real document
/// rather than a hand-written stand-in is the point: it is what CI actually emits.
const CURRENT: &str = include_str!("../bundles/current.json");
/// The first published bundle, verbatim. Using a real document rather than a hand-written
/// stand-in is the point: it is what CI actually emits.
///
/// It is a frozen copy rather than a live include, because published bundles moved off `main`
/// onto a branch this checkout does not carry. Frozen is the honest shape anyway — a test that
/// silently re-targeted whatever CI published last would change meaning without a commit.
const CURRENT: &str = include_str!("../tests/fixtures/published-bundle.json");
#[test]
fn the_published_bundle_parses() {
@@ -190,25 +509,132 @@ mod tests {
assert_eq!(bundle.link.version, "1.1.0");
assert_eq!(bundle.overlay.version, "0.1.1");
assert_eq!(bundle.overlay.servuo.patches_verified_against, "57.4");
assert_eq!(bundle.link.assets.len(), 2);
assert!(bundle.overlay.asset.name.ends_with(".tar.gz"));
}
#[test]
fn both_platforms_have_a_sidecar_binary() {
// Whichever of the two this test runs on, the lookup must resolve — a bundle missing the
// host's binary would fail an install after the overlay had already been deployed.
fn every_platform_the_bundle_names_is_well_formed() {
// A floor, not an exact count: `linux-aarch64` joins these from link's first arm64 release
// (PLAN.md §5.2), and a test asserting "exactly two" would fail on the bundle that adds it
// rather than on anything being wrong.
let bundle = parse(CURRENT).unwrap();
let asset = bundle.sidecar_asset().unwrap();
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
for required in ["linux-x86_64", "windows-x86_64"] {
let asset = bundle
.link
.assets
.get(required)
.unwrap_or_else(|| panic!("bundle carries no {required} binary"));
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
}
#[test]
fn the_hosts_binary_either_resolves_or_says_why_not() {
// On x86_64 the lookup must resolve — a bundle missing the host's binary would otherwise
// fail an install after the overlay had already been deployed. On a host whose platform
// postdates the bundle (an arm64 box reading the first published one), it must fail with
// the reason, since every bundle is kept unchanged forever so `--bundle` stays
// reproducible and therefore cannot gain a key retroactively.
let bundle = parse(CURRENT).unwrap();
match bundle.sidecar_asset() {
Ok(asset) => {
assert_eq!(asset.sha256.len(), 64);
assert!(asset.url.contains(&bundle.link.tag));
}
Err(e) => {
let msg = e.to_string();
assert!(msg.contains(platform_key().unwrap()), "{msg}");
assert!(msg.contains("--bundle"), "{msg}");
}
}
}
#[test]
fn the_host_is_a_platform_the_components_are_built_for() {
// `cargo test` running at all means the host is one the crate compiles on, so a refusal
// here is a build target the release workflows have not caught up with.
let key = platform_key().unwrap();
assert!(
["linux-x86_64", "linux-aarch64", "windows-x86_64"].contains(&key),
"unexpected platform key {key}"
);
}
#[test]
fn a_newer_schema_is_refused_rather_than_guessed_at() {
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 2");
let body = CURRENT.replace("\"schema\": 1", "\"schema\": 3");
let err = parse(&body).unwrap_err().to_string();
assert!(err.contains("schema 2"), "{err}");
assert!(err.contains("schema 3"), "{err}");
}
/// Published on the bundles branch as `v2/servuo/bundle-2026.09.15.json` and at the root as
/// `bundle-2026.09.15.json` — the same matrix at both schemas, under the same tag.
const V2_SERVUO: &str = include_str!("../tests/fixtures/published-bundle-v2-servuo.json");
const V1_SAME: &str = include_str!("../tests/fixtures/published-bundle-2026.09.15.json");
/// What the compose job wrote for Rust from real Rust-Link and Rust-Plugins artifacts, with
/// the mock host's URLs pointed back at Gitea.
const RUST: &str = include_str!("../tests/fixtures/rust-bundle-v2.json");
#[test]
fn a_schema_2_servuo_bundle_lowers_to_what_schema_1_says() {
// The lowering is the whole claim that a ServUO host records the same deployment whichever
// stream it read. Only `schema` and `generated` describe the document rather than the pair.
let mut v2 = parse(V2_SERVUO).unwrap();
let mut v1 = parse(V1_SAME).unwrap();
assert_eq!(v2.schema, 2);
v2.schema = 1;
v2.generated.clear();
v1.generated.clear();
assert_eq!(v2, v1);
}
#[test]
fn a_rust_bundle_is_refused_by_the_servuo_reader() {
let err = parse(RUST).unwrap_err().to_string();
assert!(err.contains("--game rust"), "{err}");
}
#[test]
fn the_rust_bundle_parses() {
let bundle = parse_rust(RUST).unwrap();
assert_eq!(bundle.game, "rust");
assert_eq!(bundle.protocol, 12);
assert_eq!(bundle.payload.kind, "plugin");
assert_eq!(
bundle.payload.compat.frameworks["carbon"].min_version,
"2.0.259"
);
assert_eq!(
bundle.payload.compat.requires_plugins,
["Kits", "ZoneManager"]
);
assert!(!bundle.sidecar.assets.contains_key("linux-aarch64"));
assert_eq!(bundle.sidecar.launcher.name, "with-sidecar.sh");
}
#[test]
fn a_servuo_bundle_is_refused_by_the_rust_reader() {
let err = parse_rust(V2_SERVUO).unwrap_err().to_string();
assert!(err.contains("not Rust"), "{err}");
let err = parse_rust(V1_SAME).unwrap_err().to_string();
assert!(err.contains("schema 1"), "{err}");
}
#[test]
fn a_protocol_disagreement_inside_a_rust_bundle_is_refused() {
let mut doc: serde_json::Value = serde_json::from_str(RUST).unwrap();
doc["payload"]["protocol"] = 11.into();
let err = parse_rust(&doc.to_string()).unwrap_err().to_string();
assert!(err.contains("internally inconsistent"), "{err}");
}
#[test]
fn schema_2_urls_name_their_game() {
assert!(v2_url(Game::Rust, None).ends_with("/v2/rust/current.json"));
assert!(
v2_url(Game::ServUo, Some("2026.09.15")).ends_with("/v2/servuo/bundle-2026.09.15.json")
);
}
#[test]

View File

@@ -56,6 +56,14 @@ pub enum PatchChoice {
No,
}
/// Which game's shard side this run is about (docs/modules/rust/PLAN.md §34.2.3). ServUO is the
/// default, so every command line written before Rust existed means exactly what it meant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Game {
ServUo,
Rust,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Run(Command),
@@ -66,6 +74,15 @@ pub enum Mode {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cli {
pub mode: Mode,
/// `--game servuo|rust`.
pub game: Game,
/// `--rust <path>`: a Rust server root (the directory holding `RustDedicated`).
pub rust: Option<String>,
/// `--server-id <id>`: names one Rust instance — its service, config, database and ports — and
/// is the plugin's `ServerId` (D148).
pub server_id: Option<String>,
/// `--web-port <port>`: the port the website reaches a Rust instance's sidecar on.
pub web_port: Option<u16>,
/// `--verify`: report every change that would be made, write nothing.
pub verify: bool,
/// `--servuo <path>`: name the ServUO root instead of detecting or prompting.
@@ -77,18 +94,25 @@ pub struct Cli {
pub patches_unsupported_servuo: bool,
/// `--host <name>`: the hostname to print in the website URLs.
pub host: Option<String>,
/// `--site-url <url>`: the site's base URL, for the Admin → Shard link.
/// `--site-url <url>`: the site's base URL, for the Admin → Shard (uo-link) link.
pub site_url: Option<String>,
/// `--yes`: assume the default answer to every prompt.
pub assume_yes: bool,
/// `--purge`: on uninstall, also delete `sidecar.toml` and `uo-link.db`.
/// `--purge`: on uninstall, also delete `sidecar.toml`, `uo-link.db`, the cached patch set
/// and every backup.
pub purge: bool,
/// `--no-backup`: do not copy what this run is about to overwrite (PLAN.md §5.3).
pub no_backup: bool,
}
impl Default for Cli {
fn default() -> Self {
Self {
mode: Mode::Help,
game: Game::ServUo,
rust: None,
server_id: None,
web_port: None,
verify: false,
servuo: None,
bundle: None,
@@ -98,25 +122,29 @@ impl Default for Cli {
site_url: None,
assume_yes: false,
purge: false,
no_backup: false,
}
}
}
pub const USAGE: &str = "\
Runic Gateway installer — connects a ServUO shard to a Runic Gateway website.
Runic Gateway installer — connects a ServUO shard, or Rust servers, to a Runic
Gateway website.
Usage: runicgateway-installer <COMMAND> [OPTIONS]
Commands:
install Deploy the plugin overlay, install the uo-link sidecar and its
service, record what was deployed, and print the values the
website needs.
install Deploy the game-side plugin (the ServUO overlay, or the Rust
plugin), install the sidecar and its service, record what was
deployed, and print the values the website needs.
doctor Diagnose an existing deployment end to end.
update Re-resolve the bundle and move both components to it.
uninstall Remove what the installer exclusively owns. Never edits the
ServUO tree — it prints what to remove there.
ServUO tree — it prints what to remove there. For Rust it
removes only its own plugin file and keeps the plugin config.
Options:
--game <servuo|rust> Which game's shard side. Default servuo.
--verify install, update. Dry run: report every
change that would be made, write nothing.
--servuo <PATH> install, doctor, update. The ServUO root,
@@ -133,13 +161,28 @@ Options:
--host <NAME> install. The hostname to print in the
website URLs.
--site-url <URL> install. Your site's base URL, for the
Admin → Shard link.
Admin → Shard (uo-link) link.
--yes Assume the default answer to every prompt.
On uninstall it means yes: that prompt
defaults to no, and typing `uninstall
--yes` is not an accident.
--purge uninstall. Also delete sidecar.toml and
uo-link.db, which are otherwise kept.
--no-backup install, update. Do not copy the files
this run is about to overwrite. They are
otherwise saved under <state>/backups/,
newest 3 kept.
--purge uninstall. Also delete sidecar.toml,
uo-link.db, the cached patch set and every
backup, all of which are otherwise kept.
Rust (with --game rust):
--rust <PATH> install. The Rust server root — the
directory holding RustDedicated. Oxide or
Carbon is detected, never asked.
--server-id <ID> install: required. doctor, uninstall:
one instance instead of all. The id the
website knows this server by, and the
name of its sidecar's service.
--web-port <PORT> install. The sidecar's website port.
Default: the first free from 8090.
-V, --version Print the installer version and exit.
-h, --help Print this help and exit.
@@ -182,6 +225,7 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
"--verify" => cli.verify = true,
"--yes" | "-y" => cli.assume_yes = true,
"--purge" => cli.purge = true,
"--no-backup" => cli.no_backup = true,
"--patches" => cli.patches = PatchChoice::Yes,
"--no-patches" => cli.patches = PatchChoice::No,
"--patches-unsupported-servuo" => cli.patches_unsupported_servuo = true,
@@ -189,6 +233,30 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
"--bundle" => cli.bundle = Some(take_value(&name, inline, &mut it)?),
"--host" => cli.host = Some(take_value(&name, inline, &mut it)?),
"--site-url" => cli.site_url = Some(take_value(&name, inline, &mut it)?),
"--game" => {
cli.game = match take_value(&name, inline, &mut it)?.as_str() {
"servuo" => Game::ServUo,
"rust" => Game::Rust,
other => return Err(format!("--game must be servuo or rust, not {other:?}")),
}
}
"--rust" => cli.rust = Some(take_value(&name, inline, &mut it)?),
"--server-id" => {
let id = take_value(&name, inline, &mut it)?;
if !valid_server_id(&id) {
return Err(format!(
"--server-id {id:?} is not a valid server id: lowercase letters, digits \
and '-', 1 to 64 characters, not starting with '-' (the website's rule)"
));
}
cli.server_id = Some(id);
}
"--web-port" => {
let raw = take_value(&name, inline, &mut it)?;
cli.web_port = Some(raw.parse::<u16>().ok().filter(|p| *p >= 1024).ok_or_else(
|| format!("--web-port must be a port from 1024 to 65535, not {raw:?}"),
)?);
}
other if other.starts_with('-') => {
return Err(format!("unrecognized argument: {other}"))
}
@@ -202,6 +270,20 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
}
}
// The Rust flags belong to `--game rust` only. Silently ignoring one on a ServUO run would leave
// an operator believing they had named a server they had not.
if cli.game == Game::ServUo {
for (flag, set) in [
("--rust", cli.rust.is_some()),
("--server-id", cli.server_id.is_some()),
("--web-port", cli.web_port.is_some()),
] {
if set {
return Err(format!("{flag} needs --game rust"));
}
}
}
match command {
Some(c) => cli.mode = Mode::Run(c),
// No verb is not an error worth an exit code — it is someone typing the binary's name to
@@ -211,6 +293,17 @@ pub fn parse<I: IntoIterator<Item = String>>(args: I) -> Result<Cli, String> {
Ok(cli)
}
/// The website's rule for a server id, `^[a-z0-9][a-z0-9-]{0,63}$`. The same rule the plugin and the
/// egg apply, so an id that passes here is one all three accept.
pub fn valid_server_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& !id.starts_with('-')
&& id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
/// Pulls a flag's value, from `--flag=value` or from the next token.
///
/// A missing value is an error rather than a default: `--servuo` with nothing after it would
@@ -329,6 +422,50 @@ mod tests {
assert!(parse_str(&["install", "update"]).is_err());
}
#[test]
fn servuo_stays_the_default_game() {
assert_eq!(parse_str(&["install"]).unwrap().game, Game::ServUo);
let rust = parse_str(&[
"install",
"--game",
"rust",
"--rust",
"/srv/rust",
"--server-id",
"alpha",
"--web-port=8091",
])
.unwrap();
assert_eq!(rust.game, Game::Rust);
assert_eq!(rust.server_id.as_deref(), Some("alpha"));
assert_eq!(rust.web_port, Some(8091));
}
#[test]
fn a_rust_flag_without_game_rust_is_refused() {
for args in [
vec!["install", "--rust", "/srv/rust"],
vec!["doctor", "--server-id", "alpha"],
vec!["install", "--web-port", "8091"],
] {
let err = parse_str(&args).unwrap_err();
assert!(err.contains("--game rust"), "{args:?}: {err}");
}
}
#[test]
fn a_server_id_follows_the_websites_rule() {
for good in ["main", "alpha", "eu-2", "0", &"a".repeat(64)] {
assert!(valid_server_id(good), "{good}");
}
for bad in ["", "-a", "Alpha", "a_b", "a.b", &"a".repeat(65)] {
assert!(!valid_server_id(bad), "{bad}");
}
assert!(parse_str(&["install", "--game", "rust", "--server-id", "Bad"]).is_err());
assert!(parse_str(&["install", "--game", "rust", "--web-port", "80"]).is_err());
assert!(parse_str(&["install", "--game", "minecraft"]).is_err());
}
#[test]
fn order_does_not_matter() {
let a = parse_str(&["--verify", "install", "--yes"]).unwrap();

View File

@@ -68,7 +68,7 @@ pub struct Row {
}
impl Row {
fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn new(mark: Mark, label: &str, detail: impl Into<String>) -> Self {
Self {
mark,
label: label.to_string(),
@@ -77,24 +77,24 @@ impl Row {
}
}
fn ok(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn ok(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Ok, label, detail)
}
fn warn(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn warn(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Warn, label, detail)
}
fn fail(label: &str, detail: impl Into<String>) -> Self {
pub(crate) fn fail(label: &str, detail: impl Into<String>) -> Self {
Self::new(Mark::Fail, label, detail)
}
fn note(mut self, note: impl Into<String>) -> Self {
pub(crate) fn note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
pub(crate) fn notes_from(mut self, notes: impl IntoIterator<Item = String>) -> Self {
self.notes.extend(notes);
self
}
@@ -158,9 +158,22 @@ pub fn run(cli: &Cli) -> Result<i32> {
// ── The bundle ───────────────────────────────────────────────────────────
rows.push(bundle_row(&record));
// ── The host ─────────────────────────────────────────────────────────────
// Linux only, and absent entirely elsewhere (see `imaging_row`).
rows.extend(imaging_row());
// ── Backups ──────────────────────────────────────────────────────────────
rows.push(backup_row(&layout));
// ── Report ───────────────────────────────────────────────────────────────
Ok(report(&rows))
}
/// Prints the rows and the one-line verdict, and returns the exit code: `1` when anything failed.
/// Shared with `doctor --game rust`, so both games' reports read the same way.
pub(crate) fn report(rows: &[Row]) -> i32 {
println!();
for row in &rows {
for row in rows {
println!("{} {:<24} {}", row.mark.glyph(), row.label, row.detail);
for note in &row.notes {
println!(" {note}");
@@ -178,7 +191,11 @@ pub fn run(cli: &Cli) -> Result<i32> {
INSTALL.md's Troubleshooting table is keyed to these symptoms."
),
}
Ok(if failed > 0 { 1 } else { 0 })
if failed > 0 {
1
} else {
0
}
}
/// The ServUO root to inspect: `--servuo` if given, else the one the record names.
@@ -541,7 +558,7 @@ fn live_config(link: &LinkRecord) -> Option<sidecar::ConfigDoc> {
///
/// Always over loopback, never over the configured bind: `[web] bind` is regularly `0.0.0.0`, and
/// this check is about whether the process on *this* host is answering.
fn health_of(bind: &str) -> Option<Health> {
pub(crate) fn health_of(bind: &str) -> Option<Health> {
let port = bind.rsplit_once(':').map(|(_, p)| p).unwrap_or(bind);
let url = format!("http://127.0.0.1:{port}/health");
let body = net::get_text_within(&url, HEALTH_TIMEOUT).ok()?;
@@ -673,6 +690,35 @@ fn shard_row(root: Result<&ServUoRoot, &anyhow::Error>, health: Option<&Health>)
/// Offline is a `⚠`, never a `✗`. A shard host with no outbound route to Gitea is a supported way
/// to run this — the operator downloads artifacts elsewhere — and failing a health check over it
/// would report a working deployment as broken.
/// The most recent backup, so "can I go back?" is answerable without knowing the layout.
///
/// Always `✓`, never a failure: having no backup is the correct state on a host that has never
/// overwritten anything, and a shard that is running fine does not become broken because nothing
/// has displaced a file yet.
fn backup_row(layout: &paths::Layout) -> Row {
let backups = crate::backup::list(layout);
let Some(newest) = backups.first() else {
return Row::ok("Backups", "none taken — no run has replaced a file yet");
};
let detail = match crate::backup::read_manifest(newest) {
Ok(manifest) => format!(
"{} — {} file(s) replaced by {} to bundle {}",
manifest.taken,
manifest.files.len(),
manifest.command,
manifest.bundle_to
),
// A directory with an unreadable manifest is still a directory of the operator's files, so
// it is reported rather than skipped.
Err(_) => format!("{} — manifest unreadable", newest.display()),
};
Row::ok("Backups", detail).note(format!(
"{} kept in {}",
backups.len(),
layout.backups_dir().display()
))
}
fn bundle_row(record: &InstallRecord) -> Row {
let url = bundle::url_for(None);
let current = match net::get_text_within(&url, BUNDLE_TIMEOUT).and_then(|b| bundle::parse(&b)) {
@@ -723,6 +769,71 @@ fn bundle_row(record: &InstallRecord) -> Row {
Row::warn("Bundle", detail).note("run `update` to move both halves to one checked combination")
}
/// `libgdiplus` on a Linux shard host — the one host prerequisite the Asset Bridge added
/// (docs/link/v8.md §4.4, docs/link/SHARD_PREREQS.md).
///
/// ServUO targets `net48`, so on Linux it runs under Mono, and Mono's `System.Drawing` is a thin
/// layer over this library — which sits in the **decode** path, not merely the encode: without it
/// the shard cannot read a single sprite out of the operator's UO client. Windows hosts ship
/// `System.Drawing` with .NET Framework and need nothing, which is why this row exists only on
/// Linux rather than reporting "not applicable" on three quarters of the hosts that run it.
///
/// **A `⚠`, never a `✗`.** Everything else on this plane works without it: the cliloc table and
/// the shard's own spawn files have no pixels in them, and a bridge that serves names and an atlas
/// but no artwork is a working bridge with one feature missing. It is also not the last word — the
/// shard reports `NO_IMAGING` on the asset plane itself, from inside the process that would do the
/// decoding. This row exists to move that discovery from "the bestiary is empty, weeks later" to
/// "the host is missing a package, now".
#[cfg(target_os = "linux")]
fn imaging_row() -> Option<Row> {
Some(imaging_verdict(imaging_present()))
}
/// Is the library on this host? Two answers, in the order that is most likely to be right.
#[cfg(target_os = "linux")]
fn imaging_present() -> bool {
// `ldconfig -p` is the loader's own cache, which is the same question Mono asks at runtime —
// strictly better than probing paths, because a distro that puts the file somewhere unusual has
// told the loader about it and would otherwise read here as missing.
let cached = crate::util::run("ldconfig", &["-p"])
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("libgdiplus.so"))
.unwrap_or(false);
// The fallback is for a host with no `ldconfig` on PATH (a slim container, mostly), where a
// present library would otherwise be reported absent.
cached
|| [
"/usr/lib/libgdiplus.so",
"/usr/lib64/libgdiplus.so",
"/usr/lib/x86_64-linux-gnu/libgdiplus.so",
"/usr/lib/aarch64-linux-gnu/libgdiplus.so",
"/usr/local/lib/libgdiplus.so",
]
.iter()
.any(|p| Path::new(p).exists())
}
/// The operator-visible half, split out so the wording and the mark are testable on a host that
/// has the library and on one that does not — which the detection itself is not.
#[cfg(target_os = "linux")]
fn imaging_verdict(found: bool) -> Row {
if found {
return Row::ok("Imaging (libgdiplus)", "present");
}
Row::warn("Imaging (libgdiplus)", "not found on this host")
.note("this shard cannot decode artwork out of its UO client — creature portraits and")
.note("item pictures will be absent; names and the spawn atlas are unaffected")
.note("install it: apt-get install libgdiplus / dnf install libgdiplus")
.note("see docs/link/SHARD_PREREQS.md — Windows hosts need nothing")
}
/// Windows and macOS hosts do not need it, so there is no row to print.
#[cfg(not(target_os = "linux"))]
fn imaging_row() -> Option<Row> {
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -881,4 +992,39 @@ mod tests {
assert_eq!(row.mark, Mark::Ok);
assert!(row.detail.contains("operator-owned"), "{}", row.detail);
}
// ── The host row (Linux only; see `imaging_row`) ─────────────────────────
#[cfg(target_os = "linux")]
#[test]
fn a_missing_libgdiplus_warns_and_names_the_package() {
let row = imaging_verdict(false);
// A ⚠, never a ✗: the cliloc table and the spawn atlas have no pixels in them, so a host
// without this library still runs a useful bridge. `doctor`'s exit code must not turn red
// over one absent feature.
assert_eq!(row.mark, Mark::Warn);
let notes = row.notes.join(" ");
assert!(notes.contains("apt-get install libgdiplus"), "{notes}");
assert!(notes.contains("SHARD_PREREQS.md"), "{notes}");
}
#[cfg(target_os = "linux")]
#[test]
fn a_present_libgdiplus_is_one_quiet_ok_line() {
let row = imaging_verdict(true);
assert_eq!(row.mark, Mark::Ok);
assert!(
row.notes.is_empty(),
"a satisfied prerequisite needs no advice"
);
}
#[cfg(target_os = "linux")]
#[test]
fn detection_answers_rather_than_panicking_on_a_host_with_no_ldconfig() {
// The value depends on the host and is not asserted — what is asserted is that a missing
// `ldconfig` degrades to the path probe instead of taking `doctor` down, which is the rule
// every row in this module follows.
let _ = imaging_present();
}
}

View File

@@ -38,7 +38,7 @@ use crate::record::{
};
use crate::servuo::ServUoRoot;
use crate::util::TempDir;
use crate::{bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
use crate::{backup, bundle, net, overlay, paths, service, servuo, sidecar, tier, ui};
/// Which verb is driving the pipeline.
///
@@ -185,6 +185,23 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let planned = overlay::plan(&unpacked, &root.path, prior_files)?;
let summary = overlay::summarize(&planned);
// ── Backup ───────────────────────────────────────────────────────────────
// Created before the first write and handed to every stage that overwrites, so each copy is
// taken while the file is still the operator's (PLAN.md §5.3). The directory is created lazily:
// a run that displaces nothing leaves nothing behind.
let mut backup = backup::Session::new(
&layout,
&root.path,
if mode.is_update() {
"update"
} else {
"install"
},
prior.as_ref().map(|p| p.bundle.tag.clone()),
bundle.bundle.clone(),
!cli.verify && !cli.no_backup,
);
ui::heading("Overlay sync");
let lines = overlay::render(&planned);
if lines.is_empty() {
@@ -200,6 +217,15 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
summary.add, summary.change, summary.unchanged, summary.kept
);
} else {
// `Change` only. An `Add` has nothing underneath it, `Unchanged` is byte-identical to what
// would replace it, and `KeptOperatorModified` is not written at all — copying those three
// would bury the files that are actually being displaced.
for file in planned
.iter()
.filter(|f| f.action == overlay::Action::Change)
{
backup.capture(&file.dst, backup::Reason::OverlayChange)?;
}
overlay::apply(&planned)?;
// "deployed" is claimed only when something actually moved. A run that copied nothing
// reporting "deployed" would read as a fresh install to anyone skimming the output.
@@ -246,8 +272,18 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
.as_ref()
.map(|p| p.patch_records())
.unwrap_or_default(),
&mut backup,
)?;
// The config joins a backup that is already being taken; it is never the reason for one. The
// installer never rewrites `sidecar.toml`, so nothing here displaces it — it is copied so that
// a restored set of files comes with the token that matches them, rather than an operator
// restoring a tree and then finding the website pointed at a token that has moved on.
if backup.has_entries() {
backup.capture(&layout.sidecar_config(), backup::Reason::SidecarConfig)?;
}
let backup_dir = backup.finish()?;
// ── The sidecar and its service ──────────────────────────────────────────
let sidecar = install_sidecar(
cli,
@@ -294,6 +330,19 @@ pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
}
}
// Named after the record rather than at the moment it was taken, because that is where an
// operator looks when a run has finished and something is wrong. Restoring is theirs to do:
// the installer cannot know what has changed since, and putting an old `.cs` file back over a
// newer overlay eats work rather than saving it.
if let Some(dir) = &backup_dir {
println!("\n Backed up {}", dir.display());
println!(
" the files this run replaced, with a manifest naming each one.\n\
\x20 The newest {} backups are kept; `uninstall --purge` removes them.",
backup::KEEP
);
}
// ── Closing notes ────────────────────────────────────────────────────────
println!();
if cli.verify {
@@ -527,8 +576,9 @@ fn install_sidecar(
layout.relocated,
)?;
let outcome = service::register(&prepared, layout, action.writes())?;
let mut outcome = service::register(&prepared, layout, action.writes())?;
service::grant_service_access(&config_path, &layout.data_dir, &outcome)?;
service::start_registered(&mut outcome, &layout.sidecar_bin, &config_path)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
@@ -645,7 +695,7 @@ fn preflight_writable(layout: &paths::Layout) -> Result<()> {
/// Only ever printed, never connected to — which is why a non-interactive run falls back to the
/// detected name instead of failing. Getting it wrong costs the operator one edit in Admin → Shard;
/// aborting a completed install over an unanswerable prompt costs them the whole run.
fn resolve_host(cli: &Cli) -> String {
pub(crate) fn resolve_host(cli: &Cli) -> String {
if let Some(host) = &cli.host {
return host.clone();
}

View File

@@ -9,6 +9,12 @@
//! `install.json`, the uo-link sidecar and its service, the token handoff, and the day-two
//! commands `doctor`, `update` and `uninstall`.
//!
//! **`--game rust`** (module-rust phase 18, docs/modules/rust/PLAN.md §34) runs the same four verbs
//! for Rust servers, in [`rustgame`]: schema-2 Rust bundles, named instances, a Rust-Link sidecar
//! and service per instance, and the plugin placed for Oxide or Carbon. ServUO's pipeline, files
//! and record are untouched by it; ServUO bundles are now read at schema 2 with schema 1 as the
//! fallback ([`bundle::fetch`]).
//!
//! Exit codes: `0` success, `1` the run failed, `2` the arguments were unusable — the same
//! convention as the sidecar's CLI. `doctor` additionally uses `1` for a *completed* run that
//! found something broken, so it can be read by a monitoring script; a `⚠` row never does that.
@@ -28,6 +34,7 @@
//! PLAN.md §3, and `[[bin]] test = false` keeps Cargo from building a harness under the triggering
//! name. Nothing an operator sees changes.
pub mod backup;
pub mod bundle;
pub mod cli;
pub mod diff;
@@ -38,6 +45,7 @@ pub mod overlay;
pub mod patch;
pub mod paths;
pub mod record;
pub mod rustgame;
pub mod service;
pub mod servuo;
pub mod sidecar;
@@ -75,6 +83,7 @@ pub fn run() -> i32 {
println!("runicgateway-installer {}", env!("CARGO_PKG_VERSION"));
Ok(0)
}
Mode::Run(command) if parsed.game == cli::Game::Rust => rustgame::run(&parsed, command),
Mode::Run(Command::Install) => install::run(&parsed).map(|()| 0),
Mode::Run(Command::Update) => update::run(&parsed).map(|()| 0),
Mode::Run(Command::Doctor) => doctor::run(&parsed),

View File

@@ -29,6 +29,9 @@ pub struct Layout {
pub data_dir: PathBuf,
/// `/usr/bin/runicgateway-link` — the installed sidecar binary.
pub sidecar_bin: PathBuf,
/// `/usr/bin/runicgateway-rust-link` — the rust-link sidecar, one binary for every Rust
/// instance on the host (docs/modules/rust/PLAN.md §34.2.3).
pub rust_sidecar_bin: PathBuf,
/// This layout came from [`STATE_DIR_ENV`], so it describes a test run rather than a real
/// deployment. Service registration is skipped when it is set — see [`layout`].
pub relocated: bool,
@@ -68,11 +71,68 @@ impl Layout {
self.patches_dir().join("originals")
}
/// `/etc/runicgateway/backups` — one dated directory per run that overwrote something
/// (PLAN.md §5.3).
///
/// Beside the cached patch set rather than inside it: both survive an uninstall and both go
/// with `--purge`, but a backup is a copy of what *this host* had, while `patches/` is a copy
/// of what the *release* shipped.
pub fn backups_dir(&self) -> PathBuf {
self.state_dir.join("backups")
}
/// The unit file a systemd host gets. Meaningless elsewhere, and unused under a relocated
/// layout, where no service is registered at all.
pub fn systemd_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::SYSTEMD_UNIT)
}
// ── Rust ─────────────────────────────────────────────────────────────────
//
// Rust's files live under a `rust/` of their own, beside ServUO's rather than among them, so a
// host running both games has two records that cannot corrupt each other (§34.4) and an
// uninstall of one never reaches the other.
/// `/etc/runicgateway/rust/install.json` — the Rust record: the bundle and every instance.
pub fn rust_record(&self) -> PathBuf {
self.state_dir.join("rust").join("install.json")
}
/// Where instance configs live on Linux — the directory the template unit names with `%i`.
pub fn rust_config_dir(&self) -> PathBuf {
self.state_dir.join("rust")
}
/// One instance's `sidecar.toml`.
///
/// **Linux:** `/etc/runicgateway/rust/<id>.toml`, beside its siblings, because the template
/// unit derives the path from the instance name. **Windows:** inside the instance's own
/// directory, because a Windows service logs beside its config — two instances sharing a
/// directory would share, and fight over, one log file.
pub fn rust_config(&self, server_id: &str) -> PathBuf {
if cfg!(windows) {
self.rust_data_dir(server_id).join("sidecar.toml")
} else {
self.rust_config_dir().join(format!("{server_id}.toml"))
}
}
/// `/var/lib/runicgateway/rust/<id>` — one instance's store (and, on Windows, its config and
/// logs).
pub fn rust_data_dir(&self, server_id: &str) -> PathBuf {
self.data_dir.join("rust").join(server_id)
}
/// One instance's SQLite store. Written into its config as an absolute path, so the store never
/// depends on anybody's working directory or on a shared default name.
pub fn rust_db(&self, server_id: &str) -> PathBuf {
self.rust_data_dir(server_id).join("rust-link.db")
}
/// The shared systemd template for every Rust instance.
pub fn rust_template_unit(&self) -> PathBuf {
PathBuf::from("/etc/systemd/system").join(crate::service::RUST_TEMPLATE_UNIT)
}
}
/// Resolves the layout for this platform, honouring [`STATE_DIR_ENV`].
@@ -97,8 +157,14 @@ pub fn layout() -> Layout {
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("uo-link-sidecar"));
let rust_bin_name = layout
.rust_sidecar_bin
.file_name()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("rust-link-sidecar"));
layout.data_dir = root.join("data");
layout.sidecar_bin = root.join("bin").join(bin_name);
layout.rust_sidecar_bin = root.join("bin").join(rust_bin_name);
layout.state_dir = root;
layout.relocated = true;
}
@@ -124,6 +190,9 @@ fn platform_layout() -> Layout {
sidecar_bin: program_files
.join("RunicGateway")
.join("uo-link-sidecar.exe"),
rust_sidecar_bin: program_files
.join("RunicGateway")
.join("rust-link-sidecar.exe"),
relocated: false,
}
}
@@ -134,6 +203,7 @@ fn platform_layout() -> Layout {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: false,
}
}
@@ -175,6 +245,25 @@ mod tests {
}
}
#[test]
fn two_rust_instances_share_nothing_but_the_binary() {
// The failure this prevents: two instances on one database, one token file, or (on
// Windows) one log file.
let l = platform_layout();
assert_ne!(l.rust_config("alpha"), l.rust_config("beta"));
assert_ne!(l.rust_db("alpha"), l.rust_db("beta"));
// A Windows service logs beside its config, so there each instance needs its own directory.
if cfg!(windows) {
assert_ne!(
l.rust_config("alpha").parent(),
l.rust_config("beta").parent()
);
}
assert_ne!(l.rust_record(), l.install_record());
assert_ne!(l.rust_sidecar_bin, l.sidecar_bin);
assert!(l.rust_db("alpha").is_absolute());
}
#[test]
fn a_relocated_layout_moves_the_binary_too() {
// The failure this prevents: a test run that writes its config and database under the

297
src/rustgame/doctor.rs Normal file
View File

@@ -0,0 +1,297 @@
//! `doctor --game rust` — every recorded instance, or one with `--server-id`.
//!
//! The same rule as ServUO's doctor: every row asks the host what is true now, and the record only
//! says where to look. Per instance (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - the framework, and whether the plugin file is still the one deployed;
//! - that the plugin's config names this instance;
//! - `requires_plugins` present, as a warning (D153);
//! - the service registered and running;
//! - `/health`: reachable, the protocol, and whether the plugin is connected.
//!
//! **The framework version floor is not measured from files.** A plugin that compiled and connected
//! on this framework is the fact a floor only approximates, so a connected plugin is the pass, and
//! the floors are printed beside a plugin that is not.
use std::path::Path;
use anyhow::Result;
use super::plugin;
use super::record::{Instance, RustRecord};
use super::server;
use crate::cli::Cli;
use crate::doctor::{health_of, report, Row};
use crate::{bundle, paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — doctor (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"No Rust deployment is recorded on this host.\n Looked for {}\n Run `install --game \
rust` first. If you installed with {} set, set it again for this run.",
record_path.display(),
paths::STATE_DIR_ENV
));
return Ok(1);
};
let mut rows = vec![Row::ok(
"Rust record",
format!(
"{} (bundle {}, {} instance(s), installer {})",
record_path.display(),
record.bundle.tag,
record.instances.len(),
record.installer.version
),
)];
let selected: Vec<(&String, &Instance)> = match &cli.server_id {
Some(id) => match record.instances.get_key_value(id) {
Some(pair) => vec![pair],
None => {
rows.push(Row::fail(
"Instance",
format!(
"{id:?} is not recorded (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
));
return Ok(report(&rows));
}
},
None => record.instances.iter().collect(),
};
// The floors, fetched once, for the note beside a plugin that has not connected. Read from the
// installed bundle rather than the current one: they describe the plugin that is installed.
let installed_bundle = bundle::fetch_rust(Some(&record.bundle.tag)).ok();
for (id, instance) in selected {
rows.extend(instance_rows(
id,
instance,
&record,
installed_bundle.as_ref().map(|b| &b.0),
));
}
rows.push(bundle_row(&record));
Ok(report(&rows))
}
fn instance_rows(
id: &str,
instance: &Instance,
record: &RustRecord,
bundle: Option<&bundle::RustBundle>,
) -> Vec<Row> {
let mut rows = Vec::new();
let label = |what: &str| format!("{id}: {what}");
// ── The server and its framework ─────────────────────────────────────────
let server = server::open(Path::new(&instance.server_root));
match &server {
Ok(s) if s.framework.as_str() == instance.framework => rows.push(Row::ok(
&label("server"),
format!("{} ({})", s.path.display(), instance.framework),
)),
Ok(s) => rows.push(
Row::fail(
&label("server"),
format!(
"{} now runs {}, but the plugin was installed for {}",
s.path.display(),
s.framework.as_str(),
instance.framework
),
)
.note(format!(
"reinstall it: install --game rust --server-id {id} --rust {}",
s.path.display()
)),
),
Err(e) => rows.push(Row::fail(&label("server"), e.to_string())),
}
// ── The plugin file: ours ────────────────────────────────────────────────
let plugin_path = Path::new(&instance.plugin_path);
match std::fs::read(plugin_path) {
Ok(bytes) if crate::util::sha256_bytes(&bytes) == instance.plugin_sha256 => {
rows.push(Row::ok(
&label("plugin"),
format!(
"{} (Rust-Plugins {})",
instance.plugin_path, record.plugin.tag
),
))
}
Ok(_) => rows.push(
Row::fail(
&label("plugin"),
format!("{} is not the file that was deployed", instance.plugin_path),
)
.note("edited or replaced by hand; `update --game rust` puts the released one back"),
),
Err(_) => rows.push(Row::fail(
&label("plugin"),
format!("{} is missing", instance.plugin_path),
)),
}
// ── The plugin's config: the website's ───────────────────────────────────
match plugin::read_config(Path::new(&instance.plugin_config)) {
Ok(Some(view)) if view.server_id.as_deref() == Some(id) => rows.push(Row::ok(
&label("plugin config"),
format!(
"ServerId {id}, Port {}",
view.port
.map(|p| p.to_string())
.unwrap_or_else(|| "default".into())
),
)),
Ok(Some(view)) => rows.push(Row::fail(
&label("plugin config"),
format!(
"{} says ServerId {:?}; this instance is {id:?}",
instance.plugin_config,
view.server_id.unwrap_or_else(|| "main (default)".into())
),
)),
Ok(None) => rows.push(Row::warn(
&label("plugin config"),
format!(
"{} is gone — the plugin will write one with ServerId \"main\" on its next load",
instance.plugin_config
),
)),
Err(e) => rows.push(Row::fail(&label("plugin config"), e.to_string())),
}
// ── Third-party plugins: reported, never installed (D153) ────────────────
if let (Ok(s), Some(b)) = (&server, bundle) {
let missing = plugin::missing_plugins(&s.plugins_dir(), &b.payload.compat.requires_plugins);
rows.push(if missing.is_empty() {
Row::ok(
&label("required plugins"),
b.payload.compat.requires_plugins.join(", "),
)
} else {
Row::warn(
&label("required plugins"),
format!(
"missing {} in {}",
missing.join(", "),
s.plugins_dir().display()
),
)
.note("install from uMod; the features that use them stay off until then")
});
}
// ── The service ──────────────────────────────────────────────────────────
match &instance.service {
Some(svc) => {
let status = service::observe(&svc.kind, &svc.name);
let detail = format!("{} {}", svc.name, status.detail);
rows.push(if status.running && status.enabled {
Row::ok(&label("service"), detail)
} else {
Row::fail(&label("service"), detail)
});
}
None => rows.push(Row::fail(
&label("service"),
"none registered — the install printed the steps to register it by hand",
)),
}
// ── The sidecar, and the plugin through it ───────────────────────────────
let health = health_of(&format!("127.0.0.1:{}", instance.web_port));
let Some(health) = health else {
rows.push(Row::fail(
&label("sidecar"),
format!(
"nothing answered http://127.0.0.1:{}/health",
instance.web_port
),
));
return rows;
};
rows.push(Row::ok(
&label("sidecar"),
format!(
"127.0.0.1:{} /health {}, database {}",
instance.web_port,
health.status.as_deref().unwrap_or("ok"),
health.database.as_deref().unwrap_or("unknown")
),
));
match health.protocol {
Some(p) if p == record.bundle.protocol => {
rows.push(Row::ok(&label("protocol"), p.to_string()))
}
Some(p) => rows.push(Row::fail(
&label("protocol"),
format!(
"the sidecar speaks {p}; bundle {} is {}",
record.bundle.tag, record.bundle.protocol
),
)),
None => rows.push(Row::warn(&label("protocol"), "the sidecar did not say")),
}
let floors = bundle
.map(|b| {
b.payload
.compat
.frameworks
.iter()
.map(|(f, v)| format!("{f} {}", v.min_version))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let running = server.as_ref().is_ok_and(|s| server::is_running(&s.path));
rows.push(match (health.plugin_connected, running) {
(Some(true), _) => Row::ok(&label("plugin connected"), "yes"),
(_, false) => Row::warn(&label("plugin connected"), "no — the server is not running"),
(_, true) => {
Row::fail(&label("plugin connected"), "no, and the server is running").note(format!(
"the plugin has not dialled 127.0.0.1:{}. Check the server console for a compile \
error; the plugin is known good on {floors}",
instance.game_port
))
}
});
rows
}
/// Whether a newer Rust bundle than the installed one is published.
fn bundle_row(record: &RustRecord) -> Row {
match bundle::fetch_rust(None) {
Ok((current, _)) if current.bundle == record.bundle.tag => {
Row::ok("Bundle", format!("{} is current", record.bundle.tag))
}
Ok((current, _)) => Row::warn(
"Bundle",
format!(
"{} is installed; {} is published",
record.bundle.tag, current.bundle
),
)
.note("update --game rust moves every instance to it"),
Err(e) => Row::warn("Bundle", format!("could not check for a newer one: {e}")),
}
}

777
src/rustgame/install.rs Normal file
View File

@@ -0,0 +1,777 @@
//! `install --game rust` and `update --game rust` — one pipeline, as for ServUO.
//!
//! docs/modules/rust/PLAN.md §34.2.3. What an instance is: one Rust server root, its plugin, and a
//! sidecar of its own — service, config, database, game port and web port — named by `--server-id`
//! (D148). What instances share: the one sidecar binary and the one bundle, so `update` moves every
//! instance on the host together (§34.4).
//!
//! The order follows ServUO's, for the same reasons:
//!
//! 1. **Resolve everything that can fail cheaply first** — the bundle, this host's binary, the
//! server root and its framework, the plugin's config and the ports — so a run that cannot
//! finish ends before anything is written.
//! 2. **The sidecar before the plugin.** Both frameworks load a plugin file the moment it lands,
//! even on a running server (§34.2.3), so the sidecar it dials is registered first.
//! 3. **Provision the config before registering the service**, record last, handoff after that.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use super::plugin::{self, ConfigView};
use super::record::{ComponentRecord, Instance, RustRecord, SCHEMA};
use super::server::{self, RustServer};
use super::sidecar;
use crate::bundle::{self, RustBundle};
use crate::cli::Cli;
use crate::install::Mode;
use crate::record::{now_rfc3339, BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::sidecar::BinaryAction;
use crate::util::{write_atomic, TempDir};
use crate::{paths, service, ui};
/// The plugin's own default game port, and where the search for a free one starts.
const GAME_PORT_START: u16 = 7799;
/// Where the search for a free web port starts. Clear of ServUO's uo-link default (8080).
const WEB_PORT_START: u16 = 8090;
/// One instance this run is about, with every decision made and nothing written.
struct Planned {
id: String,
server: RustServer,
running: bool,
plugin_config: Option<ConfigView>,
plugin_action: BinaryAction,
game_port: u16,
web_port: u16,
config_path: PathBuf,
db_path: PathBuf,
config_exists: bool,
}
pub fn deploy(cli: &Cli, mode: Mode) -> Result<()> {
let layout = paths::layout();
let record_path = layout.rust_record();
let prior = RustRecord::load(&record_path)?;
// ── Which instances ──────────────────────────────────────────────────────
let targets: Vec<(String, PathBuf)> = match mode {
Mode::Install => {
let id = cli.server_id.clone().ok_or_else(|| {
anyhow::anyhow!(
"install --game rust needs --server-id <id>: the id the website will know this \
server by, which also names its sidecar. For example:\n \
runicgateway-installer install --game rust --rust /srv/rust --server-id main"
)
})?;
let recorded = prior
.as_ref()
.and_then(|p| p.instances.get(&id))
.map(|i| PathBuf::from(&i.server_root));
let root = match (&cli.rust, recorded) {
(Some(path), _) => PathBuf::from(path),
(None, Some(root)) => root,
(None, None) => bail!(
"install --game rust needs --rust <path>: the Rust server root, the directory \
holding RustDedicated"
),
};
vec![(id, root)]
}
Mode::Update => {
if cli.server_id.is_some() || cli.rust.is_some() {
bail!(
"update --game rust moves every Rust instance on this host together — they \
share one sidecar binary, and a host whose instances spoke two protocols is one \
no bundle describes. Run it without --server-id or --rust."
);
}
let Some(prior) = prior.as_ref() else {
bail!(
"nothing to update — no Rust deployment is recorded on this host (looked for {}). \
Run `install --game rust` first.",
record_path.display()
);
};
prior
.instances
.iter()
.map(|(id, i)| (id.clone(), PathBuf::from(&i.server_root)))
.collect()
}
};
// ── What to install ──────────────────────────────────────────────────────
let (bundle, bundle_url) = bundle::fetch_rust(cli.bundle.as_deref())?;
let asset = bundle.sidecar_asset()?.clone();
println!(
"\nRunic Gateway installer {} — Rust {} to bundle {} (protocol {}){}",
env!("CARGO_PKG_VERSION"),
if mode.is_update() {
"update"
} else {
"install"
},
bundle.bundle,
bundle.protocol,
if cli.verify {
" [--verify: nothing will be written]"
} else {
""
}
);
println!();
ui::row(
"Sidecar",
&format!(
"{:<24} protocol {}",
format!("rust-link {}", bundle.sidecar.tag),
bundle.sidecar.protocol
),
);
ui::row(
"Plugin",
&format!(
"{:<24} protocol {}",
format!("Rust-Plugins {}", bundle.payload.tag),
bundle.payload.protocol
),
);
// The plugin is fetched before anything is planned: its manifest is the last statement of the
// protocol to check, and a pair that disagrees must stop the run before any file moves.
let scratch = TempDir::new("runicgateway-rust")?;
let tarball = scratch.path().join(&bundle.payload.asset.name);
crate::net::download_verified(
&bundle.payload.asset.url,
&tarball,
&bundle.payload.asset.sha256,
)?;
let released = plugin::read_tarball(&tarball)?;
if released.manifest.version != bundle.payload.version {
ui::warn(&format!(
"the plugin tarball says version {} but bundle {} names {}. The checksum matched, so \
this is a labelling mismatch in the release rather than a wrong download.",
released.manifest.version, bundle.bundle, bundle.payload.version
));
}
if released.manifest.protocol != bundle.protocol {
bail!(
"the plugin in bundle {} declares protocol {}, and the bundle {}. The Rust game link \
has no version handshake, so a mismatched plugin mis-parses rather than being \
refused — refusing the pair here.",
bundle.bundle,
released.manifest.protocol,
bundle.protocol
);
}
// ── Plan every instance ──────────────────────────────────────────────────
let mut planned = Vec::new();
for (id, root) in &targets {
planned.push(plan_instance(
cli,
&layout,
prior.as_ref(),
id,
root,
&released,
)?);
}
let binary_action = crate::sidecar::decide(&asset, &layout.rust_sidecar_bin)?;
print_plan(&layout, &planned, binary_action, &bundle);
if cli.verify {
println!(
"\nNothing was written. Re-run without --verify to {}.",
if mode.is_update() { "update" } else { "deploy" }
);
return Ok(());
}
preflight_writable(&layout)?;
// ── The sidecar binary, shared by every instance ─────────────────────────
let prepared = service::prepare(layout.relocated);
let binary_sha256 = if binary_action.writes() {
// Every instance runs this one file: on Windows it is locked while any of them runs, and on
// Linux replacing it leaves each one serving old code until restarted.
let mut running: Vec<String> = prior
.as_ref()
.map(|p| p.instances.keys().cloned().collect())
.unwrap_or_default();
running.extend(planned.iter().map(|p| p.id.clone()));
running.sort();
running.dedup();
service::stop_rust_instances(&prepared.manager, &running)?;
let sha = crate::sidecar::place(&asset, &layout.rust_sidecar_bin, scratch.path())?;
ui::ok(&format!(
"rust-link binary verified sha256 {}…",
&asset.sha256[..8.min(asset.sha256.len())]
));
sha
} else {
asset.sha256.trim().to_ascii_lowercase()
};
// ── Each instance ────────────────────────────────────────────────────────
let mut record = prior
.clone()
.unwrap_or_else(|| empty_record(&bundle, &bundle_url));
let mut handoffs = Vec::new();
for plan in &planned {
let (instance, doc, registered) = deploy_instance(
&layout,
&prepared,
&bundle,
&released,
plan,
binary_action.writes(),
prior
.as_ref()
.and_then(|p| p.instances.get(&plan.id))
.map(|i| i.plugin_config_written)
.unwrap_or(false),
)?;
record.instances.insert(plan.id.clone(), instance);
handoffs.push((plan.id.clone(), doc, registered, plan.running));
}
// Instances this run did not touch still run the binary it just replaced.
if binary_action.writes() {
if let Some(prior) = &prior {
for (id, instance) in &prior.instances {
if planned.iter().any(|p| &p.id == id) || instance.service.is_none() {
continue;
}
match service::restart_rust_instance(id) {
Ok(()) => ui::ok(&format!(
"restarted {} on the new binary",
service::rust_service_name(id)
)),
Err(e) => ui::warn(&format!("could not restart {id} on the new binary: {e}")),
}
}
}
}
// ── Record ───────────────────────────────────────────────────────────────
record.schema = SCHEMA;
record.installer = InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
};
record.updated = now_rfc3339();
record.bundle = BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: bundle_url.clone(),
};
record.sidecar = component(
&bundle.sidecar.repo,
&bundle.sidecar.tag,
&bundle.sidecar.version,
"",
bundle.sidecar.protocol,
);
record.binary = BinaryRef {
path: layout.rust_sidecar_bin.display().to_string(),
sha256: binary_sha256,
};
record.plugin = component(
&bundle.payload.repo,
&bundle.payload.tag,
&bundle.payload.version,
&bundle.payload.commit,
bundle.payload.protocol,
);
record.service_user_created |= prepared.user_created;
match prior.as_ref() {
Some(previous) if previous.same_deployment_as(&record) => {
println!("\n {} unchanged", record_path.display());
}
_ => {
record.save(&record_path).with_context(|| {
format!(
"cannot write {} — run as root/Administrator, or set {} for a test run",
record_path.display(),
paths::STATE_DIR_ENV
)
})?;
println!("\n Recorded {}", record_path.display());
}
}
// ── What is left for the operator ────────────────────────────────────────
match mode {
Mode::Install => {
let host = crate::install::resolve_host(cli);
for (id, doc, registered, running) in &handoffs {
println!(
"{}",
sidecar::handoff(
id,
doc,
&host,
cli.site_url.as_deref(),
*registered,
*running
)
);
}
}
Mode::Update => {
let before = prior.as_ref().map(|p| p.bundle.clone());
match before {
Some(b) if b.tag == bundle.bundle => {
// The same bundle can still have written something: a plugin edited by hand is
// put back, which is what `doctor` tells an operator to run `update` for.
let restored = planned.iter().filter(|p| p.plugin_action.writes()).count();
if restored == 0 && !binary_action.writes() {
println!("\nAlready on bundle {} — nothing moved.", bundle.bundle)
} else {
println!(
"\nAlready on bundle {} — put back what no longer matched it (see above).",
bundle.bundle
)
}
}
Some(b) => {
println!(
"\nMoved {} instance(s) from bundle {} to {}.",
planned.len(),
b.tag,
bundle.bundle
);
if b.protocol != bundle.protocol {
ui::warn(&format!(
"The protocol moved from {} to {}. The website's Rust module must speak \
{} too, or it will be refused with 409 — update the site first.",
b.protocol, bundle.protocol, bundle.protocol
));
}
}
None => {}
}
}
}
Ok(())
}
/// Every decision for one instance, made without writing anything.
fn plan_instance(
cli: &Cli,
layout: &paths::Layout,
prior: Option<&RustRecord>,
id: &str,
root: &Path,
released: &plugin::Released,
) -> Result<Planned> {
let server = server::open(root)
.with_context(|| format!("cannot use {} as a Rust server root", root.display()))?;
let recorded = prior.and_then(|p| p.instances.get(id));
if let Some(instance) = recorded {
if Path::new(&instance.server_root) != server.path {
bail!(
"instance {id:?} is recorded for {}, not {}. An id names one server; uninstall it \
first (`uninstall --game rust --server-id {id}`) to move it.",
instance.server_root,
server.path.display()
);
}
}
if let Some((other, _)) = prior.and_then(|p| {
p.instances
.iter()
.find(|(other, i)| other.as_str() != id && Path::new(&i.server_root) == server.path)
}) {
bail!(
"{} is already installed as instance {other:?}. One server root has one plugin and one \
ServerId; run with --server-id {other}.",
server.path.display()
);
}
let plugin_config = plugin::read_config(&server.plugin_config_path())?;
let held_game = prior.map(|p| p.game_ports_except(id)).unwrap_or_default();
let game_port = match &plugin_config {
Some(view) => {
plugin::check_server_id(view, id, &server.plugin_config_path())?;
// A config without a Port gets the plugin's default.
let port = view.port.unwrap_or(GAME_PORT_START);
if held_game.contains(&port) {
bail!(
"{} says the plugin dials port {port}, which another Rust instance on this host \
already listens on. Give it a free Port and run again.",
server.plugin_config_path().display()
);
}
port
}
None => sidecar::choose_port(GAME_PORT_START, &held_game, recorded.map(|i| i.game_port))?,
};
let held_web = prior.map(|p| p.web_ports_except(id)).unwrap_or_default();
if let Some(port) = cli.web_port {
if held_web.contains(&port) {
bail!("--web-port {port} is already another Rust instance's web port");
}
}
let web_port = match (cli.web_port, recorded) {
(Some(port), _) => port,
(None, Some(instance)) => instance.web_port,
(None, None) => sidecar::choose_port(WEB_PORT_START, &held_web, None)?,
};
let plugin_action = match std::fs::read(server.plugin_path()) {
Err(_) => BinaryAction::Install,
Ok(bytes) if crate::util::sha256_bytes(&bytes) == released.sha256 => {
BinaryAction::Unchanged
}
Ok(_) => BinaryAction::Replace,
};
let config_path = layout.rust_config(id);
Ok(Planned {
id: id.to_string(),
running: server::is_running(&server.path),
config_exists: config_path.exists(),
config_path,
db_path: layout.rust_db(id),
server,
plugin_config,
plugin_action,
game_port,
web_port,
})
}
fn print_plan(
layout: &paths::Layout,
planned: &[Planned],
binary: BinaryAction,
bundle: &RustBundle,
) {
ui::row(
"binary",
&format!("{} {}", layout.rust_sidecar_bin.display(), binary.label()),
);
for p in planned {
ui::heading(&format!("Instance {}", p.id));
ui::row(
"server",
&format!(
"{} ({}, {})",
p.server.path.display(),
p.server.framework.as_str(),
if p.running {
"running — the plugin loads at once"
} else {
"not running — the plugin loads at next boot"
}
),
);
ui::row(
"plugin",
&format!(
"{} {}",
p.server.plugin_path().display(),
p.plugin_action.label()
),
);
ui::row(
"plugin config",
&match &p.plugin_config {
Some(_) => format!(
"{} kept (the website's)",
p.server.plugin_config_path().display()
),
None => format!(
"{} written: ServerId {}, Port {}",
p.server.plugin_config_path().display(),
p.id,
p.game_port
),
},
);
ui::row(
"sidecar config",
&format!(
"{} {}",
p.config_path.display(),
if p.config_exists { "kept" } else { "written" }
),
);
ui::row(
"ports",
&format!(
"game 127.0.0.1:{} web 127.0.0.1:{}",
p.game_port, p.web_port
),
);
let missing = plugin::missing_plugins(
&p.server.plugins_dir(),
&bundle.payload.compat.requires_plugins,
);
if !missing.is_empty() {
ui::warn(&format!(
"{} not in {} — the features that use {} stay off until you install {} from uMod. \
The installer does not fetch third-party plugins (D153).",
missing.join(", "),
p.server.plugins_dir().display(),
if missing.len() == 1 { "it" } else { "them" },
if missing.len() == 1 { "it" } else { "them" },
));
}
}
}
/// Writes one instance: plugin config, sidecar config, service, plugin — in that order.
fn deploy_instance(
layout: &paths::Layout,
prepared: &service::Prepared,
bundle: &RustBundle,
released: &plugin::Released,
plan: &Planned,
binary_changed: bool,
plugin_config_written_before: bool,
) -> Result<(Instance, sidecar::ConfigDoc, bool)> {
ui::heading(&format!("Instance {}", plan.id));
let data_dir = layout.rust_data_dir(&plan.id);
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("cannot create {}", data_dir.display()))?;
// The plugin's config, once, and only if it does not exist (§34.2.3).
let plugin_config_path = plan.server.plugin_config_path();
let wrote_plugin_config = plan.plugin_config.is_none();
if wrote_plugin_config {
if let Some(parent) = plugin_config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plugin_config_path,
plugin::initial_config(&plan.id, plan.game_port).as_bytes(),
)?;
ui::ok(&format!("wrote {}", plugin_config_path.display()));
}
// The sidecar's config, once; the token is the sidecar's to generate.
if !plan.config_exists {
if let Some(parent) = plan.config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
write_atomic(
&plan.config_path,
sidecar::instance_config(&plan.id, plan.game_port, plan.web_port, &plan.db_path)
.as_bytes(),
)?;
}
let doc = sidecar::print_config(&layout.rust_sidecar_bin, &plan.config_path)?;
if doc.protocol != bundle.protocol {
bail!(
"the installed rust-link sidecar reports protocol {} but bundle {} was composed at {}. \
Refusing to register a service for a pair that was never checked together.",
doc.protocol,
bundle.bundle,
bundle.protocol
);
}
if doc.version != bundle.sidecar.version {
ui::warn(&format!(
"the installed binary reports version {} but bundle {} names {}. The checksum matched, \
so this is a labelling mismatch in the release rather than a wrong download.",
doc.version, bundle.bundle, bundle.sidecar.version
));
}
// The sidecar's server_id is a cross-check against the plugin's hello. An edited config that
// names another server makes every hello log a disagreement, so it is said here, once.
if !doc.game.server_id.is_empty() && doc.game.server_id != plan.id {
ui::warn(&format!(
"{} cross-checks server id {:?}, but this instance is {:?}; the sidecar will log a \
disagreement on every connect.",
doc.config_path, doc.game.server_id, plan.id
));
}
// An existing sidecar config is the operator's; what it says is what runs. Say so when it
// disagrees with the plugin, because that pair never meets.
if let Some(port) = sidecar::port_of(&doc.game.bind) {
if port != plan.game_port {
ui::warn(&format!(
"{} listens for the plugin on {port}, but the plugin dials {}. Make them agree, or \
the plugin never connects.",
doc.config_path, plan.game_port
));
}
}
ui::row(
"sidecar config",
&format!(
"{} {}",
doc.config_path,
if doc.token_generated {
"token generated"
} else {
"token kept"
}
),
);
service::protect_config(
&plan.config_path,
&data_dir,
prepared.user.as_deref(),
layout.relocated,
)?;
let mut outcome = service::register_rust(
prepared,
layout,
&plan.id,
&plan.config_path,
binary_changed,
)?;
service::grant_service_access(&plan.config_path, &data_dir, &outcome)?;
service::start_registered(&mut outcome, &layout.rust_sidecar_bin, &plan.config_path)?;
let service_record = match &outcome {
service::Outcome::Registered {
kind,
name,
user,
state,
..
} => {
ui::row("service", &format!("{name} {state}"));
Some(ServiceRecord {
kind: (*kind).to_string(),
name: name.clone(),
unit_path: None,
user: user.clone(),
// The account is shared with every instance and possibly with ServUO's sidecar, so
// no single instance's removal may delete it (see `RustRecord`).
user_created: false,
})
}
service::Outcome::Skipped { reason, manual } => {
ui::warn(&format!(
"service NOT REGISTERED — {reason}.\n The binary and config are in place; nothing is \
running them. Do this by hand:"
));
print!("{manual}");
None
}
};
// The plugin last: it loads the moment it lands, and its sidecar is now there to dial.
let plugin_path = plan.server.plugin_path();
if plan.plugin_action.writes() {
std::fs::create_dir_all(plan.server.plugins_dir())
.with_context(|| format!("cannot create {}", plan.server.plugins_dir().display()))?;
// Overwritten in place, NOT `write_atomic`. Oxide and Carbon watch the plugins directory
// and reload on a CHANGE; `write_atomic` removes the old file and renames a `.tmp` over it,
// which both frameworks see as a delete — they unload the bridge — and a rename they
// ignore, so the new file is never loaded. On a running server that was an `update` that
// silently took the bridge down until the next boot (the phase 18 walk, on all three
// instances and both frameworks). A write in place is what an operator's `cp` does, and
// it reloads. A crash mid-write leaves a file that fails to compile; `doctor` reports it
// as not the deployed file and `update` writes it again.
std::fs::write(&plugin_path, &released.source)
.with_context(|| format!("cannot write {}", plugin_path.display()))?;
ui::ok(&format!(
"plugin {} {}",
if plan.plugin_action == BinaryAction::Replace {
"replaced"
} else {
"installed"
},
plugin_path.display()
));
}
Ok((
Instance {
server_root: plan.server.path.display().to_string(),
framework: plan.server.framework.as_str().to_string(),
plugin_path: plugin_path.display().to_string(),
plugin_sha256: released.sha256.clone(),
plugin_config: plugin_config_path.display().to_string(),
plugin_config_written: wrote_plugin_config || plugin_config_written_before,
game_port: plan.game_port,
web_port: sidecar::port_of(&doc.web.bind).unwrap_or(plan.web_port),
config_path: doc.config_path.clone(),
db_path: doc.store.path.clone(),
service: service_record,
},
doc,
outcome.registered(),
))
}
fn component(repo: &str, tag: &str, version: &str, commit: &str, protocol: u32) -> ComponentRecord {
ComponentRecord {
repo: repo.to_string(),
tag: tag.to_string(),
version: version.to_string(),
commit: commit.to_string(),
protocol,
}
}
fn empty_record(bundle: &RustBundle, url: &str) -> RustRecord {
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
},
updated: String::new(),
bundle: BundleRef {
tag: bundle.bundle.clone(),
protocol: bundle.protocol,
url: url.to_string(),
},
sidecar: component("", "", "", "", 0),
binary: BinaryRef {
path: String::new(),
sha256: String::new(),
},
plugin: component("", "", "", "", 0),
service_user_created: false,
instances: Default::default(),
extra: Default::default(),
}
}
/// Fails before anything is written if this process cannot write where it must.
fn preflight_writable(layout: &paths::Layout) -> Result<()> {
let bin_dir = layout
.rust_sidecar_bin
.parent()
.unwrap_or(&layout.rust_sidecar_bin)
.to_path_buf();
for dir in [
layout.rust_config_dir(),
layout.data_dir.join("rust"),
bin_dir,
] {
std::fs::create_dir_all(&dir)
.and_then(|_| {
let probe = dir.join(".runicgateway-write-test");
std::fs::write(&probe, b"")?;
std::fs::remove_file(&probe)
})
.with_context(|| {
format!(
"cannot write to {}. Run as {}, or set {} for a test run (no service is \
registered then).",
dir.display(),
if cfg!(windows) {
"Administrator"
} else {
"root (sudo)"
},
paths::STATE_DIR_ENV
)
})?;
}
Ok(())
}

34
src/rustgame/mod.rs Normal file
View File

@@ -0,0 +1,34 @@
//! `--game rust`: the installer's second game (docs/modules/rust/PLAN.md §34, R4).
//!
//! The same four verbs as ServUO and the same bundle discipline — an exact, protocol-checked pair
//! from CI, never "latest of each" — over a different shape of host. A Rust host commonly runs
//! several servers, and R8 gives each its own sidecar, so where ServUO has one deployment this has
//! **named instances** (D148): one server root, one plugin, and one sidecar service per
//! `--server-id`, all sharing one binary and one bundle.
//!
//! Kept in its own module rather than threaded through the ServUO pipeline: the two share the
//! bundle reader, the download and checksum code, the service machinery and the report format, and
//! share nothing about what is deployed or where. ServUO's files and record are untouched by any
//! of this.
mod doctor;
mod install;
mod plugin;
mod record;
mod server;
mod sidecar;
mod uninstall;
use anyhow::Result;
use crate::cli::{Cli, Command};
/// Runs one verb for Rust. The `i32` is the exit code, as for ServUO.
pub fn run(cli: &Cli, command: Command) -> Result<i32> {
match command {
Command::Install => install::deploy(cli, crate::install::Mode::Install).map(|()| 0),
Command::Update => install::deploy(cli, crate::install::Mode::Update).map(|()| 0),
Command::Doctor => doctor::run(cli),
Command::Uninstall => uninstall::run(cli),
}
}

238
src/rustgame/plugin.rs Normal file
View File

@@ -0,0 +1,238 @@
//! The plugin: its released tarball, and its config in the server root.
//!
//! Two files, two owners (docs/modules/rust/PLAN.md §34.2.3):
//!
//! - **`RunicGateway.cs` is the installer's.** It comes from the bundle, is replaced when the bundle
//! moves, and is removed by `uninstall`.
//! - **`RunicGateway.json` is the website's.** The plugin writes it, the site edits it through the
//! plugin, and it locks `ServerId`. The installer writes it exactly once — when it does not exist
//! yet, holding just `ServerId` and `Port` — and never rewrites it. An existing one whose
//! `ServerId` is not this instance's refuses the run, naming both. That is the same rule the
//! ServUO overlay follows for `Bridge.cfg`.
use std::collections::BTreeMap;
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use crate::util::sha256_bytes;
/// The fixed top directory inside the plugin tarball (Rust-Plugins' release.yml).
const PREFIX: &str = "runicgateway-rust-plugin";
/// `runicgateway-rust-plugin/manifest.json`, which the release folds from `overlay.toml`.
#[derive(Debug, Clone, Deserialize)]
pub struct Manifest {
pub version: String,
pub protocol: u32,
#[serde(default)]
pub files: BTreeMap<String, String>,
}
/// The released plugin, read out of its tarball.
#[derive(Debug, Clone)]
pub struct Released {
pub manifest: Manifest,
pub source: Vec<u8>,
pub sha256: String,
}
/// Reads the plugin and its manifest out of a downloaded tarball, and checks that the two agree.
pub fn read_tarball(path: &Path) -> Result<Released> {
let file =
std::fs::File::open(path).with_context(|| format!("cannot open {}", path.display()))?;
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(file));
let mut manifest: Option<Vec<u8>> = None;
let mut source: Option<Vec<u8>> = None;
for entry in archive
.entries()
.context("the plugin tarball is not a tar.gz")?
{
let mut entry = entry.context("the plugin tarball is truncated")?;
let name = entry.path()?.to_string_lossy().replace('\\', "/");
let slot = if name == format!("{PREFIX}/manifest.json") {
&mut manifest
} else if name == format!("{PREFIX}/{}", super::server::PLUGIN_FILE) {
&mut source
} else {
continue;
};
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
*slot = Some(bytes);
}
let manifest = manifest
.ok_or_else(|| anyhow::anyhow!("the plugin tarball has no {PREFIX}/manifest.json"))?;
let source = source.ok_or_else(|| {
anyhow::anyhow!(
"the plugin tarball has no {PREFIX}/{}",
super::server::PLUGIN_FILE
)
})?;
let manifest: Manifest =
serde_json::from_slice(&manifest).context("the plugin's manifest.json is unreadable")?;
let sha256 = sha256_bytes(&source);
if let Some(declared) = manifest.files.get(super::server::PLUGIN_FILE) {
if !declared.eq_ignore_ascii_case(&sha256) {
bail!(
"the plugin in the tarball does not match its own manifest (sha256 {sha256}, \
manifest says {declared}). The tarball matched the bundle's checksum, so the \
release itself is inconsistent — refusing it."
);
}
}
Ok(Released {
manifest,
source,
sha256,
})
}
/// What the plugin's config says, as far as the installer cares.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigView {
pub server_id: Option<String>,
pub port: Option<u16>,
}
/// Reads `RunicGateway.json`, or `None` when the plugin has never written one.
pub fn read_config(path: &Path) -> Result<Option<ConfigView>> {
if !path.exists() {
return Ok(None);
}
let text =
std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
let value: serde_json::Value = serde_json::from_str(&text).with_context(|| {
format!(
"{} is not valid JSON. The plugin will not load it either; fix or remove it.",
path.display()
)
})?;
Ok(Some(ConfigView {
server_id: value
.get("ServerId")
.and_then(|v| v.as_str())
.map(str::to_string),
port: value
.get("Port")
.and_then(|v| v.as_u64())
.and_then(|p| u16::try_from(p).ok()),
}))
}
/// The config the installer writes when there is none: just the two keys it decides. The plugin
/// fills in every other key on its first load, with its own defaults (§34.2.3).
pub fn initial_config(server_id: &str, port: u16) -> String {
let doc = serde_json::json!({ "ServerId": server_id, "Port": port });
let mut text = serde_json::to_string_pretty(&doc).unwrap_or_default();
text.push('\n');
text
}
/// Refuses an existing config that names another server.
pub fn check_server_id(view: &ConfigView, server_id: &str, path: &Path) -> Result<()> {
match view.server_id.as_deref() {
Some(id) if id == server_id => Ok(()),
Some(id) => bail!(
"{} already names this server {id:?}, and this run was asked to install {server_id:?}.\n \
The website locks a server's id once it has seen it, and this installer never rewrites \
the plugin's config. Run again with --server-id {id}, or — if this server really is \
new to the site — remove that file and run again.",
path.display()
),
// A config with no ServerId gets the plugin's default ("main") on load. Treat that as what
// it will become rather than as agreement.
None => {
if server_id == "main" {
Ok(())
} else {
bail!(
"{} has no ServerId, so the plugin will call this server \"main\"; this run was \
asked to install {server_id:?}. Add \"ServerId\": \"{server_id}\" to it, or run \
with --server-id main.",
path.display()
)
}
}
}
}
/// Which of `required` are missing from a plugins directory. Reported, never installed (D153).
pub fn missing_plugins(plugins_dir: &Path, required: &[String]) -> Vec<String> {
required
.iter()
.filter(|name| !plugins_dir.join(format!("{name}.cs")).is_file())
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
#[test]
fn the_first_config_holds_only_what_the_installer_decides() {
let text = initial_config("alpha", 7800);
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(value.as_object().unwrap().len(), 2);
assert_eq!(value["ServerId"], "alpha");
assert_eq!(value["Port"], 7800);
}
#[test]
fn an_existing_config_for_another_server_refuses_the_run() {
let path = Path::new("oxide/config/RunicGateway.json");
let view = ConfigView {
server_id: Some("rust-oxide".into()),
port: Some(7799),
};
assert!(check_server_id(&view, "rust-oxide", path).is_ok());
let err = check_server_id(&view, "alpha", path)
.unwrap_err()
.to_string();
assert!(
err.contains("\"rust-oxide\"") && err.contains("\"alpha\""),
"{err}"
);
}
#[test]
fn a_config_without_an_id_is_main() {
let path = Path::new("x.json");
let view = ConfigView {
server_id: None,
port: None,
};
assert!(check_server_id(&view, "main", path).is_ok());
assert!(check_server_id(&view, "alpha", path).is_err());
}
#[test]
fn the_plugins_config_is_read_back_without_the_rest_of_its_keys() {
let dir = TempDir::new("rg-rust-cfg").unwrap();
let path = dir.path().join("RunicGateway.json");
assert_eq!(read_config(&path).unwrap(), None);
// What the rig's plugin actually wrote, 2026-09-26.
std::fs::write(
&path,
r#"{ "Host": "127.0.0.1", "Port": 7799, "QueueCap": 5000, "ServerId": "rust-oxide",
"EventsEnabled": true, "MapMaxBases": 2000 }"#,
)
.unwrap();
let view = read_config(&path).unwrap().unwrap();
assert_eq!(view.server_id.as_deref(), Some("rust-oxide"));
assert_eq!(view.port, Some(7799));
}
#[test]
fn a_missing_required_plugin_is_named() {
let dir = TempDir::new("rg-rust-req").unwrap();
std::fs::write(dir.path().join("Kits.cs"), b"").unwrap();
let missing = missing_plugins(dir.path(), &["Kits".into(), "ZoneManager".into()]);
assert_eq!(missing, ["ZoneManager"]);
}
}

208
src/rustgame/record.rs Normal file
View File

@@ -0,0 +1,208 @@
//! `rust/install.json` — what this host has deployed for Rust.
//!
//! Its own file rather than a list added to ServUO's `install.json` (docs/modules/rust/PLAN.md
//! §34.4): a host running both games has two records that cannot corrupt each other, and an
//! installer that predates Rust never sees this one at all.
//!
//! One bundle and one sidecar binary for the host, and a map of instances. `update` moves every
//! instance together because they share the binary (§34.4), which is why the bundle is recorded once
//! rather than per instance. Like `install.json`, **the auth tokens are not here** — they live in
//! each instance's `sidecar.toml` — and fields this build has no name for are carried through.
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::record::{BinaryRef, BundleRef, InstallerInfo, ServiceRecord};
use crate::util::write_atomic;
/// The shape of this document.
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RustRecord {
pub schema: u32,
pub installer: InstallerInfo,
pub updated: String,
pub bundle: BundleRef,
pub sidecar: ComponentRecord,
pub binary: BinaryRef,
pub plugin: ComponentRecord,
/// This installer created the shared `runicgateway` service user. Kept here rather than on each
/// instance: the account outlives any one instance and may also run ServUO's sidecar, so only
/// the last Rust instance's removal — on a host with no ServUO record — may delete it.
#[serde(default)]
pub service_user_created: bool,
pub instances: BTreeMap<String, Instance>,
#[serde(flatten)]
pub extra: BTreeMap<String, serde_json::Value>,
}
/// A released component, as installed.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ComponentRecord {
pub repo: String,
pub tag: String,
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub commit: String,
pub protocol: u32,
}
/// One Rust server and its sidecar.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Instance {
pub server_root: String,
/// `oxide` or `carbon`, as detected when this was recorded.
pub framework: String,
pub plugin_path: String,
/// What was deployed, so `doctor` can tell an edited plugin file from the release's.
pub plugin_sha256: String,
/// The plugin's config — the website's file, recorded so `doctor` knows where to look.
pub plugin_config: String,
/// The installer wrote that config (it did not exist). Informational; it is kept either way.
#[serde(default)]
pub plugin_config_written: bool,
/// The loopback port the plugin dials — the plugin config's `Port`.
pub game_port: u16,
/// The port the website reaches this instance's sidecar on.
pub web_port: u16,
pub config_path: String,
pub db_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub service: Option<ServiceRecord>,
}
impl RustRecord {
/// Everything but the timestamp, so a second run with nothing to do writes nothing.
pub fn same_deployment_as(&self, other: &Self) -> bool {
let mut a = self.clone();
let mut b = other.clone();
a.updated.clear();
b.updated.clear();
a == b
}
pub fn load(path: &Path) -> Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let record = serde_json::from_str(&body).with_context(|| {
format!(
"{} exists but is not a record this installer understands. \
Move it aside to start over, or install a newer installer.",
path.display()
)
})?;
Ok(Some(record))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let mut body =
serde_json::to_string_pretty(self).context("cannot serialize rust/install.json")?;
body.push('\n');
write_atomic(path, body.as_bytes())
}
/// Game ports held by instances other than `except`.
pub fn game_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.game_port)
.collect()
}
/// Web ports held by instances other than `except`.
pub fn web_ports_except(&self, except: &str) -> Vec<u16> {
self.instances
.iter()
.filter(|(id, _)| id.as_str() != except)
.map(|(_, i)| i.web_port)
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
pub fn sample() -> RustRecord {
let instance = |root: &str, game: u16, web: u16| Instance {
server_root: root.into(),
framework: "oxide".into(),
plugin_path: format!("{root}/oxide/plugins/RunicGateway.cs"),
plugin_sha256: "ab".repeat(32),
plugin_config: format!("{root}/oxide/config/RunicGateway.json"),
plugin_config_written: true,
game_port: game,
web_port: web,
config_path: "/etc/runicgateway/rust/x.toml".into(),
db_path: "/var/lib/runicgateway/rust/x/rust-link.db".into(),
service: None,
};
RustRecord {
schema: SCHEMA,
installer: InstallerInfo {
version: "0.3.0".into(),
},
updated: "2026-09-26T00:00:00Z".into(),
bundle: BundleRef {
tag: "2026.09.26".into(),
protocol: 12,
url: "https://example/v2/rust/current.json".into(),
},
sidecar: ComponentRecord {
repo: "RunicGateway/Rust-Link".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: String::new(),
protocol: 12,
},
binary: BinaryRef {
path: "/usr/bin/runicgateway-rust-link".into(),
sha256: "cd".repeat(32),
},
plugin: ComponentRecord {
repo: "RunicGateway/Rust-Plugins".into(),
tag: "v0.1.0".into(),
version: "0.1.0".into(),
commit: "abc".into(),
protocol: 12,
},
service_user_created: true,
instances: BTreeMap::from([
("alpha".to_string(), instance("/srv/a", 7799, 8090)),
("beta".to_string(), instance("/srv/b", 7800, 8091)),
]),
extra: BTreeMap::new(),
}
}
#[test]
fn a_record_round_trips_and_holds_no_token() {
let dir = TempDir::new("rg-rust-record").unwrap();
let path = dir.path().join("rust").join("install.json");
let record = sample();
record.save(&path).unwrap();
assert_eq!(RustRecord::load(&path).unwrap().unwrap(), record);
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("auth_token") && !text.contains("token\""));
}
#[test]
fn ports_held_by_other_instances_exclude_the_one_being_installed() {
let record = sample();
assert_eq!(record.game_ports_except("alpha"), [7800]);
assert_eq!(record.web_ports_except("beta"), [8090]);
}
}

219
src/rustgame/server.rs Normal file
View File

@@ -0,0 +1,219 @@
//! A Rust server root: that it is one, and which modding framework it runs.
//!
//! **The framework is detected, never asked** (docs/modules/rust/PLAN.md §34.2.3). The plugin is one
//! file that runs unchanged on Oxide and on Carbon (R19); what differs is only the directory it goes
//! in, and the server itself already says which framework it has. Asking would be one more answer an
//! operator could get wrong.
//!
//! The marker files were read off the two rigs (2026-09-26), one per framework, before this code
//! relied on them:
//!
//! - **Oxide:** `RustDedicated_Data/Managed/Oxide.Rust.dll`.
//! - **Carbon:** `carbon/managed/Carbon.dll`.
//!
//! Both present is refused: Oxide and Carbon cannot run in one install (R21), so a tree carrying both
//! is one mid-migration, and guessing would put the plugin where one of them never looks.
//!
//! **Carbon's directories are its defaults.** The plan expected a moved plugins directory to be
//! readable from `carbon/config.json`; on 2.0.259, the build R19 was proven on, that file has no
//! folder keys at all — Carbon's directories move only by a launch argument, which an installer
//! cannot see. `carbon/plugins/` and `carbon/configs/` are what every stock install uses.
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
/// Which framework loads the plugin.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framework {
Oxide,
Carbon,
}
impl Framework {
pub fn as_str(self) -> &'static str {
match self {
Self::Oxide => "oxide",
Self::Carbon => "carbon",
}
}
/// The file whose presence says this framework is installed.
fn marker(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root
.join("RustDedicated_Data")
.join("Managed")
.join("Oxide.Rust.dll"),
Self::Carbon => root.join("carbon").join("managed").join("Carbon.dll"),
}
}
/// Where the framework loads plugins from.
pub fn plugins_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("plugins"),
Self::Carbon => root.join("carbon").join("plugins"),
}
}
/// Where the framework keeps each plugin's config. Oxide says `config`, Carbon `configs`.
pub fn config_dir(self, root: &Path) -> PathBuf {
match self {
Self::Oxide => root.join("oxide").join("config"),
Self::Carbon => root.join("carbon").join("configs"),
}
}
}
/// A validated Rust server root.
#[derive(Debug, Clone)]
pub struct RustServer {
pub path: PathBuf,
pub framework: Framework,
}
/// The file this installer owns in a server root: the plugin.
pub const PLUGIN_FILE: &str = "RunicGateway.cs";
/// The plugin's config, which is the website's once written (§34.2.3).
pub const PLUGIN_CONFIG: &str = "RunicGateway.json";
impl RustServer {
pub fn plugins_dir(&self) -> PathBuf {
self.framework.plugins_dir(&self.path)
}
pub fn plugin_path(&self) -> PathBuf {
self.plugins_dir().join(PLUGIN_FILE)
}
pub fn plugin_config_path(&self) -> PathBuf {
self.framework.config_dir(&self.path).join(PLUGIN_CONFIG)
}
}
/// Validates `path` as a Rust server root and detects its framework.
pub fn open(path: &Path) -> Result<RustServer> {
let path = std::fs::canonicalize(path)
.map(crate::servuo::strip_extended_prefix)
.with_context(|| format!("{} does not exist", path.display()))?;
if !path.is_dir() {
bail!("{} is not a directory", path.display());
}
if !path.join("RustDedicated").is_file() && !path.join("RustDedicated.exe").is_file() {
bail!(
"{} is not a Rust server root: it has no RustDedicated or RustDedicated.exe",
path.display()
);
}
let oxide = Framework::Oxide.marker(&path).is_file();
let carbon = Framework::Carbon.marker(&path).is_file();
let framework = match (oxide, carbon) {
(true, false) => Framework::Oxide,
(false, true) => Framework::Carbon,
(true, true) => bail!(
"{} has both Oxide ({}) and Carbon ({}). They cannot run in one install, so this tree \
is part-way through a migration. Remove the one you are leaving, then run again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
(false, false) => bail!(
"{} has neither Oxide nor Carbon installed, and the bridge is a plugin for one of \
them. Install one (looked for {} and {}), start the server once so it lays out its \
directories, and run this again.",
path.display(),
Framework::Oxide.marker(&path).display(),
Framework::Carbon.marker(&path).display()
),
};
Ok(RustServer { path, framework })
}
/// Whether a RustDedicated is running out of this root.
///
/// Informational, unlike ServUO's refusal: both frameworks load and unload a plugin file while the
/// server runs, so an install under a live server is simply one that takes effect at once (§34.2.3).
/// `doctor` also uses it, to tell a server that is down from one whose plugin is silent.
pub fn is_running(root: &Path) -> bool {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let root = normalize(&root.to_string_lossy());
system.processes().values().any(|process| {
let exe = process
.exe()
.map(|p| normalize(&p.to_string_lossy()))
.unwrap_or_default();
exe.starts_with(&root) && exe.contains("rustdedicated")
})
}
fn normalize(s: &str) -> String {
s.to_lowercase().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::util::TempDir;
fn server(dir: &Path, frameworks: &[Framework]) {
std::fs::write(dir.join("RustDedicated"), b"").unwrap();
for f in frameworks {
let marker = f.marker(dir);
std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
std::fs::write(marker, b"").unwrap();
}
}
#[test]
fn each_framework_is_detected_and_places_the_plugin_its_own_way() {
for (f, plugins, config) in [
(Framework::Oxide, "oxide/plugins", "oxide/config"),
(Framework::Carbon, "carbon/plugins", "carbon/configs"),
] {
let dir = TempDir::new("rg-rust-root").unwrap();
server(dir.path(), &[f]);
let root = open(dir.path()).unwrap();
assert_eq!(root.framework, f);
let rel = |p: PathBuf| {
p.strip_prefix(&root.path)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
};
assert_eq!(rel(root.plugins_dir()), plugins);
assert_eq!(
rel(root.plugin_config_path()),
format!("{config}/RunicGateway.json")
);
}
}
#[test]
fn both_frameworks_are_refused_and_so_is_neither() {
let dir = TempDir::new("rg-rust-both").unwrap();
server(dir.path(), &[Framework::Oxide, Framework::Carbon]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(
err.contains("both Oxide") && err.contains("Carbon"),
"{err}"
);
let dir = TempDir::new("rg-rust-none").unwrap();
server(dir.path(), &[]);
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("neither Oxide nor Carbon"), "{err}");
}
#[test]
fn a_directory_without_the_game_is_not_a_server_root() {
let dir = TempDir::new("rg-rust-empty").unwrap();
let err = open(dir.path()).unwrap_err().to_string();
assert!(err.contains("RustDedicated"), "{err}");
}
}

325
src/rustgame/sidecar.rs Normal file
View File

@@ -0,0 +1,325 @@
//! One instance's rust-link sidecar: its config, and asking the installed binary about it.
//!
//! **The installer writes each instance's `sidecar.toml` itself, once.** ServUO's sidecar is one per
//! host and its defaults are right as shipped; a Rust host runs several, and every one of them needs
//! its own game port, web port and database, which no default can give it. So the first run writes
//! the file with those three decided and the token blank, and `--print-config` then generates the
//! token and saves it into that same file — the sidecar still owns the secret, exactly as for ServUO.
//! An existing file is never rewritten: after the first run it is the operator's, and the ports it
//! names are the ones recorded.
//!
//! The same rule as `crate::sidecar` holds for the token: it crosses one process boundary, here,
//! and goes to the terminal and nowhere else.
use std::path::Path;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
/// What `rust-link-sidecar --print-config` prints (Rust-Link `sidecar/src/config.rs::describe`).
#[derive(Debug, Clone, Deserialize)]
pub struct ConfigDoc {
pub component: String,
pub version: String,
pub protocol: u32,
pub config_path: String,
pub token_generated: bool,
pub game: GameDoc,
pub web: WebDoc,
pub store: StoreDoc,
}
#[derive(Debug, Clone, Deserialize)]
pub struct GameDoc {
pub bind: String,
#[serde(default)]
pub server_id: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct WebDoc {
pub bind: String,
/// **A secret.** Printed in the handoff and recorded nowhere.
pub auth_token: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct StoreDoc {
pub path: String,
}
/// The first `sidecar.toml` for an instance. Pure, so its content is a test.
///
/// Paths are TOML **literal** strings (single quotes): a Windows path is all backslashes, and a basic
/// string would read each one as an escape. A literal string cannot hold a single quote, and neither
/// a server id nor any path this installer builds contains one.
pub fn instance_config(server_id: &str, game_port: u16, web_port: u16, db: &Path) -> String {
format!(
"# rust-link sidecar for Rust server {server_id:?}.\n\
#\n\
# Written once by the Runic Gateway installer, which decided the ports and the database\n\
# below; it is never rewritten, so it is yours to edit from here. The token is generated\n\
# by the sidecar on first start and saved into this file.\n\
\n\
[game]\n\
# Where the plugin dials in. Loopback: there is no token on this link.\n\
bind = '127.0.0.1:{game_port}'\n\
# Cross-checked against the serverId the plugin announces.\n\
server_id = '{server_id}'\n\
\n\
[web]\n\
# Where the website reaches this sidecar. Put a TLS proxy in front to reach it from\n\
# another machine (docs/rust-link/INSTALL.md).\n\
bind = '127.0.0.1:{web_port}'\n\
auth_token = \"\"\n\
\n\
[store]\n\
path = '{db}'\n",
db = db.display(),
)
}
/// Runs the installed binary's `--print-config` against one instance's config, provisioning the
/// token on first use. **Must not print or attach the child's stdout** — it holds the token.
pub fn print_config(binary: &Path, config: &Path) -> Result<ConfigDoc> {
let output = std::process::Command::new(binary)
.arg("--print-config")
.arg("--config")
.arg(config)
// An operator's shell may export RUSTLINK_* while testing; an installed instance is
// described by its file alone, so none of them may leak into this read.
.env_remove("RUSTLINK_CONFIG")
.env_remove("RUSTLINK_GAME_BIND")
.env_remove("RUSTLINK_WEB_BIND")
.env_remove("RUSTLINK_WEB_TOKEN")
.env_remove("RUSTLINK_DB_PATH")
.env_remove("RUSTLINK_SERVER_ID")
.output()
.with_context(|| {
format!(
"cannot run {} --print-config — the binary cannot execute here",
binary.display()
)
})?;
if !output.status.success() {
let reason = String::from_utf8_lossy(&output.stderr)
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("(nothing on stderr)")
.to_string();
bail!(
"{} --print-config --config {} failed: {reason}",
binary.display(),
config.display()
);
}
let doc: ConfigDoc = serde_json::from_slice(&output.stdout).context(
"the rust-link sidecar's --print-config output is not the document this installer \
expects. It is not shown here because it contains the auth token.",
)?;
if doc.component != "rust-link-sidecar" {
bail!(
"the binary at {} identifies itself as {:?}, not rust-link-sidecar",
binary.display(),
doc.component
);
}
if doc.web.auth_token.trim().is_empty() {
bail!(
"the sidecar reported an empty auth token from {}; refusing to continue",
doc.config_path
);
}
Ok(doc)
}
/// The port half of a bind.
pub fn port_of(bind: &str) -> Option<u16> {
bind.rsplit_once(':').and_then(|(_, p)| p.parse().ok())
}
/// The lowest port from `start` that no recorded instance holds and nothing on this host is bound
/// to. `keep`, when given, is the port this instance already has, which is always kept.
pub fn choose_port(start: u16, held: &[u16], keep: Option<u16>) -> Result<u16> {
if let Some(port) = keep {
return Ok(port);
}
(start..=u16::MAX)
.take(1000)
.find(|p| !held.contains(p) && std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok())
.ok_or_else(|| anyhow::anyhow!("no free port found in the 1000 from {start}"))
}
/// The end-of-run block for one instance: what `/admin/rust/servers` asks for.
///
/// It says what the run did, not what it hopes: nothing here has seen the plugin connect — on a
/// stopped server it has not even loaded — so the first line never claims a link. `doctor` is the
/// command that checks one (D156).
pub fn handoff(
server_id: &str,
doc: &ConfigDoc,
host: &str,
site_url: Option<&str>,
registered: bool,
running: bool,
) -> String {
let port = port_of(&doc.web.bind)
.map(|p| p.to_string())
.unwrap_or_else(|| doc.web.bind.clone());
let site = site_url
.map(|s| s.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://<your-site>".to_string());
let loopback = doc.web.bind.starts_with("127.") || doc.web.bind.starts_with("[::1]");
format!(
"\nRust server {server_id:?} is set up. {when}\n\n\
One manual step remains — add it to the website:\n\n \
Server id {server_id}\n \
Sidecar URL http://{host}:{port}\n \
Auth token {token}\n \
(also in {config})\n \
Protocol {protocol}\n\n\
Add these at {site}/admin/rust/servers\n{loopback_note}{service_note}",
token = doc.web.auth_token,
config = doc.config_path,
protocol = doc.protocol,
when = if running {
format!(
"The plugin loads now; `doctor --game rust --server-id {server_id}` confirms it connected."
)
} else {
"The plugin connects when the server next starts.".to_string()
},
loopback_note = if loopback {
format!(
"\nThe sidecar listens on {} only. If the website runs on another machine, put a \
TLS proxy in\nfront of it and give the site the proxy's URL \
(docs/rust-link/INSTALL.md).\n",
doc.web.bind
)
} else {
String::new()
},
service_note = if registered {
""
} else {
"\nNo service was registered, so nothing is listening yet — see the steps above.\n"
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_instance_config_parses_and_keeps_a_windows_path_intact() {
let db = Path::new(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db");
let text = instance_config("alpha", 7800, 8091, db);
let value: toml_lite::Doc = toml_lite::parse(&text);
assert_eq!(value.get("game", "bind"), Some("127.0.0.1:7800"));
assert_eq!(value.get("game", "server_id"), Some("alpha"));
assert_eq!(value.get("web", "bind"), Some("127.0.0.1:8091"));
assert_eq!(value.get("web", "auth_token"), Some(""));
assert_eq!(
value.get("store", "path"),
Some(r"C:\ProgramData\RunicGateway\rust\alpha\rust-link.db")
);
}
#[test]
fn the_print_config_document_parses() {
// What Rust-Link v0.1.0 prints (sidecar/src/config.rs::describe).
let doc: ConfigDoc = serde_json::from_str(
r#"{ "component": "rust-link-sidecar", "version": "0.1.0", "protocol": 12,
"config_path": "/etc/runicgateway/rust/alpha.toml", "config_created": false,
"token_generated": true,
"game": { "bind": "127.0.0.1:7800", "server_id": "alpha" },
"web": { "bind": "127.0.0.1:8091", "ws_path": "/ws", "auth_required": true,
"auth_token": "t0k" },
"store": { "path": "/var/lib/runicgateway/rust/alpha/rust-link.db" } }"#,
)
.unwrap();
assert_eq!(port_of(&doc.web.bind), Some(8091));
let text = handoff("alpha", &doc, "rust.example", None, true, false);
for needle in [
"alpha",
"http://rust.example:8091",
"t0k",
"/admin/rust/servers",
"12",
] {
assert!(text.contains(needle), "{needle} missing from:\n{text}");
}
assert!(text.contains("TLS proxy"), "{text}");
// D156: never a claim about a link nothing has seen.
assert!(!text.contains("is connected"), "{text}");
assert!(
text.contains("The plugin connects when the server next starts."),
"{text}"
);
let running = handoff("alpha", &doc, "rust.example", None, true, true);
assert!(!running.contains("is connected"), "{running}");
assert!(
running.contains("doctor --game rust --server-id alpha"),
"{running}"
);
// The whole first line, so a wrapped source line cannot leave its indent in the sentence
// again (installer#34: "confirms it connected.").
assert_eq!(
running.lines().find(|l| !l.is_empty()),
Some(
"Rust server \"alpha\" is set up. The plugin loads now; \
`doctor --game rust --server-id alpha` confirms it connected."
),
"{running}"
);
}
#[test]
fn a_held_port_is_skipped_and_a_kept_one_is_kept() {
let port = choose_port(47_990, &[47_990, 47_991], None).unwrap();
assert!(port >= 47_992);
assert_eq!(
choose_port(47_990, &[47_990], Some(47_990)).unwrap(),
47_990
);
}
/// Enough TOML to read back the file `instance_config` writes — `[section]` and
/// `key = 'literal'` / `key = "basic"` lines. The installer has no TOML dependency and this
/// test is the only reader it needs.
mod toml_lite {
pub struct Doc(Vec<(String, String, String)>);
impl Doc {
pub fn get(&self, section: &str, key: &str) -> Option<&str> {
self.0
.iter()
.find(|(s, k, _)| s == section && k == key)
.map(|(_, _, v)| v.as_str())
}
}
pub fn parse(text: &str) -> Doc {
let mut section = String::new();
let mut out = Vec::new();
for line in text.lines().map(str::trim) {
if line.starts_with('#') || line.is_empty() {
continue;
}
if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
section = name.to_string();
} else if let Some((k, v)) = line.split_once('=') {
let v = v.trim();
let v = v
.strip_prefix('\'')
.and_then(|v| v.strip_suffix('\''))
.or_else(|| v.strip_prefix('"').and_then(|v| v.strip_suffix('"')))
.unwrap_or(v);
out.push((section.clone(), k.trim().to_string(), v.to_string()));
}
}
Doc(out)
}
}
}

199
src/rustgame/uninstall.rs Normal file
View File

@@ -0,0 +1,199 @@
//! `uninstall --game rust [--server-id <id>] [--purge]`.
//!
//! What it removes, per instance (docs/modules/rust/PLAN.md §34.2.3, §34.4):
//!
//! - **the service**, and **our plugin file** — `RunicGateway.cs` is the installer's;
//! - **never the plugin's config** — `RunicGateway.json` is the website's, and it holds the
//! server's id; a reinstall must find it;
//! - with `--purge`, the instance's sidecar config (its token) and database.
//!
//! With the last instance gone: the shared binary, the template unit, the record, and — only if
//! this installer created it and no ServUO deployment uses it — the service account.
use std::path::Path;
use anyhow::Result;
use super::record::RustRecord;
use crate::cli::Cli;
use crate::{paths, service, ui};
pub fn run(cli: &Cli) -> Result<i32> {
let layout = paths::layout();
let record_path = layout.rust_record();
println!(
"\nRunic Gateway installer {} — uninstall (Rust)",
env!("CARGO_PKG_VERSION")
);
let Some(mut record) = RustRecord::load(&record_path)? else {
println!();
ui::warn(&format!(
"Nothing to uninstall — no Rust deployment is recorded on this host (looked for {}).",
record_path.display()
));
return Ok(0);
};
let ids: Vec<String> = match &cli.server_id {
Some(id) if record.instances.contains_key(id) => vec![id.clone()],
Some(id) => anyhow::bail!(
"{id:?} is not a recorded Rust instance (recorded: {})",
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
None => record.instances.keys().cloned().collect(),
};
let last = ids.len() == record.instances.len();
// ── Say what will happen, then ask ───────────────────────────────────────
println!();
for id in &ids {
let i = &record.instances[id];
ui::heading(&format!("Instance {id}"));
if let Some(svc) = &i.service {
println!(" · remove the service {}", svc.name);
}
println!(" · remove {}", i.plugin_path);
println!(
" · keep {} (the website's; it names this server)",
i.plugin_config
);
if cli.purge {
println!(
" · remove {} and the database {}",
i.config_path, i.db_path
);
} else {
println!(
" · keep {} and {} (--purge removes them)",
i.config_path, i.db_path
);
}
}
if last {
println!(
"\n and, with no instance left: {} and {}",
record.binary.path,
record_path.display()
);
}
println!();
let proceed = if cli.assume_yes {
println!("Remove the components listed above? [y/N] (--yes)");
true
} else {
ui::confirm("Remove the components listed above?", false, false)?
};
if !proceed {
println!("\nNothing was removed.");
return Ok(0);
}
// ── Remove ───────────────────────────────────────────────────────────────
let mut done = Vec::new();
let mut problems = Vec::new();
for id in &ids {
let Some(instance) = record.instances.remove(id) else {
continue;
};
if let Some(svc) = &instance.service {
let removal = service::remove(svc);
done.extend(removal.done);
problems.extend(removal.problems);
}
remove_file(Path::new(&instance.plugin_path), &mut done, &mut problems);
if cli.purge {
remove_file(Path::new(&instance.config_path), &mut done, &mut problems);
let data_dir = layout.rust_data_dir(id);
if data_dir.exists() {
match std::fs::remove_dir_all(&data_dir) {
Ok(()) => done.push(format!("removed {}", data_dir.display())),
Err(e) => problems.push(format!("cannot remove {}: {e}", data_dir.display())),
}
}
}
}
if record.instances.is_empty() {
remove_file(Path::new(&record.binary.path), &mut done, &mut problems);
done.extend(service::remove_rust_template(&layout));
remove_shared_user(&record, &layout, &mut done, &mut problems);
remove_file(&record_path, &mut done, &mut problems);
} else {
match record.save(&record_path) {
Ok(()) => done.push(format!(
"{} keeps {} instance(s): {}",
record_path.display(),
record.instances.len(),
record
.instances
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
)),
Err(e) => problems.push(format!("cannot update {}: {e}", record_path.display())),
}
}
for line in &done {
println!(" · {line}");
}
for problem in &problems {
println!();
ui::warn(problem);
}
println!(
"\nRemove each server from Admin -> Rust -> Servers on the website too; the installer never \
contacts it."
);
Ok(if problems.is_empty() { 0 } else { 1 })
}
/// The `runicgateway` account is shared: every Rust instance runs as it, and so does ServUO's
/// sidecar. It goes only when this installer created it for Rust and no ServUO deployment remains.
fn remove_shared_user(
record: &RustRecord,
layout: &paths::Layout,
done: &mut Vec<String>,
problems: &mut Vec<String>,
) {
if !record.service_user_created || cfg!(windows) {
return;
}
if layout.install_record().exists() {
done.push(format!(
"left the {} account — the ServUO deployment on this host runs as it",
service::SERVICE_USER
));
return;
}
let removed = crate::util::run_ok("userdel", &[service::SERVICE_USER])
.or_else(|_| crate::util::run_ok("deluser", &[service::SERVICE_USER]));
match removed {
Ok(_) => done.push(format!(
"removed the {} service user",
service::SERVICE_USER
)),
Err(e) => problems.push(format!(
"cannot remove the {} service user ({}); remove it by hand if you want it gone",
service::SERVICE_USER,
e.to_string().replace('\n', " ")
)),
}
}
fn remove_file(path: &Path, done: &mut Vec<String>, problems: &mut Vec<String>) {
match std::fs::remove_file(path) {
Ok(()) => done.push(format!("removed {}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
done.push(format!("{} was already gone", path.display()))
}
Err(e) => problems.push(format!("cannot remove {}: {e}", path.display())),
}
}

View File

@@ -44,6 +44,11 @@ pub const WINDOWS_SERVICE: &str = "RunicGatewayLink";
pub const SERVICE_USER: &str = "runicgateway";
/// What both platforms show a human.
const DISPLAY_NAME: &str = "Runic Gateway uo-link sidecar";
/// The first `link` release whose sidecar speaks the Windows SCM startup protocol, and so the
/// oldest one that can be started as a service at all. Named only in the 1053 diagnosis; nothing
/// enforces it, because the Linux side has no such floor and a version gate on an installed binary
/// would refuse deployments that are working.
const MIN_SERVICE_SIDECAR: &str = "v1.2.0";
/// Which service manager this host has — or why it has none this installer can drive.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -226,7 +231,21 @@ fn register_systemd(
) -> Result<Outcome> {
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
let text = systemd_unit_text(binary, config, db, &user);
register_systemd_unit(prepared, SYSTEMD_UNIT, unit_path, &text, restart)
}
/// Writes a unit file (when its content changed), then enables and starts `name`.
///
/// `name` and the file differ for a template: the file is `runicgateway-rust@.service` and what is
/// enabled is `runicgateway-rust@alpha.service`, one per Rust instance (D148).
#[cfg(unix)]
fn register_systemd_unit(
prepared: &Prepared,
name: &str,
unit_path: &Path,
text: &str,
restart: bool,
) -> Result<Outcome> {
// An unchanged unit is not rewritten: daemon-reload is not free, and an mtime that moves on
// every run is a change an operator watching /etc has to investigate and then dismiss.
let current = std::fs::read_to_string(unit_path).unwrap_or_default();
@@ -236,31 +255,31 @@ fn register_systemd(
run_ok("systemctl", &["daemon-reload"])?;
}
run_ok("systemctl", &["enable", SYSTEMD_UNIT])?;
run_ok("systemctl", &["enable", name])?;
if restart {
// The binary underneath a running service has just been replaced; `start` on an already
// active unit is a no-op and would leave the old code running.
run_ok("systemctl", &["restart", SYSTEMD_UNIT])?;
run_ok("systemctl", &["restart", name])?;
} else {
run_ok("systemctl", &["start", SYSTEMD_UNIT])?;
run_ok("systemctl", &["start", name])?;
}
Ok(Outcome::Registered {
kind: "systemd",
name: SYSTEMD_UNIT.to_string(),
name: name.to_string(),
unit_path: Some(unit_path.to_path_buf()),
user: prepared.user.clone(),
user_created: prepared.user_created,
state: systemd_state(),
state: systemd_state(name),
})
}
/// Reads the unit's state back rather than inferring it from the exit codes above. `systemctl
/// start` succeeding and the service still being up a second later are different claims.
#[cfg(unix)]
fn systemd_state() -> String {
let active = one_word(run("systemctl", &["is-active", SYSTEMD_UNIT]));
let enabled = one_word(run("systemctl", &["is-enabled", SYSTEMD_UNIT]));
fn systemd_state(name: &str) -> String {
let active = one_word(run("systemctl", &["is-active", name]));
let enabled = one_word(run("systemctl", &["is-enabled", name]));
format!("{active}, {enabled}")
}
@@ -295,7 +314,14 @@ fn prepare_platform() -> Prepared {
/// SERVICE\<name>` form is a well-known prefix, unlike `BUILTIN\Administrators`, whose display name
/// is translated.
pub fn windows_service_account() -> String {
format!("NT SERVICE\\{WINDOWS_SERVICE}")
windows_account_for(WINDOWS_SERVICE)
}
/// The virtual account for any service this installer registers — `NT SERVICE\<name>`. A Rust
/// instance's is `NT SERVICE\RunicGatewayRust-<id>`, so two instances cannot read each other's
/// token.
pub fn windows_account_for(name: &str) -> String {
format!("NT SERVICE\\{name}")
}
/// The `binPath=` value: the executable and the `--config` it must always be started with.
@@ -307,25 +333,95 @@ pub fn windows_bin_path(binary: &Path, config: &Path) -> String {
format!("\"{}\" --config \"{}\"", binary.display(), config.display())
}
/// What to tell the operator when `sc.exe start` fails.
///
/// Pure and tested on both platforms, because the *wrong* explanation here is expensive. This
/// originally blamed every failure on the config file — "a service that exits immediately usually
/// cannot read its config" — which for the one error code that actually shows up sends the reader
/// to inspect a file that is almost certainly fine.
///
/// **1053 is not a crash.** It is the SCM giving up after 30 seconds waiting for the service
/// process to call `StartServiceCtrlDispatcher` and identify itself. The process starts, runs, and
/// is very likely serving traffic; it simply never had the conversation the SCM required. A sidecar
/// older than the one that speaks the SCM protocol produces this *every time*, on a perfectly good
/// config — so the config is the last thing to look at, not the first.
pub fn windows_start_failure(code: i32, binary: &Path, config: &Path) -> String {
windows_start_failure_for(WINDOWS_SERVICE, MIN_SERVICE_SIDECAR, code, binary, config)
}
/// [`windows_start_failure`] for any service: `min_sidecar` is the first release of that sidecar
/// that speaks the SCM handshake.
pub fn windows_start_failure_for(
name: &str,
min_sidecar: &str,
code: i32,
binary: &Path,
config: &Path,
) -> String {
let command = crate::util::command_line("sc.exe", &["start", name]);
match code {
1053 => format!(
"`{command}` failed with 1053 — the service did not respond to the start request in \
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(),
MIN_SERVICE_SIDECAR = min_sidecar,
),
// ERROR_SERVICE_LOGON_FAILED. The account is the virtual one the SCM makes itself, so this
// is a policy that forbids virtual service accounts rather than a wrong password.
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_account_for(name),
),
_ => format!(
"`{command}` failed with exit code {code}.\n\n Check the Windows event log \
(System, source \"Service Control Manager\"), and `sc query {name}` for \
the service's own exit code. A sidecar that exits immediately usually cannot read its \
config: {}\n\n Running it in the foreground prints the reason:\n\n \
\"{}\" --config \"{}\"",
config.display(),
binary.display(),
config.display(),
),
}
}
#[cfg(windows)]
fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
let account = windows_service_account();
register_windows_named(WINDOWS_SERVICE, DISPLAY_NAME, binary, config, restart)
}
if windows_service_exists() {
/// Registers (or reconfigures) one SCM service and sets its restart policy. It does not start it:
/// [`start_registered`] does, after [`grant_service_access`].
#[cfg(windows)]
fn register_windows_named(
name: &str,
display: &str,
binary: &Path,
config: &Path,
restart: bool,
) -> Result<Outcome> {
let bin_path = windows_bin_path(binary, config);
let account = windows_account_for(name);
if windows_service_exists(name) {
// `config` rather than delete-and-recreate: recreating would drop the failure actions and,
// more to the point, would briefly leave a host with no service if the create half failed.
run_ok(
"sc.exe",
&[
"config",
WINDOWS_SERVICE,
"binPath=",
&bin_path,
"start=",
"auto",
"obj=",
&account,
"config", name, "binPath=", &bin_path, "start=", "auto", "obj=", &account,
],
)?;
} else {
@@ -333,7 +429,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"sc.exe",
&[
"create",
WINDOWS_SERVICE,
name,
"binPath=",
&bin_path,
"start=",
@@ -341,7 +437,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"obj=",
&account,
"DisplayName=",
DISPLAY_NAME,
display,
],
)
.context(
@@ -349,7 +445,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
(no password); if this host's policy forbids them, register the service by hand — \
INSTALL.md Appendix A4.",
)?;
let _ = run("sc.exe", &["description", WINDOWS_SERVICE, DISPLAY_NAME]);
let _ = run("sc.exe", &["description", name, display]);
}
// Restart on failure, matching systemd's Restart=on-failure / RestartSec=5. `reset= 86400`
@@ -359,7 +455,7 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
"sc.exe",
&[
"failure",
WINDOWS_SERVICE,
name,
"reset=",
"86400",
"actions=",
@@ -367,44 +463,38 @@ fn register_windows(binary: &Path, config: &Path, restart: bool) -> Result<Outco
],
)?;
if restart && windows_service_state().contains("RUNNING") {
stop_windows_service()?;
}
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", WINDOWS_SERVICE])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(
"`{}` 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),
config.display()
);
if restart && windows_service_state(name).contains("RUNNING") {
stop_windows_service(name)?;
}
// NOT started here: the virtual account `sc create` just made cannot read the config yet —
// `protect_config` locked it to SYSTEM and Administrators, and `grant_service_access` is what
// lets the account in. Started first, the sidecar died on `Access is denied` and the service
// sat STOPPED; the restart policy never fires for a clean exit with an error code. The phase 18
// walk found it (D157). The caller grants, then calls `start_registered`.
Ok(Outcome::Registered {
kind: "windows-scm",
name: WINDOWS_SERVICE.to_string(),
name: name.to_string(),
unit_path: None,
user: Some(account),
user_created: false,
state: format!("{}, automatic start", windows_service_state()),
state: format!("{}, automatic start", windows_service_state(name)),
})
}
/// 1060 is ERROR_SERVICE_DOES_NOT_EXIST. Anything else — including an access-denied — is treated as
/// "it exists", so the caller reconfigures rather than trying to create a service that is there.
#[cfg(windows)]
fn windows_service_exists() -> bool {
match run("sc.exe", &["query", WINDOWS_SERVICE]) {
fn windows_service_exists(name: &str) -> bool {
match run("sc.exe", &["query", name]) {
Ok(output) => output.status.code() != Some(1060),
Err(_) => false,
}
}
#[cfg(windows)]
fn windows_service_state() -> String {
let Ok(output) = run("sc.exe", &["query", WINDOWS_SERVICE]) else {
fn windows_service_state(name: &str) -> String {
let Ok(output) = run("sc.exe", &["query", name]) else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
@@ -420,12 +510,12 @@ fn windows_service_state() -> String {
}
#[cfg(windows)]
fn stop_windows_service() -> Result<()> {
fn stop_windows_service(name: &str) -> Result<()> {
// 1062 is ERROR_SERVICE_NOT_ACTIVE — already the state being asked for.
let stop = run("sc.exe", &["stop", WINDOWS_SERVICE])?;
let stop = run("sc.exe", &["stop", name])?;
if !stop.status.success() && stop.status.code() != Some(1062) {
anyhow::bail!(
"cannot stop {WINDOWS_SERVICE} (exit code {}). The sidecar binary is locked while the \
"cannot stop {name} (exit code {}). The sidecar binary is locked while the \
service runs, so the install cannot replace it.",
stop.status.code().unwrap_or(-1)
);
@@ -434,17 +524,259 @@ fn stop_windows_service() -> Result<()> {
// actually exits. Polling is the only way to know, and a fixed sleep would be either too short
// or a delay on every run.
for _ in 0..30 {
if windows_service_state() == "STOPPED" {
if windows_service_state(name) == "STOPPED" {
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
anyhow::bail!(
"{WINDOWS_SERVICE} did not stop within 15 seconds. Stop it by hand and re-run: \
sc.exe stop {WINDOWS_SERVICE}"
"{name} did not stop within 15 seconds. Stop it by hand and re-run: \
sc.exe stop {name}"
)
}
// ── Rust instances (docs/modules/rust/PLAN.md §34.2.3, D148) ─────────────────
//
// One sidecar binary per host and one service per instance. On Linux that is a systemd TEMPLATE
// unit, so every instance runs the same definition with its own config; on Windows it is one SCM
// service per instance, each under its own virtual account. The ServUO service above is untouched:
// a host running both games has three kinds of name and none of them collide.
/// The template unit file. Instances are `runicgateway-rust@<id>.service`.
pub const RUST_TEMPLATE_UNIT: &str = "runicgateway-rust@.service";
/// The prefix of every Rust instance's SCM service name, `RunicGatewayRust-<id>`. Rust-Link's
/// `windows.rs` carries the same literal.
pub const RUST_WINDOWS_PREFIX: &str = "RunicGatewayRust-";
/// The first Rust-Link release whose sidecar speaks the SCM handshake — its first release at all.
#[cfg(windows)]
const MIN_RUST_SERVICE_SIDECAR: &str = "v0.1.0";
/// The systemd instance name for a server id.
pub fn rust_systemd_instance(server_id: &str) -> String {
format!("runicgateway-rust@{server_id}.service")
}
/// The Windows service name for a server id.
pub fn rust_windows_service(server_id: &str) -> String {
format!("{RUST_WINDOWS_PREFIX}{server_id}")
}
/// The service name this platform uses for an instance.
pub fn rust_service_name(server_id: &str) -> String {
if cfg!(windows) {
rust_windows_service(server_id)
} else {
rust_systemd_instance(server_id)
}
}
/// Whether a Windows service name is one this installer registers — the ServUO sidecar's, or a Rust
/// instance's. Anything else in a record is not ours to query, stop or delete.
pub fn is_ours(name: &str) -> bool {
name == WINDOWS_SERVICE || name.starts_with(RUST_WINDOWS_PREFIX)
}
/// The template unit. `%i` is the server id, so each instance reads its own config — which names
/// its own ports and its own database (see `crate::rustgame`). Pure, so it is a test.
pub fn rust_template_unit_text(binary: &Path, config_dir: &Path, user: &str) -> String {
format!(
"# Runic Gateway rust-link sidecar, one instance per Rust server.\n\
#\n\
# Generated by the Runic Gateway installer {installer}. Instances are\n\
# {instance}; each reads {config_dir}/<id>.toml.\n\
# Local edits belong in a drop-in: systemctl edit runicgateway-rust@<id>.service\n\
\n\
[Unit]\n\
Description=Runic Gateway rust-link sidecar (%i)\n\
After=network.target\n\
\n\
[Service]\n\
Type=simple\n\
User={user}\n\
Environment=RUSTLINK_CONFIG={config_dir}/%i.toml\n\
ExecStart={binary}\n\
Restart=on-failure\n\
RestartSec=5\n\
\n\
[Install]\n\
WantedBy=multi-user.target\n",
installer = env!("CARGO_PKG_VERSION"),
instance = rust_systemd_instance("<id>"),
config_dir = config_dir.display(),
binary = binary.display(),
)
}
/// Registers, enables and starts one Rust instance — or explains why it did not.
///
/// `restart` is true when the shared binary was just replaced: every instance runs it, so every
/// instance the run touches is restarted rather than started.
pub fn register_rust(
prepared: &Prepared,
layout: &crate::paths::Layout,
server_id: &str,
config: &Path,
restart: bool,
) -> Result<Outcome> {
let binary = &layout.rust_sidecar_bin;
match &prepared.manager {
Manager::Unavailable(reason) => Ok(Outcome::Skipped {
reason: reason.clone(),
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
}),
#[cfg(unix)]
Manager::Systemd => {
let user = prepared.user.clone().unwrap_or_else(|| "root".into());
let text = rust_template_unit_text(binary, &layout.rust_config_dir(), &user);
let mut outcome = register_systemd_unit(
prepared,
&rust_systemd_instance(server_id),
&layout.rust_template_unit(),
&text,
restart,
)?;
// The template file is shared by every instance, so it is not this instance's to
// remove: `uninstall` deletes it with the last one.
if let Outcome::Registered { unit_path, .. } = &mut outcome {
*unit_path = None;
}
Ok(outcome)
}
#[cfg(windows)]
Manager::WindowsScm => register_windows_named(
&rust_windows_service(server_id),
&format!("Runic Gateway rust-link sidecar ({server_id})"),
binary,
config,
restart,
),
#[allow(unreachable_patterns)]
other => Ok(Outcome::Skipped {
reason: format!("{} is not supported by this build", other.kind()),
manual: rust_manual_steps(layout, server_id, config, !layout.relocated),
}),
}
}
/// Stops every listed Rust instance so the shared binary can be replaced under them.
pub fn stop_rust_instances(manager: &Manager, server_ids: &[String]) -> Result<()> {
let _ = server_ids;
match manager {
#[cfg(unix)]
Manager::Systemd => {
for id in server_ids {
let _ = run("systemctl", &["stop", &rust_systemd_instance(id)]);
}
Ok(())
}
#[cfg(windows)]
Manager::WindowsScm => {
for id in server_ids {
let name = rust_windows_service(id);
if windows_service_exists(&name) {
stop_windows_service(&name)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
/// Starts (or restarts) an instance that is already registered, without re-registering it. Used by
/// `update` for instances whose own files did not change but whose binary did.
pub fn restart_rust_instance(server_id: &str) -> Result<()> {
#[cfg(unix)]
{
run_ok("systemctl", &["restart", &rust_systemd_instance(server_id)])?;
}
#[cfg(windows)]
{
let name = rust_windows_service(server_id);
let start = run("sc.exe", &["start", &name])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(
"cannot start {name} (exit code {})",
start.status.code().unwrap_or(-1)
);
}
}
Ok(())
}
/// Removes the shared template unit, once the last Rust instance is gone. Linux only; Windows has
/// no shared definition.
pub fn remove_rust_template(layout: &crate::paths::Layout) -> Option<String> {
#[cfg(unix)]
{
let path = layout.rust_template_unit();
if path.exists() && std::fs::remove_file(&path).is_ok() {
let _ = run("systemctl", &["daemon-reload"]);
return Some(format!("removed {}", path.display()));
}
}
let _ = layout;
None
}
/// What to do by hand for one instance when no service was registered.
pub fn rust_manual_steps(
layout: &crate::paths::Layout,
server_id: &str,
config: &Path,
config_protected: bool,
) -> String {
let binary = &layout.rust_sidecar_bin;
let data_dir = layout.rust_data_dir(server_id);
#[cfg(windows)]
{
let name = rust_windows_service(server_id);
let account = windows_account_for(&name);
format!(
" From an elevated PowerShell:\n\n \
sc.exe create {name} binPath= '{bin_path}' obj= '{account}' start= auto\n \
sc.exe failure {name} reset= 86400 actions= restart/5000\n \
icacls '{config}' /grant '{account}:(R)'\n \
icacls '{data_dir}' /grant '{account}:(OI)(CI)M'\n \
sc.exe start {name}\n\n{token_note}",
bin_path = windows_bin_path(binary, config),
config = config.display(),
data_dir = data_dir.display(),
token_note = if config_protected {
" That config file has already been restricted to Administrators and SYSTEM, \
because it holds\n the auth token.\n"
} else {
" That config file holds the auth token, and this run did NOT restrict it. \
Lock it down too.\n"
},
)
}
#[cfg(not(windows))]
{
let _ = config_protected;
format!(
" Write this to /etc/systemd/system/{RUST_TEMPLATE_UNIT}:\n\n{unit}\n \
Then:\n\n \
useradd --system --no-create-home --shell /usr/sbin/nologin {SERVICE_USER}\n \
chown {SERVICE_USER} {config}\n \
chown -R {SERVICE_USER} {data_dir}\n \
systemctl daemon-reload\n \
systemctl enable --now {instance}\n\n \
Without systemd, run {binary} with RUSTLINK_CONFIG={config}\n \
as an unprivileged user that can write {data_dir}.\n",
unit = indent(&rust_template_unit_text(
binary,
&layout.rust_config_dir(),
SERVICE_USER
)),
config = config.display(),
data_dir = data_dir.display(),
instance = rust_systemd_instance(server_id),
binary = binary.display(),
)
}
}
// ── Shared entry points ──────────────────────────────────────────────────────
/// Stops a running service so its binary can be replaced.
@@ -463,8 +795,8 @@ pub fn stop_for_replacement(manager: &Manager) -> Result<()> {
}
#[cfg(windows)]
Manager::WindowsScm => {
if windows_service_exists() {
stop_windows_service()?;
if windows_service_exists(WINDOWS_SERVICE) {
stop_windows_service(WINDOWS_SERVICE)?;
}
Ok(())
}
@@ -531,13 +863,13 @@ fn observe_platform(kind: &str, name: &str) -> Status {
if kind != "windows-scm" {
return Status::absent(format!("recorded as {kind}, which this host does not run"));
}
// Only the service this installer registers is queried by name; anything else would be reading
// Only services this installer registers are queried by name; anything else would be reading
// another product's service out of a hand-edited record.
if name != WINDOWS_SERVICE || !windows_service_exists() {
if !is_ours(name) || !windows_service_exists(name) {
return Status::absent("not registered with the service manager".to_string());
}
let state = windows_service_state();
let start = windows_start_type();
let state = windows_service_state(name);
let start = windows_start_type(name);
Status {
present: true,
running: state.contains("RUNNING"),
@@ -549,8 +881,8 @@ fn observe_platform(kind: &str, name: &str) -> Status {
/// `sc qc` reports the start type; `sc query` does not. Read separately so a service that exists but
/// was set to manual start is reported as such rather than as healthy.
#[cfg(windows)]
fn windows_start_type() -> String {
let Ok(output) = run("sc.exe", &["qc", WINDOWS_SERVICE]) else {
fn windows_start_type(name: &str) -> String {
let Ok(output) = run("sc.exe", &["qc", name]) else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
@@ -627,8 +959,11 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
)),
}
} else if let Some(user) = record.user.as_deref() {
// Not "this installer did not create it": a Rust instance's service never owns the account
// even when this installer made it, because every instance shares it — the last instance's
// removal decides (`rustgame::uninstall`). Said that way, the line is true for both games.
out.done.push(format!(
"left the {user} account alone — this installer did not create it"
"left the {user} account alone — it is not this service's to remove"
));
}
out
@@ -645,7 +980,15 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
));
return out;
}
if !windows_service_exists() {
if !is_ours(&record.name) {
out.problems.push(format!(
"the record names a service called {}, which is not one this installer registers - \
left alone",
record.name
));
return out;
}
if !windows_service_exists(&record.name) {
out.done
.push(format!("{} was already unregistered", record.name));
return out;
@@ -654,7 +997,7 @@ fn remove_platform(record: &ServiceRecord) -> Removal {
// Stopping first is not politeness: `sc delete` on a running service only marks it for deletion,
// and the service — and its lock on the binary this uninstall is about to remove — survives
// until the process exits.
if let Err(error) = stop_windows_service() {
if let Err(error) = stop_windows_service(&record.name) {
out.problems
.push(error.to_string().replace('\n', " ").to_string());
} else {
@@ -823,6 +1166,47 @@ pub fn protect_config(
protect_config_platform(config, data_dir, user, relocated)
}
/// Starts a service that [`register`] or [`register_rust`] has just registered, once
/// [`grant_service_access`] has let its account read the config.
///
/// A no-op on Linux, where registration starts the unit itself: the service user was known before
/// the config existed, so `protect_config` had already handed the file to it. On Windows the order
/// is the whole point — see `register_windows_named`. The outcome's `state` is refreshed so the
/// run reports what the service is doing now, not what it was doing before it was started.
pub fn start_registered(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
start_registered_platform(outcome, binary, config)
}
#[cfg(unix)]
fn start_registered_platform(_outcome: &mut Outcome, _binary: &Path, _config: &Path) -> Result<()> {
Ok(())
}
#[cfg(windows)]
fn start_registered_platform(outcome: &mut Outcome, binary: &Path, config: &Path) -> Result<()> {
let Outcome::Registered { name, state, .. } = outcome else {
return Ok(());
};
let min_sidecar = if name.as_str() == WINDOWS_SERVICE {
MIN_SERVICE_SIDECAR
} else {
MIN_RUST_SERVICE_SIDECAR
};
// 1056 is ERROR_SERVICE_ALREADY_RUNNING, which is the desired end state, not a failure.
let start = run("sc.exe", &["start", name])?;
if !start.status.success() && start.status.code() != Some(1056) {
anyhow::bail!(windows_start_failure_for(
name,
min_sidecar,
start.status.code().unwrap_or(-1),
binary,
config
));
}
*state = format!("{}, automatic start", windows_service_state(name));
Ok(())
}
/// Grants the registered service account access to what it must read and write.
///
/// A no-op on Linux, where the account was known before the config existed and `protect_config`
@@ -900,10 +1284,14 @@ fn grant_service_access_platform(config: &Path, data_dir: &Path, outcome: &Outco
// Nothing to grant when nothing was registered: the account only exists because `sc create`
// made it, and a skipped registration leaves the config locked to Administrators — which is the
// right resting state for a host where no service is going to read it.
if !outcome.registered() {
// The account comes from what was registered: the ServUO service's, or one Rust instance's.
let Outcome::Registered {
user: Some(account),
..
} = outcome
else {
return Ok(());
}
let account = windows_service_account();
};
run_ok(
"icacls",
@@ -994,6 +1382,45 @@ mod tests {
}
}
#[test]
fn error_1053_is_diagnosed_as_a_handshake_not_a_bad_config() {
// The regression this guards: 1053 used to be reported as "a service that exits immediately
// usually cannot read its config", which is the one thing it almost never is. A reader who
// follows that sentence goes and stares at a config file that is fine.
let msg = windows_start_failure(
1053,
Path::new(r"C:\Program Files\RunicGateway\uo-link-sidecar.exe"),
Path::new(r"C:\ProgramData\RunicGateway\sidecar.toml"),
);
assert!(msg.contains("1053"), "{msg}");
assert!(msg.contains("handshake"), "{msg}");
assert!(!msg.contains("cannot read its config"), "{msg}");
// It has to name the two things that actually resolve it: check the version, and prove the
// binary is healthy by running it in the foreground.
assert!(msg.contains("--version"), "{msg}");
assert!(msg.contains(MIN_SERVICE_SIDECAR), "{msg}");
assert!(msg.contains("uo-link-sidecar.exe"), "{msg}");
assert!(msg.contains("sidecar.toml"), "{msg}");
}
#[test]
fn a_logon_failure_points_at_policy_not_a_password() {
let msg = windows_start_failure(1069, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("NT SERVICE\\RunicGatewayLink"), "{msg}");
assert!(msg.contains("policy"), "{msg}");
}
#[test]
fn an_unrecognized_code_still_says_how_to_see_the_real_error() {
// The fallback must not pretend to know the cause; it must hand over the two places the
// cause is actually written down.
let msg = windows_start_failure(5, Path::new("bin.exe"), Path::new("c.toml"));
assert!(msg.contains("exit code 5"), "{msg}");
assert!(msg.contains("event log"), "{msg}");
assert!(msg.contains("sc query RunicGatewayLink"), "{msg}");
assert!(msg.contains("--config"), "{msg}");
}
#[test]
fn the_manual_steps_are_a_complete_recipe() {
// This text is all an operator gets on a host the installer cannot drive, so it has to name
@@ -1062,6 +1489,7 @@ mod tests {
state_dir: PathBuf::from("/etc/runicgateway"),
data_dir: PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: true,
};
let outcome = register(&prepare(true), &layout, false).unwrap();

View File

@@ -163,7 +163,7 @@ pub fn normalize_version(raw: &str) -> String {
}
#[cfg(windows)]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
match path.to_str().and_then(|s| s.strip_prefix(r"\\?\")) {
Some(stripped) => PathBuf::from(stripped),
None => path,
@@ -171,7 +171,7 @@ fn strip_extended_prefix(path: PathBuf) -> PathBuf {
}
#[cfg(not(windows))]
fn strip_extended_prefix(path: PathBuf) -> PathBuf {
pub(crate) fn strip_extended_prefix(path: PathBuf) -> PathBuf {
path
}

View File

@@ -294,6 +294,18 @@ fn port_of(bind: &str) -> &str {
}
}
/// Where the shard settings live in the website's admin panel.
///
/// NOT `/admin/shard`, which is what this printed until 2026-08-24 and what an operator who ran
/// an older build still has in their scrollback. Those screens belong to the `uo` MODULE now, and
/// a module owns one path segment wherever it appears (website `MODULE_SYSTEM.md` §2.8), so the
/// page moved. The old path does not 404 — the SPA sends it to the dashboard, which is the worst
/// way for a link in a handoff to be wrong, because it looks like it worked.
///
/// API routes are NOT affected by that rule and keep `/api/v1/admin/shard/*`. This is the SPA URL
/// a person types.
const ADMIN_SHARD_PATH: &str = "/admin/uo/link";
/// The end-of-run block from PLAN.md §6 — the one manual step the installer cannot do.
///
/// Returned as a string rather than printed so it can be tested, and so the caller decides where it
@@ -312,12 +324,13 @@ pub fn handoff(doc: &ConfigDoc, host: &str, site_url: Option<&str>) -> String {
Protocol version {protocol}\n \
Auth token {token}\n \
(also in {config})\n\n\
Paste these into Admin → Shard on your Runic Gateway site:\n \
{site}/admin/shard\n\n\
Paste these into Admin → Shard (uo-link) on your Runic Gateway site:\n \
{site}{admin_path}\n\n\
The token is write-only once saved — the site will never show it back to you.\n",
protocol = doc.protocol,
token = doc.web.auth_token,
config = doc.config_path,
admin_path = ADMIN_SHARD_PATH,
)
}
@@ -401,7 +414,7 @@ mod tests {
assert!(block.contains(&doc.web.auth_token), "{block}");
// The trailing slash on the site URL must not produce a double slash in the link.
assert!(
block.contains("https://my-site.example/admin/shard"),
block.contains("https://my-site.example/admin/uo/link"),
"{block}"
);
assert!(block.contains("/etc/runicgateway/sidecar.toml"), "{block}");
@@ -412,7 +425,10 @@ mod tests {
// An unattended run has nobody to ask, and the token is far too useful to withhold over a
// link the operator does not need.
let block = handoff(&doc(), "shard", None);
assert!(block.contains("https://<your-site>/admin/shard"), "{block}");
assert!(
block.contains("https://<your-site>/admin/uo/link"),
"{block}"
);
assert!(block.contains("4f9c"), "{block}");
}

View File

@@ -95,6 +95,7 @@ pub fn run(
declared: Option<&Tier>,
layout: &paths::Layout,
prior: &[FeatureRecord],
backup: &mut crate::backup::Session,
) -> Result<Outcome> {
let declared_tier = Tier::resolve(declared);
if declared_tier.features.is_empty() {
@@ -131,7 +132,7 @@ pub fn run(
),
);
announce_new_features(&declared_tier, &tier);
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported);
return apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup);
}
match consent(cli, root, supported, &tier)? {
@@ -157,7 +158,7 @@ pub fn run(
}
}
apply_tier(cli, root, unpacked, &tier, layout, prior, supported)
apply_tier(cli, root, unpacked, &tier, layout, prior, supported, backup)
}
/// The subset of a release's tier that a previous run actually applied.
@@ -322,6 +323,7 @@ fn apply_tier(
layout: &paths::Layout,
prior: &[FeatureRecord],
supported: bool,
backup: &mut crate::backup::Session,
) -> Result<Outcome> {
let previous = patch::index_records(prior);
let mut records: Vec<FeatureRecord> = Vec::new();
@@ -353,7 +355,7 @@ fn apply_tier(
}
if !cli.verify {
write_feature(root, unpacked, feature, &resolved, layout)?;
write_feature(root, unpacked, feature, &resolved, layout, backup)?;
}
applied_patches += resolved.len();
@@ -521,9 +523,18 @@ fn write_feature(
feature: &Feature,
resolved: &[Resolved],
layout: &paths::Layout,
backup: &mut crate::backup::Session,
) -> Result<()> {
for r in resolved {
if let Resolution::Applicable { edits, .. } = &r.resolution {
// `patches/originals/` holds the pre-*tier* copy and is never overwritten, which is the
// right thing to revert to. It is not a copy of what this file looked like before *this*
// run, though — on a second tier pass the operator's own later edits are only in the
// backup (PLAN.md §5.3).
backup.capture(
&patch::join(&root.path, &r.target),
crate::backup::Reason::PatchTarget,
)?;
let original = patch::join(&layout.patch_originals_dir(), &r.target);
if !original.exists() {
write_atomic(&original, &r.content)
@@ -542,6 +553,9 @@ fn write_feature(
for companion in &feature.companions {
let src = patch::join(unpacked, &companion.file);
let dst = patch::join(&root.path, &companion.install_to);
// Copied unconditionally, like every other `.cs` the overlay owns — so an operator who
// edited one loses it here unless a copy is taken first.
backup.capture(&dst, crate::backup::Reason::PatchCompanion)?;
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
@@ -717,6 +731,7 @@ mod tests {
state_dir: std::path::PathBuf::from("/etc/runicgateway"),
data_dir: std::path::PathBuf::from("/var/lib/runicgateway"),
sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-link"),
rust_sidecar_bin: std::path::PathBuf::from("/usr/bin/runicgateway-rust-link"),
relocated: false,
}
}

View File

@@ -121,11 +121,24 @@ pub fn run(cli: &Cli) -> Result<i32> {
if cli.purge {
remove_dir(&layout.patches_dir(), &mut done, &mut problems);
} else if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
remove_dir(&layout.backups_dir(), &mut done, &mut problems);
} else {
if layout.patches_dir().exists() {
done.push(format!(
"kept {} — the cached patches and the pre-patch originals you need to revert by hand",
layout.patches_dir().display()
));
}
// Same rule and the same reason as the patch cache: a backup is the only copy of what this
// host had before an upgrade replaced it, and it outlives the deployment that took it.
let backups = crate::backup::list(&layout);
if !backups.is_empty() {
done.push(format!(
"kept {} — {} backup(s) of files earlier runs replaced",
layout.backups_dir().display(),
backups.len()
));
}
}
remove_file(&record_path, &mut done, &mut problems);
@@ -181,6 +194,7 @@ fn print_intent(
println!(" · {}", layout.install_record().display());
if purge {
println!(" · {} [--purge]", layout.patches_dir().display());
println!(" · {} [--purge]", layout.backups_dir().display());
}
println!();
@@ -199,6 +213,14 @@ fn print_intent(
" · {} (cached patches and pre-patch originals)",
layout.patches_dir().display()
);
let backups = crate::backup::list(layout);
if !backups.is_empty() {
println!(
" · {} ({} backup(s) of files earlier runs replaced)",
layout.backups_dir().display(),
backups.len()
);
}
}
let _ = record;
println!();
@@ -241,6 +263,7 @@ fn render_report(
render_overlay_section(&mut out, record);
render_patch_section(&mut out, record, layout, purge);
render_backup_section(&mut out, layout, purge);
let _ = writeln!(
out,
@@ -373,6 +396,50 @@ fn render_patch_section(
}
}
/// The backups earlier runs took, since this report is the durable record of what was left behind.
///
/// Listed rather than summarized: a backup is only useful to someone who knows it exists, and by
/// the time this report is read the run that took it is long out of the scrollback.
fn render_backup_section(out: &mut String, layout: &paths::Layout, purge: bool) {
let backups = crate::backup::list(layout);
if backups.is_empty() {
return;
}
if purge {
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
{} backup(s) of files earlier runs replaced were removed by --purge.
",
backups.len()
);
return;
}
let _ = writeln!(
out,
"
── Backups ──────────────────────────────────────────────────────────────────
Copies of the files earlier runs replaced, newest first. These are kept:
"
);
for dir in &backups {
let count = crate::backup::read_manifest(dir)
.map(|m| m.files.len())
.unwrap_or(0);
let _ = writeln!(out, " {} ({} file(s))", dir.display(), count);
}
let _ = writeln!(
out,
"
Each carries a manifest.json naming where every file came from. Restoring is yours to
do — this tool will not put an old file back over a newer one. `--purge` removes them.
"
);
}
/// Renders one cached patch's added and removed lines, indented for the report.
fn render_hunks(layout: &paths::Layout, name: &str, sha256: &str) -> Option<String> {
let path = layout.patches_dir().join(format!("{name}.patch"));
@@ -556,6 +623,7 @@ mod tests {
state_dir: dir.to_path_buf(),
data_dir: dir.join("data"),
sidecar_bin: dir.join("bin").join("uo-link-sidecar"),
rust_sidecar_bin: dir.join("bin").join("rust-link-sidecar"),
relocated: true,
}
}

View File

@@ -81,7 +81,7 @@ pub fn closing(prior: Option<&InstallRecord>, bundle: &Bundle, now: &InstallReco
println!();
ui::warn(&format!(
"The protocol version changed: {} → {}.\n \
Update the Protocol version field in Admin → Shard on your website. Nothing else \
Update the Protocol version field in Admin → Shard (uo-link) on your website. Nothing else \
changed —\n the URLs and the auth token are the same, and the sidecar answers a \
website still set to\n {} with 409 rather than mis-parsing it.",
previous_protocol.unwrap_or(bundle.protocol),

View File

@@ -0,0 +1,45 @@
{
"schema": 1,
"bundle": "2026.09.15",
"generated": "2026-09-15T00:12:17Z",
"protocol": 8,
"link": {
"repo": "RunicGateway/link",
"tag": "v2.3.0",
"version": "2.3.0",
"protocol": 8,
"assets": {
"linux-aarch64": {
"name": "uo-link-sidecar-linux-aarch64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
},
"linux-x86_64": {
"name": "uo-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
},
"windows-x86_64": {
"name": "uo-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
}
}
},
"overlay": {
"repo": "RunicGateway/servuo-plugins",
"tag": "v1.3.0",
"version": "1.3.0",
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
"protocol": 8,
"servuo": {
"min_version": "57.4",
"patches_verified_against": "57.4"
},
"asset": {
"name": "runicgateway-overlay-1.3.0.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
}
}
}

View File

@@ -0,0 +1,47 @@
{
"schema": 2,
"game": "servuo",
"bundle": "2026.09.15",
"generated": "2026-09-26T03:55:28Z",
"protocol": 8,
"sidecar": {
"repo": "RunicGateway/link",
"tag": "v2.3.0",
"version": "2.3.0",
"protocol": 8,
"assets": {
"linux-aarch64": {
"name": "uo-link-sidecar-linux-aarch64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-aarch64",
"sha256": "406a69a140a36e8ac5090c2b101a28a6068b8c424fc261616e0edf4de0f4accf"
},
"linux-x86_64": {
"name": "uo-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-linux-x86_64",
"sha256": "504763855e58e75146a409786dcfa8b674b43a161bec79699c66c06a6c912e36"
},
"windows-x86_64": {
"name": "uo-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/link/releases/download/v2.3.0/uo-link-sidecar-windows-x86_64.exe",
"sha256": "0daeb427f2fe4256cd6a971daab1bafe151901f8102b2a14c6ab12605ab80110"
}
}
},
"payload": {
"kind": "overlay",
"repo": "RunicGateway/servuo-plugins",
"tag": "v1.3.0",
"version": "1.3.0",
"commit": "59a6c446c6c5b546c424ee8d23ac92831b3ab641",
"protocol": 8,
"asset": {
"name": "runicgateway-overlay-1.3.0.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/download/v1.3.0/runicgateway-overlay-1.3.0.tar.gz",
"sha256": "b6d6b67f94cde9d8b3b431a89ac125c5be10bcda14d8786422a01cc61ce37dc1"
},
"compat": {
"min_version": "57.4",
"patches_verified_against": "57.4"
}
}
}

57
tests/fixtures/rust-bundle-v2.json vendored Normal file
View File

@@ -0,0 +1,57 @@
{
"schema": 2,
"game": "rust",
"bundle": "2026.09.26",
"generated": "2026-09-26T04:06:03Z",
"protocol": 12,
"sidecar": {
"repo": "RunicGateway/Rust-Link",
"tag": "v0.1.0",
"version": "0.1.0",
"protocol": 12,
"assets": {
"linux-x86_64": {
"name": "rust-link-sidecar-linux-x86_64",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-linux-x86_64",
"sha256": "3f69c56a78ea0f9a886286078744710a983b0058b61cd0be0e69a2b4db312bfe"
},
"windows-x86_64": {
"name": "rust-link-sidecar-windows-x86_64.exe",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/rust-link-sidecar-windows-x86_64.exe",
"sha256": "e8cc61bb5681ead44baf2dcd661c323630b53a84b834447410a0bc1e5b1b1e8c"
}
},
"launcher": {
"name": "with-sidecar.sh",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Link/releases/download/v0.1.0/with-sidecar.sh",
"sha256": "b1d785873f0528663f12cf1b008ec2581ba7bd7282395203d8d5915e19ceb712"
}
},
"payload": {
"kind": "plugin",
"repo": "RunicGateway/Rust-Plugins",
"tag": "v0.2.3",
"version": "0.2.3",
"commit": "deadbeef",
"protocol": 12,
"compat": {
"frameworks": {
"oxide": {
"min_version": "2.0.7585"
},
"carbon": {
"min_version": "2.0.259"
}
},
"requires_plugins": [
"Kits",
"ZoneManager"
]
},
"asset": {
"name": "runicgateway-rust-plugin-0.2.3.tar.gz",
"url": "https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/releases/download/v0.2.3/runicgateway-rust-plugin-0.2.3.tar.gz",
"sha256": "2ca6269dba329b06d0d36e5786f6f88c10757c38a0b1dd7efdbcd4e803b94c78"
}
}
}