Files
website/deploy/demo/rig.mjs
wtclaude d48d8c473c
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 50s
PR Checks / client-build (pull_request) Successful in 57s
PR Checks / server-tests (pull_request) Successful in 12m25s
feat(demo): deploy/demo and the wipe + baseline workflows (step 5)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-26 12:23:52 -05:00

373 lines
16 KiB
JavaScript

// ── 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@<version>=<manifest>, 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)
})
}