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>
253 lines
9.9 KiB
JavaScript
253 lines
9.9 KiB
JavaScript
// What an admin should be told about one installed module, and what they may do
|
|
// to it — derived, not spelled out at each button.
|
|
//
|
|
// Phase 4, slice 2 of docs/website/MODULE_SYSTEM.md §2.7.2. Plain JS rather than
|
|
// a hook or a chunk of JSX, for the same reason `lib/adminNav.js` is: the test
|
|
// runner here has no DOM, and this is the part of the Modules screen that is
|
|
// actually worth testing.
|
|
//
|
|
// **The screen has four sources of truth and they are allowed to disagree**
|
|
// (MODULE_SYSTEM.md §2.4, and slice 3 for the fourth):
|
|
//
|
|
// state what the DATABASE row records — what the operator decided, and
|
|
// what the last boot ended up doing
|
|
// liveState what the LOADER has mounted in this process and is answering with
|
|
// onVolume whether there is still a directory there at all
|
|
// declared what this container's MODULES variable asks for — the only one of
|
|
// the four that no button on this screen can change
|
|
//
|
|
// Picking one and rendering it would be simpler and would lie. The case that
|
|
// makes this concrete is the one decision 3 creates on purpose: an operator
|
|
// disables a module (its onShutdown runs, its routes 404) and then enables it
|
|
// again. The row says `enabled`; the loader still says `disabled`, because
|
|
// there is no `onBoot` re-dispatch and nothing can start it before a restart.
|
|
// It is neither running nor off, and the honest thing to show is "enabled —
|
|
// restart to start it".
|
|
|
|
/**
|
|
* The one-line status of a module, and whether that status is waiting on a
|
|
* restart.
|
|
*
|
|
* Ordering matters here. The checks run most-alarming first, so a module whose
|
|
* directory has been deleted is described that way rather than by whatever its
|
|
* row happens to still say.
|
|
*
|
|
* @param {object} m a row from GET /admin/modules
|
|
* @returns {{ label: string, tone: 'ok'|'warn'|'bad'|'idle', pending: boolean, detail: string }}
|
|
*/
|
|
export function statusOf(m) {
|
|
// Declared by the environment and not there at all: no row, no directory,
|
|
// nothing mounted. Every other branch below reads one of those three, so
|
|
// without this the screen would describe a module it has never had as though
|
|
// a row had gone stale — and the one thing the operator needs, the reason
|
|
// resolution failed, would be nowhere.
|
|
if (m.declared && !m.onVolume && m.state === null) {
|
|
return {
|
|
label: 'Declared, not installed',
|
|
tone: 'bad',
|
|
pending: false,
|
|
detail: m.declaredError
|
|
? `MODULES asks for v${m.declaredVersion}; the last start could not install it: ${m.declaredError}`
|
|
: `MODULES asks for v${m.declaredVersion}. It will be installed when the server next starts.`,
|
|
}
|
|
}
|
|
|
|
// Gone from the volume, but still known. Either a hand-deleted directory (the
|
|
// boot reconcile marks that `startup_failed`) or an uninstall waiting for its
|
|
// restart. Both are "there is nothing to run here".
|
|
if (!m.onVolume) {
|
|
return {
|
|
label: m.state === 'disabled' ? 'Uninstalled' : 'Missing from the volume',
|
|
tone: m.state === 'disabled' ? 'idle' : 'bad',
|
|
pending: m.liveState !== null,
|
|
detail: m.state === 'disabled'
|
|
? 'The files are gone. Its data was kept, and reinstalling brings it back.'
|
|
: 'A row exists but there is no module directory. Reinstall it, or uninstall to clear the row.',
|
|
}
|
|
}
|
|
|
|
// **Installed since this process booted**, and this check has to come before
|
|
// the failure one. `liveState` is the loader's record, and the loader scans
|
|
// the volume once at require time — so a module that is on the volume NOW and
|
|
// has no live record was put there after the scan. Anything the row still says
|
|
// about it therefore predates the install and is stale by definition.
|
|
//
|
|
// Found by the §7.7 browser smoke, and no unit test here had modelled it:
|
|
// installing over a row left `startup_failed` by the previous boot rendered
|
|
// "Failed at the require stage: module directory not present on the volume"
|
|
// one second after the file had been written to the volume — and, because that
|
|
// branch is not pending, suppressed the restart banner the install had just
|
|
// told the operator to use.
|
|
if (m.liveState === null) {
|
|
return {
|
|
label: 'Restart to start',
|
|
tone: 'warn',
|
|
pending: true,
|
|
detail: 'Installed. It mounts when the server next starts.',
|
|
}
|
|
}
|
|
|
|
if (m.state === 'startup_failed' || m.liveState === 'startup_failed') {
|
|
return {
|
|
label: 'Failed to start',
|
|
tone: 'bad',
|
|
pending: false,
|
|
detail: m.failureReason
|
|
? `Failed at the ${m.failureStage || 'unknown'} stage: ${m.failureReason}`
|
|
: 'It failed to start and recorded no reason.',
|
|
}
|
|
}
|
|
|
|
if (m.state === 'disabled') {
|
|
return {
|
|
label: 'Disabled',
|
|
tone: 'idle',
|
|
pending: false,
|
|
detail: 'Stopped and switched off. Its routes answer 404 and it stays off across restarts.',
|
|
}
|
|
}
|
|
|
|
// The row has been switched on but the loader has not started it — the
|
|
// decision-3 case: disable ran its onShutdown, and nothing can start it again
|
|
// before a restart.
|
|
if (m.liveState !== 'started') {
|
|
return {
|
|
label: 'Restart to start',
|
|
tone: 'warn',
|
|
pending: true,
|
|
detail: m.liveState === 'disabled'
|
|
? 'Enabled, but still stopped in the running server — it cannot be restarted in place.'
|
|
: 'Enabled. It mounts when the server next starts.',
|
|
}
|
|
}
|
|
|
|
// Running, but not the version that is installed. An upgrade writes new files
|
|
// and a new row while the old code stays loaded, so the row's `version` is a
|
|
// promise about the next boot rather than a description of this one — and
|
|
// "Running v2.0.0" beside a process serving v1.0.0 is the same lie as the
|
|
// stale-failure one above, in a different place.
|
|
if (m.liveVersion && m.liveVersion !== m.version) {
|
|
return {
|
|
label: 'Restart to finish upgrading',
|
|
tone: 'warn',
|
|
pending: true,
|
|
detail: `v${m.version} is installed; v${m.liveVersion} is still running.`,
|
|
}
|
|
}
|
|
|
|
return {
|
|
label: 'Running',
|
|
tone: 'ok',
|
|
pending: false,
|
|
detail: 'Mounted and serving.',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the environment's declaration means for this module, as one sentence — or
|
|
* null if nothing declares it.
|
|
*
|
|
* Kept out of `statusOf` on purpose. A module can be running perfectly while its
|
|
* declared upgrade is failing, and collapsing both into one label would have to
|
|
* pick which of the two is "the" status. This is a second line, beside the first.
|
|
*
|
|
* The sentence an operator most needs is the uninstall one: MODULES owns what is
|
|
* on the volume and the row owns whether it runs, so uninstalling a declared
|
|
* module puts its files back at the next start and leaves it switched off. Files
|
|
* reappearing unexplained is exactly the kind of thing that gets debugged for an
|
|
* afternoon.
|
|
*
|
|
* @param {object} m a row from GET /admin/modules
|
|
* @returns {{ text: string, tone: 'warn'|'idle' }|null}
|
|
*/
|
|
export function declarationNoteFor(m) {
|
|
if (!m.declared) return null
|
|
|
|
if (m.declaredError) {
|
|
return {
|
|
text: `MODULES asks for v${m.declaredVersion} and the last start could not install it: ${m.declaredError}`,
|
|
tone: 'warn',
|
|
}
|
|
}
|
|
if (!m.onVolume) {
|
|
return {
|
|
text:
|
|
`MODULES declares v${m.declaredVersion}, so its files come back when the server next starts`
|
|
+ (m.state === 'disabled' ? ' — switched off, until you enable it.' : '.'),
|
|
tone: 'warn',
|
|
}
|
|
}
|
|
return { text: `Declared by this deployment's MODULES variable at v${m.declaredVersion}.`, tone: 'idle' }
|
|
}
|
|
|
|
/**
|
|
* Which actions are offered for a module, and why the others are not.
|
|
*
|
|
* Returned as a map of `{ shown, reason }` rather than a list of shown actions,
|
|
* so a disabled button can say what would make it available. Every rule here
|
|
* mirrors one the server enforces — this is presentation, never the boundary.
|
|
*
|
|
* @param {object} m a row from GET /admin/modules
|
|
*/
|
|
export function actionsFor(m) {
|
|
const running = m.liveState === 'started'
|
|
const disabled = m.state === 'disabled'
|
|
|
|
return {
|
|
// Only offered while something is actually running: disabling a module that
|
|
// is already stopped has nothing to stop and no guard to flip.
|
|
disable: {
|
|
shown: !disabled && m.onVolume,
|
|
reason: disabled ? 'Already disabled.' : 'Nothing is running to stop.',
|
|
},
|
|
enable: {
|
|
shown: disabled && m.onVolume,
|
|
reason: 'Only a disabled module can be enabled.',
|
|
},
|
|
uninstall: {
|
|
shown: m.onVolume,
|
|
reason: 'There are no files left to remove.',
|
|
},
|
|
// The server refuses a standalone purge unless the module is disabled, so
|
|
// the button says so rather than offering a click that 409s.
|
|
purge: {
|
|
shown: m.onVolume && m.canPurge,
|
|
enabled: disabled,
|
|
reason: !m.canPurge
|
|
? 'This module ships no purge.sql, so its data cannot be deleted.'
|
|
: 'Disable it first, so nothing is serving out of the tables being dropped.',
|
|
},
|
|
// A row with no directory is the one thing an uninstall cannot tidy through
|
|
// the normal path — offer clearing it instead.
|
|
forget: {
|
|
shown: !m.onVolume && m.state !== null,
|
|
reason: 'The module is still installed.',
|
|
},
|
|
running,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Does anything on this list need a restart before it matches what is running?
|
|
*
|
|
* Drives the one banner at the top of the screen rather than a badge per row:
|
|
* the restart is a property of the SERVER, not of a module, and offering it
|
|
* five times would suggest otherwise.
|
|
*/
|
|
export const needsRestart = (modules) => modules.some((m) => statusOf(m).pending)
|
|
|
|
/**
|
|
* Split a hosts string the way the server will.
|
|
*
|
|
* Duplicated from `install.parseHosts` deliberately — it is four lines, and the
|
|
* alternative is an API round trip to preview what the field is going to mean.
|
|
* The server remains the one that decides; this only shows the operator how
|
|
* their typing will be read.
|
|
*/
|
|
export function parseHosts(value) {
|
|
return String(value || '')
|
|
.split(/[,\s]+/)
|
|
.map((h) => h.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
}
|