From d48d8c473c007b6dd1f6a2cdc475aa2c5d9ae0da Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 26 Oct 2026 12:23:52 -0500 Subject: [PATCH] feat(demo): deploy/demo and the wipe + baseline workflows (step 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public demo's deployment (docs/website/DEMO_MODE.md §7, §10 step 5): - deploy/demo/docker-compose.yml: the app and its database only, DEMO_MODE on, a /status healthcheck, the fixture mounted for seed:demo, MODULES read from state/modules.env so the workflow never edits the .env with secrets. - deploy/demo/.env.example: every variable the host needs, including BOT_INTERNAL_KEY (the app refuses to start in production without it) and MODULE_SOURCE_HOSTS (a fresh database's install allowlist). - deploy/demo/seed.json: the four logins, settings, two posts, a wiki page, a published "Server greeting" event on a Rust console step, the rig's server link and demo-player's Steam link; secrets as "$env:NAME". - deploy/demo/rig.mjs: plan (latest Rust bundle + newest Module-Rust on the same protocol, keeping the previous set when none matches), restore (kill, write a changed bundle, delete + unpack demo-baseline.tar.gz, start), baseline (power stop saves the world, DM22; compress, rename, start) and smoke (the site, then the rig reporting in), on the Pterodactyl client API. - .gitea/workflows/demo-wipe.yml: every 2 h on the hour UTC, on a push to deploy/demo/, and by hand; rig first, then down -v / pull / up / seed. - .gitea/workflows/demo-baseline.yml: by hand only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/demo-baseline.yml | 42 ++++ .gitea/workflows/demo-wipe.yml | 114 +++++++++ deploy/demo/.env.example | 61 +++++ deploy/demo/README.md | 53 ++++ deploy/demo/docker-compose.yml | 70 ++++++ deploy/demo/rig.mjs | 372 +++++++++++++++++++++++++++++ deploy/demo/seed.json | 79 ++++++ 7 files changed, 791 insertions(+) create mode 100644 .gitea/workflows/demo-baseline.yml create mode 100644 .gitea/workflows/demo-wipe.yml create mode 100644 deploy/demo/.env.example create mode 100644 deploy/demo/README.md create mode 100644 deploy/demo/docker-compose.yml create mode 100644 deploy/demo/rig.mjs create mode 100644 deploy/demo/seed.json diff --git a/.gitea/workflows/demo-baseline.yml b/.gitea/workflows/demo-baseline.yml new file mode 100644 index 0000000..6bf7c09 --- /dev/null +++ b/.gitea/workflows/demo-baseline.yml @@ -0,0 +1,42 @@ +# Capture the demo rig's baseline (docs/website/DEMO_MODE.md §7.2). Run by hand, +# after setting the demo default set up through the demo site: the permission +# groups, a few NPCs, a clan for the demo's Team. It stops the rig (a power stop +# runs `quit`, which saves the world, DM22), compresses oxide/data, oxide/config +# and server/ into demo-baseline.tar.gz at the server root, and starts +# it again. Every wipe from then on restores that archive. +# +# Run it right after a wipe has finished and the default set is in place, not +# while visitors are mid-change: whatever is on the rig is what every later +# cycle returns to. + +name: Demo baseline + +on: + workflow_dispatch: {} + +concurrency: + group: demo-rig + cancel-in-progress: false + +env: + DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }} + PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }} + PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }} + NODE_IMAGE: node:22.23.4-alpine + +jobs: + baseline: + runs-on: demo-site + if: github.ref == 'refs/heads/main' + steps: + - name: Check out the deploy files + uses: actions/checkout@v4 + + - name: Capture the baseline + env: + PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }} + run: | + set -euo pipefail + docker run --rm -v "$PWD/deploy/demo:/demo:ro" \ + -e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \ + "$NODE_IMAGE" node /demo/rig.mjs baseline diff --git a/.gitea/workflows/demo-wipe.yml b/.gitea/workflows/demo-wipe.yml new file mode 100644 index 0000000..3dbd6fa --- /dev/null +++ b/.gitea/workflows/demo-wipe.yml @@ -0,0 +1,114 @@ +# The public demo's wipe (docs/website/DEMO_MODE.md §7). Every 2 hours on the +# hour, UTC (DM7), it puts demo.runicgateway.com and its Rust server back to the +# demo's starting point: +# +# 1. versions the latest Rust bundle and the newest Module-Rust release on the +# same protocol (§7.5, DM12) +# 2. the rig FIRST: kill, write the new bundle if it changed, restore +# demo-baseline.tar.gz, start (§7.2). A fresh site imports what is +# on the rig, so the rig must be back before the site talks to it (§7.1) +# 3. the site down -v, pull, up, seed:demo (§7.3) +# 4. smoke the site answers, then the rig reports in (bounded) +# +# A redeploy is the same path: a push to main that changes deploy/demo/ runs it, +# and so does workflow_dispatch (§7.4). +# +# Runs on the `demo-site` runner, in host mode, on the demo host itself. The host +# needs Docker with Compose, and node for actions/checkout; everything else runs +# in containers. One-time host setup: deploy/demo/README.md. +# +# Secret: PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power) +# Variables: PTERODACTYL_URL, PTERODACTYL_SERVER (defaults below), DEMO_DIR + +name: Demo wipe + +on: + schedule: + - cron: '0 */2 * * *' + workflow_dispatch: {} + push: + branches: [main] + paths: + - 'deploy/demo/**' + - '.gitea/workflows/demo-wipe.yml' + +# A wipe and a baseline capture must never overlap: both stop the rig. +concurrency: + group: demo-rig + cancel-in-progress: false + +env: + DEMO_DIR: ${{ vars.DEMO_DIR || '/opt/demo.runicgateway.com' }} + PTERODACTYL_URL: ${{ vars.PTERODACTYL_URL || 'http://192.168.0.12' }} + PTERODACTYL_SERVER: ${{ vars.PTERODACTYL_SERVER || '6eb9d621' }} + NODE_IMAGE: node:22.23.4-alpine + +jobs: + wipe: + runs-on: demo-site + if: github.ref == 'refs/heads/main' + steps: + - name: Check out the deploy files + uses: actions/checkout@v4 + + - name: Install the deploy files + # Everything but .env, which only the host holds, and state/, which the + # wipe itself writes. + run: | + set -euo pipefail + test -f "$DEMO_DIR/.env" || { echo "::error::$DEMO_DIR/.env is missing; see deploy/demo/README.md"; exit 1; } + mkdir -p "$DEMO_DIR/state" "$DEMO_DIR/logs" "$DEMO_DIR/modules" + # The app runs as uid 1000 and unpacks the declared module into ./modules. + chown 1000:1000 "$DEMO_DIR/modules" "$DEMO_DIR/logs" 2>/dev/null || true + cp deploy/demo/docker-compose.yml deploy/demo/seed.json deploy/demo/rig.mjs "$DEMO_DIR/" + + - name: Decide the versions + run: | + set -euo pipefail + docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state "$NODE_IMAGE" node /demo/rig.mjs plan + + - name: Restore the rig + id: rig + # The site is wiped even when this fails: a demo left un-wiped is worse + # than one whose rig kept a visitor's changes for a cycle. The last step + # fails the run so the failure is seen. + continue-on-error: true + env: + PTERODACTYL_CLIENT_KEY: ${{ secrets.PTERODACTYL_CLIENT_KEY }} + run: | + set -euo pipefail + docker run --rm -v "$DEMO_DIR:/demo" -e STATE_DIR=/demo/state \ + -e PTERODACTYL_URL -e PTERODACTYL_SERVER -e PTERODACTYL_CLIENT_KEY \ + "$NODE_IMAGE" node /demo/rig.mjs restore + + - name: Wipe and reseed the site + run: | + set -euo pipefail + cd "$DEMO_DIR" + docker compose pull + docker compose down -v --remove-orphans + docker compose up -d + for attempt in $(seq 1 60); do + STATUS="$(docker inspect -f '{{.State.Health.Status}}' rg-demo-app-1 2>/dev/null || echo starting)" + [ "$STATUS" = healthy ] && break + sleep 5 + done + if [ "$STATUS" != healthy ]; then + echo "::error::the app did not become healthy within 300s" + docker compose logs --tail 100 app + exit 1 + fi + docker compose exec -T app npm run --silent --prefix server seed:demo -- /app/deploy/demo/seed.json + docker compose ps + + - name: Smoke check + run: | + set -euo pipefail + docker run --rm --network rg-demo_default -v "$DEMO_DIR:/demo" \ + -e SITE_URL=http://app:3000 "$NODE_IMAGE" node /demo/rig.mjs smoke + + - name: Report the rig restore + if: steps.rig.outcome == 'failure' + run: | + echo "::error::the rig restore failed (see its step); the site was wiped and reseeded anyway" + exit 1 diff --git a/deploy/demo/.env.example b/deploy/demo/.env.example new file mode 100644 index 0000000..7aab9af --- /dev/null +++ b/deploy/demo/.env.example @@ -0,0 +1,61 @@ +# ─── The demo site's environment (docs/website/DEMO_MODE.md) ─── +# Copy to /opt/demo.runicgateway.com/.env on the `demo-site` host and fill in. +# NEVER commit the real file. The wipe workflow never writes it; it only reads it. + +IMAGE_TAG=latest +NODE_ENV=production +PORT=3000 +# The host port the external reverse proxy forwards demo.runicgateway.com to. +DEMO_HOST_PORT=3000 +# How often the wipe runs, for the banner's "wiped at" time. Must match the cron +# in .gitea/workflows/demo-wipe.yml (every 2 hours on the hour, UTC, DM7). +DEMO_RESET_EVERY_HOURS=2 + +# Branding +BRAND_NAME=Runic Gateway Demo +BRAND_SHORT_NAME=RG Demo +BRAND_URL=https://demo.runicgateway.com +CLIENT_ORIGIN=https://demo.runicgateway.com + +# Database. Recreated empty at every wipe; the passwords only need to be stable. +DB_HOST=db +DB_PORT=3306 +DB_NAME=runic_gateway +DB_USER=runic +DB_PASSWORD= +DB_ROOT_PASSWORD= + +# Long random strings, e.g. `openssl rand -hex 32`. SECRET_ENC_KEY is required in +# production. Every stored secret is re-seeded at each wipe, so rotating either +# one only signs people out. +JWT_SECRET= +SECRET_ENC_KEY= +COOKIE_SECURE=auto +# The guard on the app's unpublished internal port. The demo runs no bot, but the +# app refuses to start in production without it. Another long random string. +BOT_INTERNAL_KEY= +# One proxy hop: the external reverse proxy. +TRUST_PROXY=1 + +# Leave ADMIN_USERNAME unset: the boot seed must not create a fifth admin. The four +# demo accounts come from seed.json (§4). +ADMIN_USERNAME= + +# Where modules may be installed from. A fresh database seeds the allowlist from +# this, and the declared module (state/modules.env) installs under it. +MODULE_SOURCE_HOSTS=gitea.whitlocktech.com + +# ── seed.json reads these through "$env:NAME" ── +# The published logins (§4) are not secrets, but they live here so the repo does +# not pin them. play-review's is NOT published; it goes in the Play Console only. +DEMO_ADMIN_PASSWORD= +DEMO_MODERATOR_PASSWORD= +DEMO_PLAYER_PASSWORD= +PLAY_REVIEW_PASSWORD= +# A stand-in Steam id that has played on the rig, so demo-player's Rust screens +# have data (DM6). +DEMO_PLAYER_STEAM_ID= +# The rig's sidecar web API (rust-oxide's last allocation) and its web token: the +# RUSTLINK_WEB_TOKEN in the rig's startup command, or rust-link/sidecar.toml. +DEMO_RIG_SIDECAR_URL=http://192.168.0.12:21004 +DEMO_RIG_SIDECAR_TOKEN= diff --git a/deploy/demo/README.md b/deploy/demo/README.md new file mode 100644 index 0000000..1c66711 --- /dev/null +++ b/deploy/demo/README.md @@ -0,0 +1,53 @@ +# deploy/demo — demo.runicgateway.com + +The public demo site: the normal image with `DEMO_MODE=true`, wiped every two hours together +with its Rust server. Design of record: `docs/website/DEMO_MODE.md`. + +| File | What it is | +| --- | --- | +| `docker-compose.yml` | The app and its database. No bot and no ntfy: the demo sends nothing outward (§5) | +| `.env.example` | Every variable the host's `.env` needs, with what each one is for | +| `seed.json` | The fixture `seed:demo` writes after every wipe: the four logins, settings, content, and the Rust module's server link and Steam link (§7.3). Passwords and credentials are `"$env:NAME"` references to `.env` | +| `rig.mjs` | Versions (`plan`), the rig restore (`restore`), the baseline capture (`baseline`) and the smoke check (`smoke`), against the Pterodactyl client API (§7.2, §7.5) | + +Two workflows drive it, both on the `demo-site` runner: + +- **`demo-wipe.yml`**: every 2 hours on the hour (UTC), on a push to `main` that changes this + directory, and by hand. Versions, then the rig, then the site, then a smoke check. +- **`demo-baseline.yml`**: by hand only. It captures the rig's current state as + `demo-baseline.tar.gz`, which every wipe restores. + +## One-time host setup + +1. The `demo-site` runner in **host mode** on the demo host, with Docker and Compose, and `node` + on the `PATH` (for `actions/checkout`). The host must reach `gitea.whitlocktech.com`, the + Pterodactyl panel and the rig's sidecar port. +2. `sudo mkdir -p /opt/demo.runicgateway.com`, owned by the runner's user (or the runner runs as + root). To use another path, set the `DEMO_DIR` Actions variable. +3. Copy `.env.example` to `/opt/demo.runicgateway.com/.env` and fill every blank. Generate + secrets on the host, e.g. `openssl rand -hex 32`. The file never leaves the host. +4. Log the host in to the registry if the images are not public: `docker login gitea.whitlocktech.com`. +5. Point the external reverse proxy at `:DEMO_HOST_PORT` (default 3000). +6. Run **Demo wipe** by hand. The first run finds no baseline, so it leaves the rig as it is and + starts it. + +Then set the demo default set up through the site: the permission groups, a few NPCs, a clan. Run +**Demo baseline** while nobody else is changing anything. Every later wipe returns the rig to that. + +The panel URL and the rig's server identifier default to `http://192.168.0.12` and `6eb9d621` +(`rust-oxide`). Override them with the `PTERODACTYL_URL` and `PTERODACTYL_SERVER` Actions +variables. `PTERODACTYL_CLIENT_KEY` is the demo subuser's key, an Actions secret. + +## What the wipe keeps on the host + +`state/` in the deploy directory: + +- `bundle.json`: the Rust bundle last written onto the rig. A new bundle is written only when + this differs. +- `modules.env`: `MODULES=rust@=`, the newest Module-Rust release on the bundle's + protocol. When no release matches a new bundle yet, the wipe keeps this file and the rig's + bundle as they were and warns (§7.5). +- `plan.json`: this cycle's decision, from `plan` to `restore`. + +`modules/` keeps the unpacked module between wipes. `down -v` drops the database and the uploads, +never this directory. diff --git a/deploy/demo/docker-compose.yml b/deploy/demo/docker-compose.yml new file mode 100644 index 0000000..43d40fa --- /dev/null +++ b/deploy/demo/docker-compose.yml @@ -0,0 +1,70 @@ +# The public demo, demo.runicgateway.com (docs/website/DEMO_MODE.md). +# +# The same image as every other deployment, switched by DEMO_MODE=true (§2). Only +# the app and its database: no bot and no ntfy, because the demo sends nothing +# outward (§5). The wipe workflow (.gitea/workflows/demo-wipe.yml) copies this +# file into the deploy directory on the `demo-site` host and runs it from there; +# the host's own `.env` beside it holds every secret. +# +# The project name is fixed so the network is `rg-demo_default` whatever the +# directory is called: the workflow's smoke check joins it to reach the app. +name: rg-demo + +services: + db: + image: mariadb:11 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + volumes: + # Dropped at every wipe (`down -v`) with the uploads volume: that is the wipe. + - dbdata:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + + app: + image: gitea.whitlocktech.com/runicgateway/website-app:${IMAGE_TAG:-latest} + restart: unless-stopped + # modules.env is written by the wipe (rig.mjs plan): MODULES=rust@=…, + # the newest Module-Rust release on the bundle's protocol (§7.5). Kept out of + # .env so the workflow never edits the file holding the secrets. + env_file: + - .env + - state/modules.env + environment: + DEMO_MODE: "true" + DB_HOST: db + UPLOAD_DIR: /app/uploads + LOG_DIR: /app/logs + MODULES_DIR: /app/modules + depends_on: + db: + condition: service_healthy + volumes: + - uploads:/app/uploads + - ./logs:/app/logs + # A bind mount, not a volume, so `down -v` keeps the unpacked module and a + # wipe does not download it again. Must be writable by uid 1000. + - ./modules:/app/modules + # The seed fixture (§7.3), read by `seed:demo` after every wipe. + - ./seed.json:/app/deploy/demo/seed.json:ro + # Busybox wget is in the Alpine image; `/status` answers once the server listens. + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3000/api/v1/public/status"] + interval: 10s + timeout: 5s + start_period: 30s + retries: 30 + # The external reverse proxy (DNS + HTTPS) forwards here. + ports: + - "${DEMO_HOST_PORT:-3000}:3000" + +volumes: + dbdata: + uploads: diff --git a/deploy/demo/rig.mjs b/deploy/demo/rig.mjs new file mode 100644 index 0000000..3c01ef4 --- /dev/null +++ b/deploy/demo/rig.mjs @@ -0,0 +1,372 @@ +// ── The demo's rig and versions (docs/website/DEMO_MODE.md §7.2, §7.5) ────── +// +// One script, four commands, run by the demo workflows on the `demo-site` host +// inside a stock Node container (so the host needs Docker and nothing else): +// +// node rig.mjs plan decide the versions for this cycle (§7.5) +// node rig.mjs restore put the rig back to the baseline, updating its plugins +// and sidecar first when the bundle changed (§7.2) +// node rig.mjs baseline capture the rig's current state as the baseline (§7.2) +// node rig.mjs smoke wait for the site, then for the rig to report in (§7.3.6) +// +// State lives in $STATE_DIR (the deploy directory's `state/`): +// bundle.json the bundle last written onto the rig +// modules.env MODULES=rust@=, read by docker-compose.yml +// plan.json this cycle's decision, written by `plan`, read by `restore` +// +// Environment: +// PTERODACTYL_URL the panel, e.g. http://192.168.0.12 +// PTERODACTYL_SERVER the rig's short identifier, e.g. e6758c06 +// PTERODACTYL_CLIENT_KEY the demo subuser's client key (file + power only) +// PTERODACTYL_IDENTITY optional: the folder under /server holding the save; +// found by listing /server when unset +// GITEA_URL default https://gitea.whitlocktech.com +// SITE_URL for `smoke`, e.g. http://app:3000 +// SMOKE_TIMEOUT_SECONDS for `smoke`, default 900 (a Rust boot is ~9 minutes) +// +// Dependency-free on purpose: Node's own fetch and crypto, and the image's tar. + +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const GITEA = (process.env.GITEA_URL || 'https://gitea.whitlocktech.com').replace(/\/+$/, '') +const STATE_DIR = process.env.STATE_DIR || path.join(path.dirname(fileURLToPath(import.meta.url)), 'state') +const BASELINE = 'demo-baseline.tar.gz' +const MODULE_REPO = 'RunicGateway/Module-Rust' +const BUNDLE_PATH = 'RunicGateway/installer/contents/v2/rust/current.json?ref=bundles' + +const log = (...a) => console.log(...a) +const warn = (msg) => console.log(`::warning::${msg}`) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +function need(name) { + const v = process.env[name] + if (!v) throw new Error(`${name} is not set`) + return v +} + +const statePath = (f) => path.join(STATE_DIR, f) +function readJson(file, fallback = null) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) + } catch { + return fallback + } +} +function writeFile(file, text) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file, text) +} + +// ── Gitea ────────────────────────────────────────────────────────────────── + +// The JSON API, never /raw/: /raw/ is CDN-cached for hours on this instance, and +// a stale current.json would pin the demo to an old bundle without a word. +async function gitea(apiPath) { + const res = await fetch(`${GITEA}/api/v1/repos/${apiPath}`, { headers: { Accept: 'application/json' } }) + if (!res.ok) throw new Error(`GET ${apiPath}: HTTP ${res.status}`) + return res.json() +} + +async function currentBundle() { + const file = await gitea(BUNDLE_PATH) + return JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')) +} + +async function download(url, sha256) { + const res = await fetch(url) + if (!res.ok) throw new Error(`GET ${url}: HTTP ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + if (sha256) { + const got = createHash('sha256').update(buf).digest('hex') + if (got !== sha256) throw new Error(`${url}: sha256 ${got} does not match the bundle's ${sha256}`) + } + return buf +} + +const semver = (v) => String(v).replace(/^v/, '').split('.').map((n) => Number.parseInt(n, 10) || 0) +function newerFirst(a, b) { + const x = semver(a) + const y = semver(b) + for (let i = 0; i < 3; i += 1) if (x[i] !== y[i]) return y[i] - x[i] + return 0 +} + +/** The newest Module-Rust release whose install manifest says `protocol` (DM12). */ +async function moduleFor(protocol) { + const releases = (await gitea(`${MODULE_REPO}/releases?limit=50`)) + .filter((r) => !r.draft && !r.prerelease) + .sort((a, b) => newerFirst(a.tag_name, b.tag_name)) + for (const r of releases) { + const asset = (r.assets || []).find((a) => /^module-rust-.+\.json$/.test(a.name)) + if (!asset) continue + const res = await fetch(asset.browser_download_url) + if (!res.ok) continue + const manifest = await res.json() + // Releases from before §7.5 carry no `protocol`; they never match. + if (manifest.protocol === protocol) { + return { version: manifest.version, manifestUrl: asset.browser_download_url } + } + } + return null +} + +// ── Pterodactyl (client API: files + power only) ───────────────────────── + +function ptero() { + const base = `${need('PTERODACTYL_URL').replace(/\/+$/, '')}/api/client/servers/${need('PTERODACTYL_SERVER')}` + const key = need('PTERODACTYL_CLIENT_KEY') + + async function call(method, sub, { json, body, type, raw = false } = {}) { + const headers = { Authorization: `Bearer ${key}`, Accept: 'application/json' } + let payload + if (json !== undefined) { + headers['Content-Type'] = 'application/json' + payload = JSON.stringify(json) + } else if (body !== undefined) { + headers['Content-Type'] = type || 'application/octet-stream' + payload = body + } + const res = await fetch(`${base}${sub}`, { method, headers, body: payload }) + const text = await res.text() + if (!res.ok) throw new Error(`${method} ${sub}: HTTP ${res.status} ${text.slice(0, 300)}`) + if (raw) return text + return text ? JSON.parse(text) : null + } + + return { + state: async () => (await call('GET', '/resources')).attributes.current_state, + power: (signal) => call('POST', '/power', { json: { signal } }), + list: async (dir) => + (await call('GET', `/files/list?directory=${encodeURIComponent(dir)}`)).data.map((f) => f.attributes), + remove: (files) => call('POST', '/files/delete', { json: { root: '/', files } }), + decompress: (file) => call('POST', '/files/decompress', { json: { root: '/', file } }), + compress: async (files) => (await call('POST', '/files/compress', { json: { root: '/', files } })).attributes, + rename: (from, to) => call('PUT', '/files/rename', { json: { root: '/', files: [{ from, to }] } }), + read: (file) => call('GET', `/files/contents?file=${encodeURIComponent(file)}`, { raw: true }), + write: (file, body, type) => call('POST', `/files/write?file=${encodeURIComponent(file)}`, { body, type }), + // One file per call: a chmod naming two applies only the first, silently. + chmod: (dir, file, mode) => call('POST', '/files/chmod', { json: { root: dir, files: [{ file, mode }] } }), + } +} + +async function waitFor(p, wanted, seconds) { + const until = Date.now() + seconds * 1000 + for (;;) { + const s = await p.state() + if (s === wanted) return + if (Date.now() > until) throw new Error(`the rig is "${s}" after ${seconds}s, expected "${wanted}"`) + await sleep(5000) + } +} + +/** The folder under /server holding the world save and cfg/. */ +async function identity(p) { + if (process.env.PTERODACTYL_IDENTITY) return process.env.PTERODACTYL_IDENTITY + const dirs = (await p.list('/server')).filter((f) => !f.is_file).map((f) => f.name) + if (dirs.length !== 1) { + throw new Error(`cannot tell the server identity: /server holds ${JSON.stringify(dirs)}; set PTERODACTYL_IDENTITY`) + } + return dirs[0] +} + +const baselineDirs = (id) => ['oxide/data', 'oxide/config', `server/${id}`] + +// ── plan ────────────────────────────────────────────────────────────────── + +async function plan() { + const bundle = await currentBundle() + const applied = readJson(statePath('bundle.json')) + const previousModules = fs.existsSync(statePath('modules.env')) + log(`bundle ${bundle.bundle} (protocol ${bundle.protocol}); on the rig: ${applied ? applied.bundle : 'none recorded'}`) + + const module = await moduleFor(bundle.protocol) + if (!module) { + // §7.5: a half-released protocol bump never takes the demo down. + if (!previousModules) { + throw new Error(`no Module-Rust release speaks protocol ${bundle.protocol}, and there is no previous set to keep`) + } + warn(`no Module-Rust release speaks protocol ${bundle.protocol} yet; keeping the previous bundle and module`) + writeFile(statePath('plan.json'), JSON.stringify({ bundle: null }, null, 2)) + return + } + + log(`module rust ${module.version} (protocol ${bundle.protocol})`) + writeFile(statePath('modules.env'), `MODULES=rust@${module.version}=${module.manifestUrl}\n`) + const changed = !applied || applied.bundle !== bundle.bundle + writeFile(statePath('plan.json'), JSON.stringify({ bundle: changed ? bundle : null }, null, 2)) + log(changed ? 'the rig gets the new bundle at this wipe' : 'the rig is already on this bundle') +} + +// ── restore ─────────────────────────────────────────────────────────────── + +async function writeBundle(p, bundle) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bundle-')) + try { + const plugins = [] + for (const part of [bundle.payload, bundle.npc].filter(Boolean)) { + const tarball = path.join(tmp, part.asset.name) + fs.writeFileSync(tarball, await download(part.asset.url, part.asset.sha256)) + const out = path.join(tmp, `${part.asset.name}.d`) + fs.mkdirSync(out) + execFileSync('tar', ['-xzf', tarball, '-C', out]) + for (const dir of fs.readdirSync(out)) { + for (const f of fs.readdirSync(path.join(out, dir))) { + if (f.endsWith('.cs')) plugins.push({ name: f, text: fs.readFileSync(path.join(out, dir, f)) }) + } + } + } + if (!plugins.length) throw new Error(`bundle ${bundle.bundle} carries no plugin files`) + + const sidecar = bundle.sidecar.assets['linux-x86_64'] + const binary = await download(sidecar.url, sidecar.sha256) + const launcher = await download(bundle.sidecar.launcher.url, bundle.sidecar.launcher.sha256) + + for (const f of plugins) { + await p.write(`/oxide/plugins/${f.name}`, f.text, 'text/plain') + log(` wrote oxide/plugins/${f.name}`) + } + await p.write('/rust-link/rust-link-sidecar', binary) + await p.write('/rust-link/with-sidecar.sh', launcher, 'text/plain') + await p.chmod('/rust-link', 'rust-link-sidecar', '755') + await p.chmod('/rust-link', 'with-sidecar.sh', '755') + log(` wrote the sidecar ${bundle.sidecar.version} and its launcher`) + + // The rig's own record of what it runs, in the shape the rig push writes, so + // anyone reading the rig sees the bundle the wipe put there. + const prior = JSON.parse((await p.read('/rust-link/bundle.json').catch(() => null)) || '{}') + const record = { + bundle: bundle.bundle, + protocol: bundle.protocol, + framework: prior.framework || 'oxide', + installed: new Date().toISOString().replace(/\.\d+Z$/, 'Z'), + sidecar: { tag: bundle.sidecar.tag }, + plugin: { tag: bundle.payload.tag, commit: bundle.payload.commit }, + ...(bundle.npc ? { runicnpc: { tag: bundle.npc.tag, api: bundle.npc.api } } : {}), + } + await p.write('/rust-link/bundle.json', `${JSON.stringify(record, null, 2)}\n`, 'text/plain') + } finally { + fs.rmSync(tmp, { recursive: true, force: true }) + } +} + +async function restore() { + const p = ptero() + const decided = readJson(statePath('plan.json')) + if (!decided) throw new Error('no plan.json; run `plan` first') + + // 1. Kill: no save, the world is about to be replaced. + if ((await p.state()) !== 'offline') { + await p.power('kill') + await waitFor(p, 'offline', 120) + } + log('the rig is stopped') + + // §7.5: the bundle goes on while the server is stopped. + if (decided.bundle) { + log(`writing bundle ${decided.bundle.bundle} onto the rig`) + await writeBundle(p, decided.bundle) + writeFile( + statePath('bundle.json'), + JSON.stringify({ bundle: decided.bundle.bundle, protocol: decided.bundle.protocol, applied: new Date().toISOString() }, null, 2), + ) + } + + // 2-3. Delete the three directories and unpack the baseline over them. + const root = (await p.list('/')).map((f) => f.name) + if (root.includes(BASELINE)) { + const id = await identity(p) + // Only what exists: a visitor cannot delete these, but a half-finished + // earlier run can have. + const oxide = new Set((await p.list('/oxide')).map((f) => `oxide/${f.name}`)) + const dirs = baselineDirs(id).filter((d) => oxide.has(d) || d === `server/${id}`) + await p.remove(dirs) + log(` deleted ${dirs.join(', ')}`) + await p.decompress(BASELINE) + log(` unpacked ${BASELINE}`) + } else { + warn(`there is no ${BASELINE} on the rig yet; it starts as it is. Capture one with the "Demo baseline" workflow.`) + } + + // 4. Start. Rust takes minutes to boot; the site does not wait for it. + await p.power('start') + log('the rig is starting') +} + +// ── baseline ────────────────────────────────────────────────────────────── + +async function baseline() { + const p = ptero() + // A power stop runs the egg's stop command, `quit`, which writes the save, so + // the subuser needs no console permission (DM22). + if ((await p.state()) !== 'offline') { + await p.power('stop') + await waitFor(p, 'offline', 300) + } + log('the rig is stopped and saved') + + const id = await identity(p) + const made = await p.compress(baselineDirs(id)) + log(` compressed ${baselineDirs(id).join(', ')} into ${made.name}`) + if ((await p.list('/')).some((f) => f.name === BASELINE)) await p.remove([BASELINE]) + await p.rename(made.name, BASELINE) + log(` saved as ${BASELINE} (${made.size} bytes)`) + + await p.power('start') + log('the rig is starting') +} + +// ── smoke ───────────────────────────────────────────────────────────────── + +async function smoke() { + const site = need('SITE_URL').replace(/\/+$/, '') + const until = Date.now() + (Number(process.env.SMOKE_TIMEOUT_SECONDS) || 900) * 1000 + const get = async (p) => { + try { + const res = await fetch(`${site}${p}`) + return res.ok ? await res.json() : null + } catch { + return null + } + } + + while (!(await get('/api/v1/public/status'))) { + if (Date.now() > until) throw new Error('the site never answered /api/v1/public/status') + await sleep(5000) + } + log('the site answers') + + for (;;) { + const list = (await get('/api/v1/public/rust/servers'))?.servers || [] + if (list.length && list.every((s) => s.online)) { + log(`the rig reports in: ${list.map((s) => s.name || s.id).join(', ')}`) + return + } + if (Date.now() > until) { + throw new Error(`the rig has not reported in (${JSON.stringify(list.map((s) => ({ id: s.id, online: s.online })))}); the site serves with it shown offline`) + } + await sleep(15000) + } +} + +// ── main ────────────────────────────────────────────────────────────────── + +export { ptero, identity, baselineDirs, writeBundle, moduleFor, newerFirst, currentBundle, BASELINE } + +// Run only when invoked, so a test can import the pieces without running one. +if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { + const commands = { plan, restore, baseline, smoke } + const command = commands[process.argv[2]] + if (!command) { + console.error(`usage: node rig.mjs ${Object.keys(commands).join('|')}`) + process.exit(2) + } + command().catch((err) => { + console.log(`::error::${err.message}`) + process.exit(1) + }) +} diff --git a/deploy/demo/seed.json b/deploy/demo/seed.json new file mode 100644 index 0000000..326132c --- /dev/null +++ b/deploy/demo/seed.json @@ -0,0 +1,79 @@ +{ + "accounts": { + "demo-admin": { "role": "admin", "password": "$env:DEMO_ADMIN_PASSWORD" }, + "demo-moderator": { "role": "moderator", "password": "$env:DEMO_MODERATOR_PASSWORD" }, + "demo-player": { "role": "player", "password": "$env:DEMO_PLAYER_PASSWORD" }, + "play-review": { "role": "admin", "password": "$env:PLAY_REVIEW_PASSWORD", "published": false } + }, + "settings": { + "site_title": "Runic Gateway Demo", + "status_message": "A public demo. Look around, change things — every change is wiped every two hours." + }, + "content": { + "posts": [ + { + "category": "news", + "title": "Welcome to the Runic Gateway demo", + "slug": "welcome-to-the-demo", + "body": "

