MODULES declares the module set a deployment runs, one entry per module as
`<id>@<version>=<install manifest URL>`, and the container arrives at it by
itself (MODULE_SYSTEM.md §2.7.2 decision 4). A module already unpacked at the
declared version is a no-op that makes NO network call, so a restart with the
network down comes up unchanged; anything else goes through install.js — same
allowlist, same sha256, same inspect-then-extract — and install() now takes an
`expect: {id, version}` so a URL resolving to another module or version is
refused while it is still only a manifest.
Resolution runs inside start(), between the seed and the require of app.js: the
seed is where the host allowlist setting comes from, and the require is what
scans the volume. That buys it the database, so a compose-installed module gets
the same provenance columns an admin install writes.
A failure is logged and carried, never fatal — an unreachable release host must
not take the site down. The declaration owns what is on the volume; the row owns
whether a module runs, so uninstalling a declared module returns its files at
the next start and leaves it disabled. The admin list gains that as a fourth
source (declared / declaredVersion / declaredError), because a declared module
that failed to resolve has no row, no directory and nothing mounted.
Deferring the app require moved core's schema ahead of the volume scan, and the
module schema-fragment replay was wired to core's schema — so every installed
module silently got no tables. Invisible to the suite (each one stubs the loader
or the pool) and to a smoke on a database that already had the tables; found by
booting against an empty one. ensureSchema() now takes `replayModules: false`
for the one caller that scans later, server.js replays them itself after the
require, and a bootOrder test pins the five steps in the only order they work in.
741 server tests (+18), 187 client (+5); manifest unchanged at 166 public + 2
internal, OpenAPI byte-identical.
Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
13 KiB
JavaScript
282 lines
13 KiB
JavaScript
import { test } from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
|
|
|
|
// lib/moduleAdmin.js — what the Modules screen says about a module and what it
|
|
// lets you do to it. Phase 4, slice 2 of MODULE_SYSTEM.md §2.7.2.
|
|
//
|
|
// This is the part of the screen worth testing, and it is plain JS so this
|
|
// runner can reach it (there is no DOM here). What it encodes is §2.4's rule
|
|
// that the row, the loader and the volume are three sources of truth which are
|
|
// ALLOWED to disagree — so most of these cases are combinations that a screen
|
|
// picking one source would render as a lie.
|
|
|
|
/** A module as GET /admin/modules returns it, with the running case as default. */
|
|
const mod = (over = {}) => ({
|
|
id: 'uo',
|
|
name: 'Ultima Online',
|
|
version: '1.0.0',
|
|
state: 'started',
|
|
failureStage: null,
|
|
failureReason: null,
|
|
source: 'https://gitea.example.com/x/uo.json',
|
|
sha256: 'a'.repeat(64),
|
|
installedAt: null,
|
|
startedAt: null,
|
|
liveState: 'started',
|
|
liveVersion: '1.0.0',
|
|
capabilities: [],
|
|
onVolume: true,
|
|
canPurge: true,
|
|
declared: false,
|
|
declaredVersion: null,
|
|
declaredError: null,
|
|
...over,
|
|
})
|
|
|
|
// ── statusOf ───────────────────────────────────────────────────────────────
|
|
|
|
test('a mounted, started module is Running and needs nothing', () => {
|
|
const s = statusOf(mod())
|
|
assert.equal(s.label, 'Running')
|
|
assert.equal(s.tone, 'ok')
|
|
assert.equal(s.pending, false)
|
|
})
|
|
|
|
test('enabled in the row but disabled in the loader is "Restart to start"', () => {
|
|
// THE case decision 3 creates on purpose: disable ran the module's onShutdown,
|
|
// then the operator enabled it again. The row says enabled; nothing can start
|
|
// it before a restart. Showing either "Running" or "Disabled" would be false.
|
|
const s = statusOf(mod({ state: 'enabled', liveState: 'disabled' }))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.tone, 'warn')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /cannot be restarted in place/)
|
|
})
|
|
|
|
test('freshly installed and never booted into is also "Restart to start"', () => {
|
|
const s = statusOf(mod({ state: 'installed', liveState: null }))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /mounts when the server next starts/)
|
|
})
|
|
|
|
test('a disabled module is Disabled, and that is not pending anything', () => {
|
|
// Disable takes effect immediately — it is the one action that does — so there
|
|
// is nothing for a restart banner to be about.
|
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled' }))
|
|
assert.equal(s.label, 'Disabled')
|
|
assert.equal(s.pending, false)
|
|
})
|
|
|
|
test('a fresh install over a failed row is pending, not failed', () => {
|
|
// THE defect the §7.7 browser smoke found, and one no test here had modelled.
|
|
// Installing over a row the previous boot left `startup_failed` rendered
|
|
// "Failed at the require stage: module directory not present on the volume" a
|
|
// second after the files had been written — and suppressed the restart banner
|
|
// the install had just told the operator to use.
|
|
//
|
|
// `liveState === null` with the module on the volume means the loader's scan
|
|
// never saw it, so it arrived after boot and everything the row says predates
|
|
// it.
|
|
const s = statusOf(mod({
|
|
state: 'startup_failed',
|
|
liveState: null,
|
|
failureStage: 'require',
|
|
failureReason: 'module directory not present on the volume',
|
|
}))
|
|
assert.equal(s.label, 'Restart to start')
|
|
assert.equal(s.pending, true)
|
|
assert.doesNotMatch(s.detail, /not present on the volume/, 'the stale reason must not survive the install')
|
|
})
|
|
|
|
test('the restart banner appears for that install', () => {
|
|
// The second half of the same defect: the banner is driven by `pending`, so a
|
|
// row wrongly classified as failed silently removed the only way to act on it.
|
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: null })]), true)
|
|
})
|
|
|
|
test('an upgrade that has not been restarted into says so', () => {
|
|
// Same class as the stale-failure defect: the row is a promise about the next
|
|
// boot, not a description of this one. Reporting "Running v2.0.0" while the
|
|
// process is serving v1.0.0 would hide the only action that fixes it.
|
|
const s = statusOf(mod({ version: '2.0.0', liveVersion: '1.0.0' }))
|
|
assert.equal(s.label, 'Restart to finish upgrading')
|
|
assert.equal(s.pending, true)
|
|
assert.match(s.detail, /v2\.0\.0 is installed; v1\.0\.0 is still running/)
|
|
})
|
|
|
|
test('reinstalling the SAME version is not an upgrade in progress', () => {
|
|
assert.equal(statusOf(mod({ version: '1.0.0', liveVersion: '1.0.0' })).label, 'Running')
|
|
})
|
|
|
|
test('a failed module reports the stage and the reason it recorded', () => {
|
|
const s = statusOf(mod({
|
|
state: 'startup_failed',
|
|
liveState: 'startup_failed',
|
|
failureStage: 'schema',
|
|
failureReason: "Unknown column 'x' in 'field list'",
|
|
}))
|
|
assert.equal(s.label, 'Failed to start')
|
|
assert.equal(s.tone, 'bad')
|
|
assert.match(s.detail, /schema stage/)
|
|
assert.match(s.detail, /Unknown column/)
|
|
})
|
|
|
|
test('a failure with no recorded reason says so rather than showing a blank', () => {
|
|
const s = statusOf(mod({ state: 'startup_failed', liveState: 'startup_failed' }))
|
|
assert.match(s.detail, /recorded no reason/)
|
|
})
|
|
|
|
test('a row whose directory is gone by hand is bad, not merely disabled', () => {
|
|
// The boot reconcile marks this `startup_failed` because a row claiming to be
|
|
// enabled for a module that is not on the volume is simply untrue.
|
|
const s = statusOf(mod({ state: 'startup_failed', liveState: null, onVolume: false, failureStage: 'require', failureReason: 'module directory not present on the volume' }))
|
|
assert.equal(s.label, 'Missing from the volume')
|
|
assert.equal(s.tone, 'bad')
|
|
})
|
|
|
|
test('an uninstalled module reads as uninstalled, and says the data was kept', () => {
|
|
// Uninstall leaves the row `disabled` and the data alone — which is the whole
|
|
// point of keeping the row, so the screen has to say it.
|
|
const s = statusOf(mod({ state: 'disabled', liveState: 'disabled', onVolume: false }))
|
|
assert.equal(s.label, 'Uninstalled')
|
|
assert.equal(s.tone, 'idle')
|
|
assert.match(s.detail, /data was kept/i)
|
|
})
|
|
|
|
test('missing-from-the-volume beats every other status', () => {
|
|
// Ordering: a module with no files is described that way whatever its row
|
|
// still claims, because there is nothing there to be running.
|
|
for (const state of ['started', 'enabled', 'installed', 'startup_failed']) {
|
|
assert.match(statusOf(mod({ state, onVolume: false })).label, /Missing from the volume/)
|
|
}
|
|
})
|
|
|
|
// ── actionsFor ─────────────────────────────────────────────────────────────
|
|
|
|
test('a running module offers disable, uninstall and a blocked purge', () => {
|
|
const a = actionsFor(mod())
|
|
assert.equal(a.disable.shown, true)
|
|
assert.equal(a.enable.shown, false)
|
|
assert.equal(a.uninstall.shown, true)
|
|
assert.equal(a.purge.shown, true)
|
|
// Shown but not clickable: the server refuses a standalone purge on anything
|
|
// that is not disabled, so offering the click would only produce a 409.
|
|
assert.equal(a.purge.enabled, false)
|
|
assert.match(a.purge.reason, /Disable it first/)
|
|
})
|
|
|
|
test('a disabled module offers enable, and purge is now live', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled' }))
|
|
assert.equal(a.enable.shown, true)
|
|
assert.equal(a.disable.shown, false)
|
|
assert.equal(a.purge.enabled, true)
|
|
})
|
|
|
|
test('a module with no purge.sql never offers purge, and says why', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: 'disabled', canPurge: false }))
|
|
assert.equal(a.purge.shown, false)
|
|
assert.match(a.purge.reason, /ships no purge.sql/)
|
|
})
|
|
|
|
test('a module with no files offers only clearing the row', () => {
|
|
const a = actionsFor(mod({ state: 'disabled', liveState: null, onVolume: false }))
|
|
assert.equal(a.uninstall.shown, false)
|
|
assert.equal(a.disable.shown, false)
|
|
assert.equal(a.enable.shown, false)
|
|
assert.equal(a.purge.shown, false, 'there is no purge.sql left to run')
|
|
assert.equal(a.forget.shown, true)
|
|
})
|
|
|
|
test('a directory with no row yet is actionable, and offers nothing to forget', () => {
|
|
// A hand-placed install before its first boot: it has no row, so `state` is
|
|
// null. Its routes are already being served, so it must be disableable.
|
|
const a = actionsFor(mod({ state: null, liveState: 'started' }))
|
|
assert.equal(a.disable.shown, true)
|
|
assert.equal(a.uninstall.shown, true)
|
|
assert.equal(a.forget.shown, false)
|
|
})
|
|
|
|
// ── needsRestart ───────────────────────────────────────────────────────────
|
|
|
|
test('the restart banner is driven by the list, not by any one module', () => {
|
|
// A restart is a property of the SERVER. One pending module is enough, and
|
|
// three do not mean three restarts.
|
|
assert.equal(needsRestart([mod(), mod({ id: 'b' })]), false)
|
|
assert.equal(needsRestart([mod(), mod({ id: 'b', state: 'installed', liveState: null })]), true)
|
|
assert.equal(needsRestart([]), false)
|
|
})
|
|
|
|
test('a disabled module does not ask for a restart', () => {
|
|
// Disable is immediate; a banner here would be asking for a restart that
|
|
// would change nothing.
|
|
assert.equal(needsRestart([mod({ state: 'disabled', liveState: 'disabled' })]), false)
|
|
})
|
|
|
|
test('a failed module does not ask for a restart either', () => {
|
|
// It is retried on every boot anyway, and the operator has to fix the cause
|
|
// first — a banner would suggest restarting is the remedy.
|
|
assert.equal(needsRestart([mod({ state: 'startup_failed', liveState: 'startup_failed' })]), false)
|
|
})
|
|
|
|
// ── parseHosts ─────────────────────────────────────────────────────────────
|
|
|
|
test('parseHosts previews exactly what the server will store', () => {
|
|
assert.deepEqual(parseHosts('A.com, b.com\n c.com'), ['a.com', 'b.com', 'c.com'])
|
|
assert.deepEqual(parseHosts(' '), [])
|
|
assert.deepEqual(parseHosts(undefined), [])
|
|
})
|
|
|
|
// ── the declaration (slice 3) ──────────────────────────────────────────────
|
|
|
|
test('a declared module that has never installed says so, with the reason', () => {
|
|
// No row, no directory, nothing mounted — invisible to the other three
|
|
// sources, so without this branch the screen would describe a module it has
|
|
// never had as a row gone stale.
|
|
const s = statusOf(mod({
|
|
state: null,
|
|
liveState: null,
|
|
liveVersion: null,
|
|
version: null,
|
|
onVolume: false,
|
|
declared: true,
|
|
declaredVersion: '0.3.0',
|
|
declaredError: 'could not reach releases.example.com',
|
|
}))
|
|
assert.equal(s.label, 'Declared, not installed')
|
|
assert.equal(s.tone, 'bad')
|
|
assert.equal(s.pending, false, 'a restart will not fix an unreachable host')
|
|
assert.match(s.detail, /could not reach releases.example.com/)
|
|
})
|
|
|
|
test('a declared module waiting for its first resolution is not reported as failed', () => {
|
|
const s = statusOf(mod({ state: null, liveState: null, version: null, onVolume: false, declared: true, declaredVersion: '0.3.0' }))
|
|
assert.match(s.detail, /installed when the server next starts/)
|
|
})
|
|
|
|
test('a running module whose declared upgrade is failing is still Running', () => {
|
|
// Both facts are true at once. The status is one label, so the declaration
|
|
// gets its own line rather than overwriting it.
|
|
const m = mod({ declared: true, declaredVersion: '2.0.0', declaredError: 'sha256 did not match' })
|
|
assert.equal(statusOf(m).label, 'Running')
|
|
const note = declarationNoteFor(m)
|
|
assert.equal(note.tone, 'warn')
|
|
assert.match(note.text, /sha256 did not match/)
|
|
})
|
|
|
|
test('uninstalling a declared module is told that its files come back', () => {
|
|
// The sentence that saves an afternoon: MODULES owns what is on the volume,
|
|
// the row owns whether it runs.
|
|
const note = declarationNoteFor(mod({ state: 'disabled', liveState: null, onVolume: false, declared: true, declaredVersion: '1.0.0' }))
|
|
assert.match(note.text, /come back when the server next starts/)
|
|
assert.match(note.text, /switched off/)
|
|
})
|
|
|
|
test('an ordinary declared module gets a quiet note, and an undeclared one none', () => {
|
|
assert.equal(declarationNoteFor(mod()), null)
|
|
const note = declarationNoteFor(mod({ declared: true, declaredVersion: '1.0.0' }))
|
|
assert.equal(note.tone, 'idle')
|
|
assert.match(note.text, /MODULES/)
|
|
})
|