// ── 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) }) }