This is a live Runic Gateway site, linked to a real Rust server. Sign in with one of the logins on the sign-in page and look around: the admin panel, the moderation queue, a player's own pages.

You can change most things. A few would break the demo for the next visitor — uploads, the server link, deleting accounts — and those answer This is turned off on the demo site. Everything is put back every two hours.

" + }, + { + "category": "news", + "title": "What the Rust module adds", + "slug": "what-the-rust-module-adds", + "body": "

The server list, the kill feed and the leaderboard come from the game as it happens. Signed in as an admin you can manage permission groups, place NPCs, edit plugin configs and build events that run on the server.

" + } + ], + "wiki": [ + { + "slug": "about-this-demo", + "title": "About this demo", + "category": "guides", + "published": true, + "body": "

The logins

The sign-in page lists them, with a button that fills the form: an admin, a moderator and a player.

The wipe

The site and its Rust server go back to this starting point every two hours, on the hour, UTC. The banner says when the next one is. Anyone signed in is signed out by it.

What is turned off

Anything that would break the demo for the next visitor: uploads, the contact form, changing the shared logins, the site mode, the server link, and console commands that would stop the server.

" + } + ], + "events": [ + { + "title": "Server greeting", + "summary": "Says hello in the game chat. Run it from Admin → Events.", + "timezone": "UTC", + "spec": { + "schedule": { "kind": "manual" }, + "phases": [ + { + "key": "main", + "label": "Greet", + "steps": [ + { + "actionId": "rust.command.run", + "params": { + "server": "demo", + "commands": ["say Hello from the Runic Gateway demo site!"], + "confirm": true + } + } + ] + } + ] + }, + "publish": true + } + ] + }, + "modules": { + "rust": { + "servers": [ + { + "id": "demo", + "name": "Demo server", + "sidecarBaseUrl": "$env:DEMO_RIG_SIDECAR_URL", + "sidecarToken": "$env:DEMO_RIG_SIDECAR_TOKEN" + } + ], + "links": [ + { "username": "demo-player", "steamId": "$env:DEMO_PLAYER_STEAM_ID", "serverId": "demo" } + ] + } + } +} -- 2.49.1