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
373 lines
16 KiB
JavaScript
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)
|
|
})
|
|
}
|