The consumer half of a release module-uo's CI has been publishing since
phase 3 closed. Before this, core had the installed_modules provenance
columns and no code that could ever fill them: nothing fetched, verified,
unpacked, removed or purged anything, and there was no admin route at all.
Adds modules/archive.js, modules/install.js, schema.runPurge(),
lifecycle.stop(), loader.stopHook(), and /api/v1/admin/modules with eight
routes. 797 server tests (+76), manifest 158 -> 166 + 2 internal, OpenAPI
gains 8 operations and loses nothing.
Reject, never sanitise
----------------------
The download is the easy part: an https-only allowlist re-checked on every
redirect hop, a declared sha256 compared against the bytes that arrived, and
a byte cap. Unpacking is where the archive chooses the filenames, and core
writes into a directory bind-mounted from the host, so an escape is not
confined to the container.
archive.js inspects the whole archive before a byte is unpacked and refuses
absolute and drive-absolute paths, `..` segments, NUL bytes, backslashes,
anything that is not a regular file or a directory, more than one top-level
entry, and anything over the entry or byte caps. Refusing symlinks and
hardlinks outright is what keeps this off the majority of node-tar's
published advisories rather than depending on the library to contain them.
That two-pass shape is load-bearing, and it was measured rather than assumed:
extracting an archive whose fourth member escapes upward throws under
node-tar 7.5.22 -- and leaves the first three members on disk. The loader
scans that directory at require time on the next boot, so a half-unpacked
module is a module. Everything therefore happens in a scratch directory that
is removed on any failure, and the move into place is the last step.
`tar` is pinned to ^7.5.22 rather than the ^6 that installs by default: 6.x
is flagged critical, and reading the advisory list is what the file's header
now says out loud -- almost all of it is hardlink or symlink traversal and
PAX header interpretation differentials, which is exactly this feature's
threat model.
Two things the plan had wrong
-----------------------------
The bundle's top-level directory is `module-uo-<version>`, not the module id
-- so "the top-level name must equal the id" was checked against nothing real.
The extractor strips that level instead, because its name belongs to whoever
published the bundle and the directory it lands in is core's. What is checked
instead is the unpacked module.json: a manifest promising `uo` and delivering
something else is refused rather than installed under the name it promised.
And purge cannot be a follow-up action (decision 5): purge.sql lives inside
the directory uninstall deletes. It is offered in the uninstall flow and as a
standalone action on a still-installed module, and the standalone one refuses
unless the module is already disabled -- dropping tables under something that
is still serving leaves it answering out of a world that no longer exists.
Disable now means stopped
-------------------------
lifecycle.stop() dispatches that one module's onShutdown before flipping the
guard, so a module an operator switches off actually releases its sockets and
closes its streams instead of merely becoming unreachable. The hook runs
first and the state moves after it, because while onShutdown runs the module
is still `started` and that is the only state in which its routes and the
world it is tearing down agree. A hook that throws does not stop the disable
-- the opposite of the boot path's rule, and deliberately.
Enable is not its mirror and there is no start(id) beside it. There is no
onBoot re-dispatch and the hooks were never promised re-entrant, so enable
moves the row and the restart route starts it. A test pins that enable does
not touch the loader, because "fixing" it is a one-line change that would put
a module with closed sockets back on the nav.
Restart raises SIGTERM against its own process rather than calling the
shutdown path directly, so server.js's handler stays the one graceful-shutdown
path and this route cannot drift from it.
The allowlist bootstraps from MODULE_SOURCE_HOSTS into a settings row and is
admin-managed after that (decision 6); seedDefault is INSERT IGNORE, so
changing the variable on an existing deployment is a no-op by design. An empty
list forbids every install rather than allowing every host -- the safe
direction for a value someone might blank by accident.
Verified against the real v0.3.0 release
----------------------------------------
Not a fixture: fetched the published install manifest over the real Gitea
host and its redirect chain, verified the sha256, inspected and unpacked the
252,517-byte artifact to 82 files, and then booted core against the result --
the module registered its five mounts, seven streams and eight capabilities
and resolved its client chunk, with no scratch directory left behind.
Two defects this slice's own tooling caught, both of which had already been
written down as classes:
- the controller destructured runPurge at require time, capturing the
function rather than the module, which made the one dependency whose
ORDER matters the one that could not be substituted;
- two swagger annotations carried an apostrophe inside a quoted string,
dropped silently by swagger-autogen before slice 5 taught it to fail loudly.
Co-Authored-By: Claude <noreply@anthropic.com>
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
// ── Replaying module schema fragments ──────────────────────────────────────
|
|
//
|
|
// Phase 2, PR 3. The contract is MODULE_API.md §2.6 (a fragment is replayed by
|
|
// the same ensureSchema() that replays core's, statement by statement, split the
|
|
// same way) and §4.4 (a failure after mounting is a state, not a crash).
|
|
//
|
|
// The property under test throughout, as in moduleLoader.test.js: **the failing
|
|
// module fails alone.** A fragment that blows up must cost its own module its
|
|
// routes and nothing else — not core's boot, not the next module's tables.
|
|
//
|
|
// No database is involved. `replayFragments` takes its `query` as an injectable
|
|
// dependency precisely so this suite can assert on the exact statements that
|
|
// would have been executed, in order, with the pool pointed at a dead port like
|
|
// every other suite here.
|
|
process.env.DB_HOST = '127.0.0.1'
|
|
process.env.DB_PORT = '59999'
|
|
|
|
const fs = require('fs')
|
|
const os = require('os')
|
|
const path = require('path')
|
|
|
|
const { test, beforeEach, after } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
|
|
const express = require('express')
|
|
|
|
const db = require('../src/utils/db')
|
|
const { replayFragments, runPurge } = require('../src/modules/schema')
|
|
const { splitStatements } = require('../src/utils/sqlStatements')
|
|
const { startApp } = require('./_helper')
|
|
|
|
after(() => db.close())
|
|
|
|
let tmpRoot
|
|
|
|
const emptyTiers = () => ({
|
|
public: express.Router(),
|
|
admin: express.Router(),
|
|
player: express.Router(),
|
|
})
|
|
|
|
function freshLoader(dir, tiers = emptyTiers()) {
|
|
process.env.MODULES_DIR = dir
|
|
delete require.cache[require.resolve('../src/modules/loader')]
|
|
// eslint-disable-next-line global-require
|
|
const loader = require('../src/modules/loader')
|
|
loader.load(tiers)
|
|
return loader
|
|
}
|
|
|
|
/** A module with a valid fragment, and optionally a route to watch 503 later. */
|
|
function writeModule(id, { schema, mounts } = {}) {
|
|
const dir = path.join(tmpRoot, id)
|
|
fs.mkdirSync(dir, { recursive: true })
|
|
fs.writeFileSync(path.join(dir, 'module.json'), JSON.stringify({
|
|
id,
|
|
name: id,
|
|
version: '1.0.0',
|
|
coreApi: '^1.0.0',
|
|
...(schema === undefined ? {} : { schema: 'schema.sql', purge: 'purge.sql' }),
|
|
...(mounts === undefined ? {} : { mounts, server: 'index.js' }),
|
|
}))
|
|
if (schema !== undefined) {
|
|
fs.writeFileSync(path.join(dir, 'schema.sql'), schema)
|
|
fs.writeFileSync(path.join(dir, 'purge.sql'), `DROP TABLE IF EXISTS ${id}_x;`)
|
|
}
|
|
if (mounts !== undefined) {
|
|
const [prefix] = mounts.public
|
|
fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = (ctx, api) => {
|
|
const r = ctx.express.Router()
|
|
r.get('/', (req, res) => res.json({ ok: true }))
|
|
api.registerRoutes({ public: { '${prefix}': r } })
|
|
}`)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
/** A query fn that records what it was asked to run, and can be told to fail. */
|
|
function recorder(failOn = null) {
|
|
const ran = []
|
|
return {
|
|
ran,
|
|
query: async (sql) => {
|
|
ran.push(sql)
|
|
if (failOn && sql.includes(failOn)) throw new Error(`ER_PARSE_ERROR: near "${failOn}"`)
|
|
return []
|
|
},
|
|
}
|
|
}
|
|
|
|
const stateOf = (loader, id) => loader.list().find((m) => m.id === id)
|
|
|
|
beforeEach(() => {
|
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-schema-'))
|
|
})
|
|
|
|
// ── The happy path ─────────────────────────────────────────────────────────
|
|
|
|
test('a fragment is replayed statement by statement, in file order', async () => {
|
|
writeModule('alpha', {
|
|
schema: [
|
|
'CREATE TABLE IF NOT EXISTS alpha_a (id INT);',
|
|
'CREATE TABLE IF NOT EXISTS alpha_b (id INT);',
|
|
'ALTER TABLE alpha_a ADD COLUMN IF NOT EXISTS name VARCHAR(64);',
|
|
].join('\n'),
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
const rec = recorder()
|
|
|
|
await replayFragments({ query: rec.query, modules: loader })
|
|
|
|
assert.equal(rec.ran.length, 3)
|
|
// Order is load-bearing, not incidental: the ALTER depends on the CREATE above
|
|
// it, which is why the replay awaits each statement rather than Promise.all.
|
|
assert.match(rec.ran[0], /alpha_a/)
|
|
assert.match(rec.ran[1], /alpha_b/)
|
|
assert.match(rec.ran[2], /^ALTER TABLE alpha_a/)
|
|
assert.equal(stateOf(loader, 'alpha').state, 'registered')
|
|
})
|
|
|
|
test('a module with no fragment is skipped, not replayed as empty', async () => {
|
|
writeModule('nodb')
|
|
const rec = recorder()
|
|
|
|
await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })
|
|
|
|
assert.deepEqual(rec.ran, [])
|
|
})
|
|
|
|
test('fragments are split exactly the way core schema.sql is', async () => {
|
|
// §2.6's "split the same way" is a promise about shared code, so the thing
|
|
// worth asserting is that the module path produces what the shared splitter
|
|
// produces — including the trailing-comment case that would otherwise chop a
|
|
// statement in half at the `;` inside it.
|
|
const sql = [
|
|
'-- a leading comment block',
|
|
'-- with two lines; and a semicolon in it',
|
|
'CREATE TABLE IF NOT EXISTS beta_a (id INT); -- trailing; comment',
|
|
'',
|
|
'CREATE TABLE IF NOT EXISTS beta_b (id INT);',
|
|
].join('\n')
|
|
writeModule('beta', { schema: sql })
|
|
const rec = recorder()
|
|
|
|
await replayFragments({ query: rec.query, modules: freshLoader(tmpRoot) })
|
|
|
|
assert.deepEqual(rec.ran, splitStatements(sql))
|
|
assert.equal(rec.ran.length, 2)
|
|
})
|
|
|
|
// ── Failure is a state ─────────────────────────────────────────────────────
|
|
|
|
test('a fragment that throws fails its own module and no one else', async () => {
|
|
writeModule('aaa', { schema: 'CREATE TABLE IF NOT EXISTS aaa_x (id INT);' })
|
|
writeModule('bbb', { schema: 'CREATE TABLE IF NOT EXISTS bbb_boom (id INT);' })
|
|
writeModule('ccc', { schema: 'CREATE TABLE IF NOT EXISTS ccc_x (id INT);' })
|
|
const loader = freshLoader(tmpRoot)
|
|
const rec = recorder('bbb_boom')
|
|
|
|
// Never throws — this is called on the boot path, between core's schema and
|
|
// seedDefaults(), and one bad module must not stop the site coming up.
|
|
await replayFragments({ query: rec.query, modules: loader })
|
|
|
|
assert.equal(stateOf(loader, 'aaa').state, 'registered')
|
|
assert.equal(stateOf(loader, 'bbb').state, 'startup_failed')
|
|
assert.match(stateOf(loader, 'bbb').reason, /ER_PARSE_ERROR/)
|
|
// The one that matters: the module AFTER the failure still got its tables.
|
|
assert.equal(stateOf(loader, 'ccc').state, 'registered')
|
|
assert.equal(rec.ran.length, 3)
|
|
})
|
|
|
|
test('a fragment stops at its first failing statement', async () => {
|
|
writeModule('part', {
|
|
schema: [
|
|
'CREATE TABLE IF NOT EXISTS part_a (id INT);',
|
|
'CREATE TABLE IF NOT EXISTS part_bad (id INT);',
|
|
'CREATE TABLE IF NOT EXISTS part_c (id INT);',
|
|
].join('\n'),
|
|
})
|
|
const loader = freshLoader(tmpRoot)
|
|
const rec = recorder('part_bad')
|
|
|
|
await replayFragments({ query: rec.query, modules: loader })
|
|
|
|
// Two attempted, the third never reached. The first table survives, and is
|
|
// accepted rather than compensated for: DDL self-commits in MariaDB, so no
|
|
// transaction could roll it back, and §2.6's idempotence rule is what makes
|
|
// re-running the corrected fragment safe.
|
|
assert.equal(rec.ran.length, 2)
|
|
assert.equal(stateOf(loader, 'part').state, 'startup_failed')
|
|
})
|
|
|
|
test('a module whose fragment failed keeps its URLs and answers 503', async () => {
|
|
// §4.4's right-hand column, now reachable for real rather than by a hand-moved
|
|
// state: schema replay is the first thing in the lifecycle that fails AFTER
|
|
// the routes are already mounted.
|
|
writeModule('svc', {
|
|
schema: 'CREATE TABLE IF NOT EXISTS svc_boom (id INT);',
|
|
mounts: { public: ['/widgets'] },
|
|
})
|
|
const tiers = emptyTiers()
|
|
const loader = freshLoader(tmpRoot, tiers)
|
|
const app = await startApp((a) => a.use('/public', tiers.public))
|
|
|
|
try {
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 200)
|
|
|
|
await replayFragments({ query: recorder('svc_boom').query, modules: loader })
|
|
|
|
assert.equal((await fetch(`${app.url}/public/widgets`)).status, 503)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
test('a module that failed validation is not replayed at all', async () => {
|
|
// It is never going to run, so creating its tables would leave an operator
|
|
// with rows belonging to a module that does not load.
|
|
writeModule('bad', { schema: 'CREATE TABLE IF NOT EXISTS not_prefixed (id INT);' })
|
|
writeModule('good', { schema: 'CREATE TABLE IF NOT EXISTS good_x (id INT);' })
|
|
const loader = freshLoader(tmpRoot)
|
|
const rec = recorder()
|
|
|
|
await replayFragments({ query: rec.query, modules: loader })
|
|
|
|
assert.equal(stateOf(loader, 'bad').state, 'startup_failed')
|
|
assert.equal(rec.ran.length, 1)
|
|
assert.match(rec.ran[0], /good_x/)
|
|
})
|
|
|
|
// ── The seed script ────────────────────────────────────────────────────────
|
|
|
|
test('replay is skipped, not thrown, when no scan happened in this process', async () => {
|
|
// `npm run seed` (db/seed.js) calls ensureSchema() standalone without ever
|
|
// requiring app.js, so the loader never ran. Before this was handled it was
|
|
// fragments()'s §7.6 throw, which would have broken seeding outright.
|
|
process.env.MODULES_DIR = tmpRoot
|
|
delete require.cache[require.resolve('../src/modules/loader')]
|
|
// eslint-disable-next-line global-require
|
|
const loader = require('../src/modules/loader')
|
|
const rec = recorder()
|
|
|
|
await replayFragments({ query: rec.query, modules: loader })
|
|
|
|
assert.deepEqual(rec.ran, [])
|
|
assert.equal(loader.isLoaded(), false)
|
|
})
|
|
|
|
// ── runPurge: the destructive twin ─────────────────────────────────────────
|
|
//
|
|
// Phase 4, slice 1. Same splitter, same pool, same serial execution as the
|
|
// replay above — pointed the other way. The two differences are deliberate and
|
|
// are what these tests are for.
|
|
|
|
test('purge runs every statement in the file, in order', async () => {
|
|
const file = path.join(tmpRoot, 'purge.sql')
|
|
fs.writeFileSync(file, [
|
|
'-- drop everything this module owns',
|
|
'DROP TABLE IF EXISTS mod_b;',
|
|
'DROP TABLE IF EXISTS mod_a;',
|
|
"DELETE FROM settings WHERE `key` = 'mod_thing';",
|
|
].join('\n'))
|
|
const rec = recorder()
|
|
|
|
const ran = await runPurge(file, { query: rec.query })
|
|
|
|
assert.equal(ran, 3)
|
|
assert.match(rec.ran[0], /DROP TABLE IF EXISTS mod_b/)
|
|
assert.match(rec.ran[2], /DELETE FROM settings/)
|
|
})
|
|
|
|
test('purge is not held to the fragment allowlist — DROP is the point of it', async () => {
|
|
// §2.6's leading-verb allowlist exists because a fragment replays on every
|
|
// boot. purge.sql never does, which is exactly why it is the one file a module
|
|
// may put a DROP in.
|
|
const file = path.join(tmpRoot, 'purge.sql')
|
|
fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nTRUNCATE TABLE mod_b;')
|
|
const rec = recorder()
|
|
|
|
assert.equal(await runPurge(file, { query: rec.query }), 2)
|
|
})
|
|
|
|
test('purge THROWS on failure, unlike the replay', async () => {
|
|
// A replay failure is one module failing to start, which the site survives by
|
|
// 503ing it. A purge failure is an operator's explicit destructive request not
|
|
// having happened — reporting success would leave them believing data is gone
|
|
// when it is not.
|
|
const file = path.join(tmpRoot, 'purge.sql')
|
|
fs.writeFileSync(file, 'DROP TABLE IF EXISTS mod_a;\nDROP TABLE mod_missing;')
|
|
const query = async (sql) => {
|
|
if (sql.includes('mod_missing')) throw new Error("Unknown table 'mod_missing'")
|
|
}
|
|
|
|
await assert.rejects(
|
|
() => runPurge(file, { query }),
|
|
// The message names WHICH statement stopped it, because a purge is not
|
|
// transactional — the ones before it have already committed and the operator
|
|
// needs to know where it got to.
|
|
/purge failed at statement 2 of 2: Unknown table 'mod_missing'/,
|
|
)
|
|
})
|
|
|
|
test('an empty purge file runs nothing and does not throw', async () => {
|
|
const file = path.join(tmpRoot, 'purge.sql')
|
|
fs.writeFileSync(file, '-- nothing to drop yet\n')
|
|
const rec = recorder()
|
|
|
|
assert.equal(await runPurge(file, { query: rec.query }), 0)
|
|
assert.deepEqual(rec.ran, [])
|
|
})
|