fix(rust): protocol 13 — a configuration save that settles after its reload (F9, D179) #20
@@ -220,6 +220,12 @@ export const adminConfig = {
|
||||
|
||||
writes: (serverId, limit = null) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes${query({ limit })}`),
|
||||
|
||||
// One write, polled while its reload settles (protocol 13). A save that
|
||||
// reloads a plugin answers before the reload has finished — behind a cold
|
||||
// compile that can be many seconds — and this is where the outcome lands.
|
||||
write: (serverId, id) =>
|
||||
req(`/admin/rust/config/${encodeURIComponent(serverId)}/writes/${encodeURIComponent(id)}`),
|
||||
}
|
||||
|
||||
// ── the admin.users.detail extension slot ─────────────────────────────────
|
||||
|
||||
@@ -138,20 +138,59 @@ function Field({ field, value, onChange, revealed, onReveal }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** How often a save that is still reloading asks how it went. */
|
||||
const POLL_MS = 2000
|
||||
|
||||
/**
|
||||
* A settled write row as the report panel reads it.
|
||||
*
|
||||
* The row is this module's audit record, not the plugin's frame, so it carries
|
||||
* no per-file `rewritten` — the file is re-read after either way, which is what
|
||||
* the panel's note about rewriting was for.
|
||||
*/
|
||||
function reportFromWrite(write) {
|
||||
return {
|
||||
pending: write.outcome === 'reloading',
|
||||
lost: write.outcome === 'lost',
|
||||
reload: write.reloadTarget,
|
||||
reloaded: Boolean(write.reloaded),
|
||||
rolledBack: write.outcome === 'rolled-back',
|
||||
restored: write.restored,
|
||||
reason: write.detail,
|
||||
log: write.log,
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** The sentence at the top of the panel. */
|
||||
function headline(report) {
|
||||
if (report.pending) {
|
||||
return `Saved. Reloading ${report.reload || 'the plugin'}… a first compile after a quiet spell can take a while.`
|
||||
}
|
||||
|
||||
if (report.lost) {
|
||||
return 'The server never said how the reload went — the bridge may have been reloaded, or the link dropped. Re-read the file to see what is on disk.'
|
||||
}
|
||||
|
||||
if (report.rolledBack) {
|
||||
return report.restored === false
|
||||
? 'The plugin did not come back, so the old file was put back — and it did not come back on the old file either. It is down: check the server console.'
|
||||
: 'The plugin did not come back, so the old file was put back automatically.'
|
||||
}
|
||||
|
||||
return report.reloaded ? 'Saved, and the plugin reloaded.' : `Saved. ${report.reason || 'Nothing was reloaded.'}`
|
||||
}
|
||||
|
||||
/** What the game said happened. The rollback case is the one worth reading. */
|
||||
function Report({ report }) {
|
||||
if (!report) return null
|
||||
|
||||
const tone = report.rolledBack ? '#e05a5a' : 'var(--ink)'
|
||||
const tone = report.rolledBack || report.lost ? '#e05a5a' : 'var(--ink)'
|
||||
|
||||
return (
|
||||
<div style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 10, marginTop: 10 }}>
|
||||
<p className="sans" style={{ color: tone, fontSize: '0.84rem', margin: 0 }}>
|
||||
{report.rolledBack
|
||||
? 'The plugin did not come back, so the old file was put back automatically.'
|
||||
: report.reloaded
|
||||
? 'Saved, and the plugin reloaded.'
|
||||
: `Saved. ${report.reason || 'Nothing was reloaded.'}`}
|
||||
{headline(report)}
|
||||
</p>
|
||||
{report.rolledBack && report.reason && (
|
||||
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '4px 0 0' }}>
|
||||
@@ -196,6 +235,8 @@ export default function ModConfig() {
|
||||
const [error, setError] = useState('')
|
||||
const [report, setReport] = useState(null)
|
||||
const [fileNonce, setFileNonce] = useState(0)
|
||||
// The write a save left reloading, polled until it settles (protocol 13, D179).
|
||||
const [watching, setWatching] = useState(null)
|
||||
|
||||
const { data: servers, error: serverError } = useAsync(() => api.admin.listServers(), [])
|
||||
|
||||
@@ -243,8 +284,48 @@ export default function ModConfig() {
|
||||
useEffect(() => {
|
||||
setPath('')
|
||||
setReport(null)
|
||||
setWatching(null)
|
||||
}, [serverId])
|
||||
|
||||
// A save that reloads a plugin comes back before the reload has finished.
|
||||
// Ask how it went every couple of seconds until the row leaves `reloading` —
|
||||
// the server calls it `lost` once twice the plugin's ceiling has passed, so
|
||||
// this always ends — then re-read the file, because a reload rewrites it and a
|
||||
// rollback puts the old one back.
|
||||
useEffect(() => {
|
||||
if (!watching) return undefined
|
||||
|
||||
let stopped = false
|
||||
let handle = null
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { write } = await api.adminConfig.write(watching.serverId, watching.id)
|
||||
if (stopped) return
|
||||
|
||||
setReport(reportFromWrite(write))
|
||||
|
||||
if (write.outcome !== 'reloading') {
|
||||
setWatching(null)
|
||||
setFileNonce((n) => n + 1)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// One failed poll is a blip, not an answer; the next one asks again.
|
||||
if (stopped) return
|
||||
}
|
||||
|
||||
handle = setTimeout(poll, POLL_MS)
|
||||
}
|
||||
|
||||
handle = setTimeout(poll, POLL_MS)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [watching])
|
||||
|
||||
if (serverError) return <ErrorState error={serverError} />
|
||||
if (!servers) return <Loading />
|
||||
|
||||
@@ -277,7 +358,13 @@ export default function ModConfig() {
|
||||
|
||||
const answer = await api.adminConfig.save(serverId, body)
|
||||
|
||||
if (answer.pending && answer.write && answer.write.id) {
|
||||
setReport({ ...(answer.report || {}), pending: true, reload: reload || null, files: [] })
|
||||
setWatching({ serverId, id: answer.write.id })
|
||||
} else {
|
||||
setReport(answer.report || null)
|
||||
}
|
||||
|
||||
if (!answer.changed) setError('Nothing changed, so nothing was written.')
|
||||
|
||||
// Re-read either way: a successful reload usually rewrites the file with
|
||||
@@ -483,10 +570,10 @@ export default function ModConfig() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
disabled={busy || Boolean(watching) || (tier === 'form' && pending === 0) || (tier === 'raw' && raw === file.text)}
|
||||
onClick={save}
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save and reload'}
|
||||
{busy ? 'Saving…' : watching ? 'Reloading…' : 'Save and reload'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/config/:serverId/writes/:writeId",
|
||||
"tier": "public"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/v1/admin/rust/permissions",
|
||||
|
||||
@@ -80,6 +80,10 @@ const STAFF_KINDS = Object.freeze([
|
||||
// changed it by hand — a question about a person's standing and about an
|
||||
// operator's own console, neither of which is a public page's business.
|
||||
'perm.drift',
|
||||
// Protocol 13. How a configuration save's reload ended — and, when it failed,
|
||||
// the tail of the game server's own log, which is an operator's console and
|
||||
// can hold anything a plugin chose to print.
|
||||
'config.outcome',
|
||||
// Protocol 6. Clan membership, which the org lead made members-only (D49):
|
||||
// who joined which clan, and who threw whom out, is the clan's business. It
|
||||
// reaches a clan's own members through core's Team feed, where core resolves
|
||||
|
||||
@@ -651,7 +651,7 @@ CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||||
-- whole document.
|
||||
tier VARCHAR(16) NOT NULL DEFAULT 'form',
|
||||
user_id INT NULL,
|
||||
-- `applied` | `rolled-back` | `refused` | `unreachable`
|
||||
-- `applied` | `rolled-back` | `refused` | `unreachable` | `reloading` (protocol 13)
|
||||
outcome VARCHAR(24) NOT NULL,
|
||||
reloaded TINYINT(1) NOT NULL DEFAULT 0,
|
||||
changes LONGTEXT NULL,
|
||||
@@ -669,6 +669,28 @@ CREATE TABLE IF NOT EXISTS rust_config_writes (
|
||||
KEY idx_rust_config_writes_server (server_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Protocol 13 (PLAN_FIXES.md F9, D177–D179). A save that reloads a plugin no
|
||||
-- longer waits for the reload: it is recorded as `reloading` the moment the files
|
||||
-- land, and ingest settles it from the plugin's `config.outcome`.
|
||||
--
|
||||
-- • `write_id` is the plugin's own name for the write — the frame carries it,
|
||||
-- and the sidecar's request ids restart with the sidecar, so they cannot.
|
||||
-- • `settle_by` is when a write that never heard back is LOST rather than slow:
|
||||
-- two of the plugin's ceilings (the edit's reload and a restore's) plus
|
||||
-- slack. Reads report `lost` past it; the row itself stays `reloading`, so an
|
||||
-- outcome that arrives late — a link that was down — still settles it.
|
||||
-- • `log_tail` is the server's log at the moment a reload failed. It used to go
|
||||
-- straight back to the browser that asked; nothing is waiting now, so the
|
||||
-- page reads it from here. Operator console output: admin-only like the rest.
|
||||
-- • `restored` is whether the plugin came back on its old file after a
|
||||
-- rollback. NULL when there was no rollback.
|
||||
ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS write_id VARCHAR(64) NULL;
|
||||
ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS settle_by DATETIME NULL;
|
||||
ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS settled_at DATETIME NULL;
|
||||
ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS log_tail TEXT NULL;
|
||||
ALTER TABLE rust_config_writes ADD COLUMN IF NOT EXISTS restored TINYINT(1) NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_rust_config_writes_write ON rust_config_writes (server_id, write_id);
|
||||
|
||||
|
||||
-- ── Who may see who is online (the presence fix, 2026-09-22) ──────────────
|
||||
--
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
const core = require('./core')
|
||||
|
||||
const clans = require('./model/clans/clans.model')
|
||||
const configDb = require('./model/config/config.db')
|
||||
const configModel = require('./model/config/config.model')
|
||||
const db = require('./model/events/events.db')
|
||||
const engagement = require('./engagement/emit')
|
||||
const links = require('./model/links/links.model')
|
||||
@@ -199,6 +201,18 @@ async function apply(serverId, item, server = null) {
|
||||
await permissionsDb.markDirty(serverId)
|
||||
break
|
||||
|
||||
// ── Protocol 13: how a configuration save's reload ended (F9, D179) ─────
|
||||
//
|
||||
// The save was answered before the reload finished and recorded as
|
||||
// `reloading`; this is the rest of it. Only a row still `reloading` moves,
|
||||
// so a replayed frame changes nothing, and one that arrives after the page
|
||||
// gave up on it (`lost`) still lands — the audit row ends up true either way.
|
||||
case 'config.outcome': {
|
||||
const outcome = configModel.outcomeOf(frame)
|
||||
if (outcome) await configDb.settleWrite(serverId, outcome)
|
||||
break
|
||||
}
|
||||
|
||||
// ── Protocol 6: first-party clans ──────────────────────────────────────
|
||||
//
|
||||
// Each one is told to core as it happens (`ctx.teams.publish`) and written
|
||||
|
||||
@@ -18,11 +18,11 @@ const core = require('../../core')
|
||||
* silence.
|
||||
*/
|
||||
async function recordWrite(row) {
|
||||
await core.query(
|
||||
const result = await core.query(
|
||||
`INSERT INTO rust_config_writes
|
||||
(server_id, path, plugin, reload_target, tier, user_id, outcome, reloaded,
|
||||
changes, version_before, version_after, detail)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
changes, version_before, version_after, detail, write_id, settle_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))`,
|
||||
[
|
||||
row.serverId,
|
||||
row.path,
|
||||
@@ -36,8 +36,55 @@ async function recordWrite(row) {
|
||||
row.versionBefore || null,
|
||||
row.versionAfter || null,
|
||||
row.detail ? String(row.detail).slice(0, 500) : null,
|
||||
row.writeId || null,
|
||||
// Seconds from now, on the database's clock — the one `lost` is read against.
|
||||
// NULL for a write nothing is waiting on, and DATE_ADD of NULL is NULL.
|
||||
row.settleSeconds != null ? Number(row.settleSeconds) : null,
|
||||
],
|
||||
)
|
||||
|
||||
return result && result.insertId != null ? Number(result.insertId) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Settles a `reloading` write from the plugin's `config.outcome` (protocol 13).
|
||||
*
|
||||
* Found by the plugin's `writeId`, scoped to the server that sent it — a write
|
||||
* id is only unique within one plugin. Only a row still `reloading` moves, so a
|
||||
* replayed frame settles nothing twice. A row whose `settle_by` has passed is
|
||||
* still `reloading` in the table (it reads as `lost`), so a late outcome still
|
||||
* lands. Returns whether a row moved.
|
||||
*/
|
||||
async function settleWrite(serverId, outcome) {
|
||||
const result = await core.query(
|
||||
`UPDATE rust_config_writes
|
||||
SET outcome = ?, reloaded = ?, restored = ?, version_after = ?, detail = ?,
|
||||
log_tail = ?, settled_at = NOW()
|
||||
WHERE server_id = ? AND write_id = ? AND outcome = 'reloading'`,
|
||||
[
|
||||
outcome.outcome,
|
||||
outcome.reloaded ? 1 : 0,
|
||||
outcome.restored == null ? null : outcome.restored ? 1 : 0,
|
||||
outcome.versionAfter || null,
|
||||
outcome.detail ? String(outcome.detail).slice(0, 500) : null,
|
||||
outcome.log || null,
|
||||
serverId,
|
||||
outcome.writeId,
|
||||
],
|
||||
)
|
||||
|
||||
return Number((result && result.affectedRows) || 0) > 0
|
||||
}
|
||||
|
||||
/** One write, by its row id, for the page that is waiting on it. */
|
||||
async function getWrite(serverId, id) {
|
||||
const rows = await core.query(
|
||||
`${SELECT_WRITE}
|
||||
WHERE server_id = ? AND id = ?`,
|
||||
[serverId, id],
|
||||
)
|
||||
|
||||
return rows[0] ? shapeWrite(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,21 +96,40 @@ async function recordWrite(row) {
|
||||
*/
|
||||
async function recentWrites(serverId, limit = 50) {
|
||||
const rows = await core.query(
|
||||
`SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail, created_at AS createdAt
|
||||
FROM rust_config_writes
|
||||
`${SELECT_WRITE}
|
||||
WHERE server_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?`,
|
||||
[serverId, Math.max(1, Math.min(Number(limit) || 50, 200))],
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
return rows.map(shapeWrite)
|
||||
}
|
||||
|
||||
// `lost` is decided by the database's clock, in the same statement that reads
|
||||
// the row, so it cannot disagree with `settle_by`, which that clock also wrote.
|
||||
const SELECT_WRITE = `SELECT id, server_id AS serverId, path, plugin, reload_target AS reloadTarget, tier,
|
||||
user_id AS userId, outcome, reloaded, restored, changes,
|
||||
version_before AS versionBefore, version_after AS versionAfter, detail,
|
||||
log_tail AS log, write_id AS writeId, settled_at AS settledAt, created_at AS createdAt,
|
||||
(outcome = 'reloading' AND settle_by IS NOT NULL AND settle_by < NOW()) AS lost
|
||||
FROM rust_config_writes`
|
||||
|
||||
/**
|
||||
* A row as the page reads it. A write still `reloading` past its `settle_by`
|
||||
* reads as `lost`: the plugin was reloaded or the link dropped before it could
|
||||
* say, and the files are whatever a re-read shows.
|
||||
*/
|
||||
function shapeWrite(row) {
|
||||
const { lost, ...rest } = row
|
||||
|
||||
return {
|
||||
...rest,
|
||||
outcome: Number(lost) ? 'lost' : row.outcome,
|
||||
reloaded: Boolean(row.reloaded),
|
||||
restored: row.restored == null ? null : Boolean(row.restored),
|
||||
changes: parseChanges(row.changes),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function parseChanges(raw) {
|
||||
@@ -76,4 +142,4 @@ function parseChanges(raw) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordWrite, recentWrites, parseChanges }
|
||||
module.exports = { recordWrite, settleWrite, getWrite, recentWrites, parseChanges, shapeWrite }
|
||||
|
||||
@@ -198,6 +198,12 @@ function summariseReport(report) {
|
||||
ok: report.ok !== false,
|
||||
reloaded: Boolean(report.reloaded),
|
||||
rolledBack: Boolean(report.rolledBack),
|
||||
// Protocol 13: the files are written and the reload has not finished. The
|
||||
// outcome follows as a `config.outcome` frame naming `writeId`.
|
||||
pending: Boolean(report.pending),
|
||||
writeId: report.writeId ? String(report.writeId) : null,
|
||||
ceilingMs: Number(report.ceilingMs) > 0 ? Number(report.ceilingMs) : null,
|
||||
restored: typeof report.restored === 'boolean' ? report.restored : null,
|
||||
reason: report.reason ? String(report.reason) : null,
|
||||
// The plugin only reads this on the failure path, and it is the difference
|
||||
// between "your change was undone" and "your change was undone BECAUSE line
|
||||
@@ -215,6 +221,48 @@ function summariseReport(report) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a pending write may go unanswered before it is lost, in seconds.
|
||||
*
|
||||
* The plugin waits at most one ceiling for the edit's reload and one more for a
|
||||
* restore's; ingest reads the feed every few seconds on top. Past both, with a
|
||||
* margin, nothing is coming — the plugin was reloaded or the link dropped — and
|
||||
* the page says so instead of spinning. A plugin that sent no ceiling gets the
|
||||
* one protocol 13 shipped with.
|
||||
*/
|
||||
const SETTLE_SLACK_SECONDS = 20
|
||||
const DEFAULT_CEILING_MS = 30 * 1000
|
||||
|
||||
function settleSeconds(ceilingMs) {
|
||||
const ceiling = Number(ceilingMs) > 0 ? Number(ceilingMs) : DEFAULT_CEILING_MS
|
||||
return Math.ceil((2 * ceiling) / 1000) + SETTLE_SLACK_SECONDS
|
||||
}
|
||||
|
||||
/**
|
||||
* A `config.outcome` frame as the row it settles, or null when it names no write.
|
||||
*
|
||||
* `applied` covers every case where the edit is on disk — reloaded, or standing
|
||||
* because the framework never reloaded it (the reason says which). A rollback is
|
||||
* `rolled-back` whether or not the plugin came back on the old file; `restored`
|
||||
* says which, and it is the difference between "try again" and "go and look".
|
||||
*/
|
||||
function outcomeOf(frame) {
|
||||
if (!frame || typeof frame !== 'object' || !frame.writeId) return null
|
||||
|
||||
const report = summariseReport(frame)
|
||||
const first = report.files[0]
|
||||
|
||||
return {
|
||||
writeId: String(frame.writeId),
|
||||
outcome: report.rolledBack ? 'rolled-back' : 'applied',
|
||||
reloaded: report.reloaded,
|
||||
restored: report.rolledBack ? report.restored : null,
|
||||
versionAfter: first ? first.version : null,
|
||||
detail: report.reason,
|
||||
log: report.log,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LOCKED_KEYS,
|
||||
LOCKED_REASON,
|
||||
@@ -226,4 +274,6 @@ module.exports = {
|
||||
lockedKeysFor,
|
||||
lockedChanges,
|
||||
summariseReport,
|
||||
outcomeOf,
|
||||
settleSeconds,
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// ── Admin · Rust · Mod configuration ──────────────────────────────────────
|
||||
//
|
||||
// R18. Four routes: list the tree, read a file, write a file, read what has
|
||||
// been written lately. Every one of them is a live round trip to a game host —
|
||||
// nothing here is cached, because a cached config is an edit somebody made over
|
||||
// SSH that this website then silently overwrote.
|
||||
// R18. Five routes: list the tree, read a file, write a file, read what has
|
||||
// been written lately, and read one write while its reload settles. The first
|
||||
// three are live round trips to a game host — nothing here is cached, because a
|
||||
// cached config is an edit somebody made over SSH that this website then
|
||||
// silently overwrote. The last two read this module's own audit table.
|
||||
//
|
||||
// ── The write is three steps and the order is the whole design ────────────
|
||||
//
|
||||
@@ -17,8 +18,10 @@
|
||||
// fails to come back from its reload.
|
||||
// 3. **Hand the whole file to the plugin**, which version-checks it again,
|
||||
// backs the old one up, writes, reloads, and rolls the write back if the
|
||||
// plugin does not announce itself. That last part is the feature; this file
|
||||
// reports it.
|
||||
// plugin fails to load. That last part is the feature; this file reports it.
|
||||
// Since protocol 13 the plugin answers as soon as the files are written and
|
||||
// reports the reload later as a `config.outcome` frame (F9), so a save that
|
||||
// reloads is recorded `reloading` and settled by ingest (D179).
|
||||
//
|
||||
// ── What the outcome means ────────────────────────────────────────────────
|
||||
//
|
||||
@@ -238,6 +241,29 @@ async function writeFile(req, res) {
|
||||
const report = model.summariseReport(reply.data)
|
||||
const after = report && report.files[0] ? report.files[0].version : null
|
||||
|
||||
// Protocol 13 (F9, D179): the files are on disk and the reload is still
|
||||
// running — behind a cold compile it can take longer than any request should.
|
||||
// The row is `reloading` until ingest settles it from the plugin's
|
||||
// `config.outcome`; the page polls it by the id returned here.
|
||||
if (report && report.pending) {
|
||||
const id = await record(req, {
|
||||
serverId,
|
||||
path,
|
||||
self,
|
||||
reload,
|
||||
tier,
|
||||
outcome: 'reloading',
|
||||
reloaded: false,
|
||||
changes,
|
||||
versionBefore: onDisk.version,
|
||||
versionAfter: after,
|
||||
writeId: report.writeId,
|
||||
settleSeconds: model.settleSeconds(report.ceilingMs),
|
||||
})
|
||||
|
||||
return res.json({ changed: true, pending: true, write: { id, writeId: report.writeId }, report })
|
||||
}
|
||||
|
||||
await record(req, {
|
||||
serverId,
|
||||
path,
|
||||
@@ -268,10 +294,34 @@ async function history(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/** One audit row, plus the activity entry core owns. Never lets a logging failure fail a save. */
|
||||
async function record(req, row) {
|
||||
/**
|
||||
* One write, for the page waiting on a reload (D179).
|
||||
*
|
||||
* `reloading` until ingest settles it, then `applied` or `rolled-back` with the
|
||||
* reason and the server's log; `lost` once it has gone unanswered past twice the
|
||||
* plugin's ceiling, which means re-read the file rather than keep waiting.
|
||||
*/
|
||||
async function writeStatus(req, res) {
|
||||
try {
|
||||
await db.recordWrite({
|
||||
const write = await db.getWrite(req.params.serverId, Number(req.params.writeId))
|
||||
if (!write) return res.status(404).json({ message: 'No such configuration write' })
|
||||
|
||||
return res.json({ write })
|
||||
} catch (err) {
|
||||
log.error('failed to read a configuration write', { error: err.message })
|
||||
return res.status(500).json({ message: 'Failed to read that configuration write' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One audit row, plus the activity entry core owns. Never lets a logging failure
|
||||
* fail a save. Returns the row's id, or null when it could not be written.
|
||||
*/
|
||||
async function record(req, row) {
|
||||
let id = null
|
||||
|
||||
try {
|
||||
id = await db.recordWrite({
|
||||
...row,
|
||||
plugin: model.isBridgeConfig(row.path, row.self) ? row.self : pluginOf(row.path),
|
||||
reloadTarget: row.reload,
|
||||
@@ -293,6 +343,8 @@ async function record(req, row) {
|
||||
} catch (err) {
|
||||
log.error('failed to record a configuration write', { path: row.path, error: err.message })
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function pluginOf(path) {
|
||||
@@ -328,4 +380,4 @@ function refusalStatus(frame) {
|
||||
return 502
|
||||
}
|
||||
|
||||
module.exports = { listFiles, readFile, writeFile, history, refusalMessage, refusalStatus }
|
||||
module.exports = { listFiles, readFile, writeFile, writeStatus, history, refusalMessage, refusalStatus }
|
||||
|
||||
@@ -67,8 +67,8 @@ configRouter.post(
|
||||
'/:serverId/file',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'Save a configuration file and reload its plugin'
|
||||
// #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.'
|
||||
/* #swagger.responses[200] = { description: 'What happened: applied, or rolled back with the reason' } */
|
||||
// #swagger.description = 'Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin fails to load. With a plugin to reload, the answer comes as soon as the file is written — `pending: true` and `write.id` — and the outcome is read from `GET …/writes/{writeId}` once the reload settles. Without one, the answer is final.'
|
||||
/* #swagger.responses[200] = { description: 'Written and reloading (`pending`, with the write’s id), or the final outcome when nothing was reloaded' } */
|
||||
/* #swagger.responses[400] = { description: 'Invalid body, an edit the form may not make, or a locked key' } */
|
||||
/* #swagger.responses[409] = { description: 'The file changed on the host since it was read' } */
|
||||
/* #swagger.responses[503] = { description: 'The sidecar or the game is unreachable' } */
|
||||
@@ -105,4 +105,18 @@ configRouter.get(
|
||||
config.history,
|
||||
)
|
||||
|
||||
configRouter.get(
|
||||
'/:serverId/writes/:writeId',
|
||||
// #swagger.tags = ['Admin · Rust']
|
||||
// #swagger.summary = 'One configuration write, while its reload settles'
|
||||
// #swagger.description = 'A save that reloads a plugin answers as soon as the file is written, with `pending: true` and this write’s id; the reload can take longer than a request should, behind a cold compile. Poll this until `outcome` leaves `reloading`: `applied`, or `rolled-back` with the reason, the server’s log, and `restored` (whether the plugin came back on its old file). `lost` means the plugin never reported — it was reloaded, or the link dropped — so re-read the file.'
|
||||
/* #swagger.responses[200] = { description: 'The write and how far it has got' } */
|
||||
/* #swagger.responses[404] = { description: 'No such write on that server' } */
|
||||
requireRole('admin'),
|
||||
param('serverId').matches(SERVER_ID),
|
||||
param('writeId').isInt({ min: 1 }),
|
||||
validate,
|
||||
config.writeStatus,
|
||||
)
|
||||
|
||||
module.exports = configRouter
|
||||
|
||||
@@ -79,7 +79,7 @@ const TIMEOUT_MS = 12000
|
||||
* deployment into a `409` naming both numbers instead of a parse failure three
|
||||
* layers further in.
|
||||
*/
|
||||
const PROTOCOL_VERSION = 12
|
||||
const PROTOCOL_VERSION = 13
|
||||
|
||||
/** What a caller gets back. Shaped once so every call site reads the same. */
|
||||
function reply(ok, status, data = null) {
|
||||
|
||||
@@ -95,7 +95,7 @@ test('every kind is classified exactly once', () => {
|
||||
assert.equal(seen.size, catalogue.PUBLIC_KINDS.length + catalogue.STAFF_KINDS.length)
|
||||
})
|
||||
|
||||
test('the classification covers exactly the kinds the protocol defines, through protocol 6', () => {
|
||||
test('the classification covers exactly the kinds the protocol defines, through protocol 6, and protocol 13’s config.outcome', () => {
|
||||
// The spec lives in another repository, so the list is restated here rather
|
||||
// than parsed — and restating it is the point: adding a kind to the protocol
|
||||
// without deciding who may see it has to fail somewhere, and this is where.
|
||||
@@ -127,6 +127,9 @@ test('the classification covers exactly the kinds the protocol defines, through
|
||||
'clan.member.added',
|
||||
'clan.member.left',
|
||||
'clan.member.kicked',
|
||||
// Protocol 13 (§19). A configuration save's outcome carries the server's
|
||||
// log tail, which is an operator's console: staff only.
|
||||
'config.outcome',
|
||||
]
|
||||
|
||||
assert.deepEqual([...catalogue.ALL_KINDS].sort(), [...PROTOCOL_4].sort())
|
||||
@@ -134,6 +137,8 @@ test('the classification covers exactly the kinds the protocol defines, through
|
||||
for (const kind of PROTOCOL_4.filter((k) => k.startsWith('clan.'))) {
|
||||
assert.equal(catalogue.isPublic(kind), false, `${kind} is members-only and must not be public`)
|
||||
}
|
||||
|
||||
assert.equal(catalogue.isPublic('config.outcome'), false, 'a server’s log tail must not be public')
|
||||
})
|
||||
|
||||
test('every kind that names a player who was on is behind the presence setting', () => {
|
||||
|
||||
@@ -435,3 +435,123 @@ test('the bridge’s own file is recognised by the plugin’s name, not by a fil
|
||||
// locking everything or guessing.
|
||||
assert.deepEqual(model.lockedKeysFor('RunicGateway.json', null), [])
|
||||
})
|
||||
|
||||
// ── Protocol 13: the save that answers before its reload does (F9, D179) ──
|
||||
|
||||
test('a save the plugin is still reloading is recorded as reloading, with the id the page polls', async () => {
|
||||
const queries = withCore()
|
||||
stubSidecar({
|
||||
write: {
|
||||
ok: true,
|
||||
status: 'ok',
|
||||
data: {
|
||||
kind: 'config.report',
|
||||
ok: true,
|
||||
reloaded: false,
|
||||
rolledBack: false,
|
||||
pending: true,
|
||||
writeId: 'w-1',
|
||||
reload: 'ZoneManager',
|
||||
ceilingMs: 30000,
|
||||
files: [{ path: 'ZoneManager.json', version: 'v-written' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
stubServer()
|
||||
|
||||
const res = fakeRes()
|
||||
await controller().writeFile(
|
||||
{
|
||||
params: { serverId: 'main' },
|
||||
body: {
|
||||
path: 'ZoneManager.json',
|
||||
version: 'v-ZoneManager.json',
|
||||
reload: 'ZoneManager',
|
||||
edits: [{ pointer: ['Enabled'], value: false }],
|
||||
},
|
||||
},
|
||||
res,
|
||||
)
|
||||
|
||||
assert.equal(res.statusCode, 200)
|
||||
assert.equal(res.body.pending, true)
|
||||
assert.deepEqual(res.body.write, { id: 1, writeId: 'w-1' })
|
||||
|
||||
const audit = queries.find((q) => q.sql.includes('INSERT INTO rust_config_writes'))
|
||||
assert.ok(audit.params.includes('reloading'))
|
||||
assert.ok(audit.params.includes('w-1'), 'the plugin’s write id is what ingest settles the row by')
|
||||
|
||||
// Two ceilings — the edit's reload and a restore's — plus slack, as seconds
|
||||
// on the database's clock.
|
||||
assert.equal(audit.params[audit.params.length - 1], 80)
|
||||
assert.match(audit.sql, /DATE_ADD\(NOW\(\), INTERVAL \? SECOND\)/)
|
||||
})
|
||||
|
||||
test('a config.outcome settles only the write it names, on the server that sent it, and only once', async () => {
|
||||
const queries = withCore()
|
||||
const ingest = require('../ingest')
|
||||
|
||||
await ingest.apply('main', {
|
||||
kind: 'config.outcome',
|
||||
frame: {
|
||||
kind: 'config.outcome',
|
||||
type: 'event',
|
||||
serverId: 'main',
|
||||
writeId: 'w-1',
|
||||
reload: 'Kits',
|
||||
ok: false,
|
||||
reloaded: false,
|
||||
rolledBack: true,
|
||||
restored: true,
|
||||
reason: "'Kits' failed to load: Failed to initialize plugin 'Kits v4.4.9'",
|
||||
log: 'Failed to initialize plugin ...',
|
||||
files: [{ path: 'Kits.json', version: 'v-restored' }],
|
||||
},
|
||||
})
|
||||
|
||||
const settle = queries.find((q) => q.sql.startsWith('UPDATE rust_config_writes'))
|
||||
assert.ok(settle, 'the outcome must settle the row')
|
||||
assert.match(settle.sql, /WHERE server_id = \? AND write_id = \? AND outcome = 'reloading'/)
|
||||
assert.deepEqual(settle.params.slice(0, 4), ['rolled-back', 0, 1, 'v-restored'])
|
||||
assert.deepEqual(settle.params.slice(-2), ['main', 'w-1'])
|
||||
})
|
||||
|
||||
test('an outcome is applied when the edit stands, and rolled-back says whether the plugin came back', () => {
|
||||
const model = require('../model/config/config.model')
|
||||
|
||||
const applied = model.outcomeOf({ writeId: 'w', reloaded: true, rolledBack: false, files: [{ path: 'a.json', version: 'v2' }] })
|
||||
assert.equal(applied.outcome, 'applied')
|
||||
assert.equal(applied.restored, null)
|
||||
assert.equal(applied.versionAfter, 'v2')
|
||||
|
||||
// The framework never reloaded it: the file stands, so it is applied, and the
|
||||
// reason is what says it is not in effect yet.
|
||||
const standing = model.outcomeOf({ writeId: 'w', reloaded: false, rolledBack: false, reason: 'the file stands', files: [] })
|
||||
assert.equal(standing.outcome, 'applied')
|
||||
assert.equal(standing.detail, 'the file stands')
|
||||
|
||||
const down = model.outcomeOf({ writeId: 'w', rolledBack: true, restored: false, files: [] })
|
||||
assert.equal(down.outcome, 'rolled-back')
|
||||
assert.equal(down.restored, false)
|
||||
|
||||
assert.equal(model.outcomeOf({ rolledBack: true }), null, 'an outcome naming no write settles nothing')
|
||||
assert.equal(model.settleSeconds(null), 80, 'a plugin that sent no ceiling gets the one protocol 13 shipped with')
|
||||
})
|
||||
|
||||
test('a write still reloading past its deadline reads as lost, and a settled one as itself', () => {
|
||||
const db = require('../model/config/config.db')
|
||||
|
||||
const base = { id: 7, outcome: 'reloading', reloaded: 0, restored: null, changes: null }
|
||||
assert.equal(db.shapeWrite({ ...base, lost: 1 }).outcome, 'lost')
|
||||
assert.equal(db.shapeWrite({ ...base, lost: 0 }).outcome, 'reloading')
|
||||
assert.equal(db.shapeWrite({ ...base, outcome: 'rolled-back', restored: 1, lost: 0 }).restored, true)
|
||||
assert.equal('lost' in db.shapeWrite({ ...base, lost: 0 }), false, 'the flag is folded into outcome, not leaked')
|
||||
})
|
||||
|
||||
test('the page’s poll answers 404 for a write that is not on that server', async () => {
|
||||
withCore()
|
||||
const res = fakeRes()
|
||||
await controller().writeStatus({ params: { serverId: 'main', writeId: '9' } }, res)
|
||||
|
||||
assert.equal(res.statusCode, 404)
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "Save a configuration file and reload its plugin",
|
||||
"description": "Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin does not come back — which is answered 200 with `report.rolledBack`, because a rollback is a round trip that worked and an edit that did not.",
|
||||
"description": "Send `edits` (the generated form: pointers and literals, type-preserving) or `text` (the raw tier: the whole document). `version` must match what the host holds or the save is refused 409 with the current file. The game backs the file up, writes it, reloads the named plugin, and **restores the old file automatically** if the plugin fails to load. With a plugin to reload, the answer comes as soon as the file is written — `pending: true` and `write.id` — and the outcome is read from `GET …/writes/{writeId}` once the reload settles. Without one, the answer is final.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "serverId",
|
||||
@@ -54,7 +54,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "What happened: applied, or rolled back with the reason"
|
||||
"description": "Written and reloading (`pending`, with the write’s id), or the final outcome when nothing was reloaded"
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid body, an edit the form may not make, or a locked key"
|
||||
@@ -158,6 +158,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/config/{serverId}/writes/{writeId}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin · Rust"
|
||||
],
|
||||
"summary": "One configuration write, while its reload settles",
|
||||
"description": "A save that reloads a plugin answers as soon as the file is written, with `pending: true` and this write’s id; the reload can take longer than a request should, behind a cold compile. Poll this until `outcome` leaves `reloading`: `applied`, or `rolled-back` with the reason, the server’s log, and `restored` (whether the plugin came back on its old file). `lost` means the plugin never reported — it was reloaded, or the link dropped — so re-read the file.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "serverId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "writeId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The write and how far it has got"
|
||||
},
|
||||
"404": {
|
||||
"description": "No such write on that server"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/rust/permissions": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user