feat(modules): the declarative Docker path (phase 4, slice 3)
Some checks failed
PR Checks / bot-install (pull_request) Successful in 23s
PR Checks / client-build (pull_request) Successful in 30s
PR Checks / server-tests (pull_request) Failing after 4m23s

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>
This commit is contained in:
2026-08-12 07:57:08 -05:00
parent 75f4d29e93
commit 9b16f39a52
15 changed files with 1090 additions and 28 deletions

View File

@@ -6,13 +6,15 @@
// runner here has no DOM, and this is the part of the Modules screen that is
// actually worth testing.
//
// **The screen has three sources of truth and they are allowed to disagree**
// (MODULE_SYSTEM.md §2.4):
// **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
@@ -34,6 +36,22 @@
* @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".
@@ -125,6 +143,43 @@ export function statusOf(m) {
}
}
/**
* 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.
*

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { statusOf, actionsFor, needsRestart, parseHosts } from '../../../lib/moduleAdmin.js'
import { statusOf, actionsFor, declarationNoteFor, needsRestart, parseHosts } from '../../../lib/moduleAdmin.js'
import { api } from '../../../api/client.js'
// Installed modules: install from a release URL, enable, disable, uninstall,
@@ -226,6 +226,7 @@ function ModuleRow({ m, onChanged, onError }) {
const [busy, setBusy] = useState('')
const status = statusOf(m)
const actions = actionsFor(m)
const note = declarationNoteFor(m)
async function run(name, fn) {
setBusy(name)
@@ -272,7 +273,9 @@ function ModuleRow({ m, onChanged, onError }) {
<td className="adm-td" style={{ color: 'var(--text)' }}>
<div style={{ fontWeight: 600 }}>{m.name}</div>
<div className="dim" style={{ fontSize: '0.76rem' }}>
{m.id} · v{m.version}
{/* A declared module that has never installed has no version to show —
only the one MODULES asks for, which the status column carries. */}
{m.id}{m.version ? ` · v${m.version}` : ''}
</div>
{m.capabilities?.length > 0 && (
<div className="dim" style={{ fontSize: '0.72rem', marginTop: 2 }}>{m.capabilities.join(' · ')}</div>
@@ -282,18 +285,37 @@ function ModuleRow({ m, onChanged, onError }) {
<td className="adm-td">
<Pill tone={status.tone}>{status.label}</Pill>
<div className="dim" style={{ fontSize: '0.74rem', marginTop: 4, maxWidth: 380 }}>{status.detail}</div>
{/* The environment's declaration, on its own line: a module can be
running fine while its declared upgrade is failing, and the status
above can only be one of those two things. */}
{note && (
<div
style={{
fontSize: '0.74rem',
marginTop: 4,
maxWidth: 380,
color: note.tone === 'warn' ? TONE.warn : 'var(--muted)',
}}
>
{note.text}
</div>
)}
</td>
<td className="adm-td dim" style={{ fontSize: '0.74rem' }}>
{/* Provenance. Null for a directory placed on the volume by hand, which
stays a supported install — so it is shown as that, not as missing. */}
stays a supported install — so it is shown as that, not as missing.
A declared module can also reach a boot with no provenance: the
no-op path never fetches, so it has no sha256 to record and no
reason to write a row. Saying "by hand" there would be the one
wrong answer. */}
{m.source ? (
<>
<div style={{ wordBreak: 'break-all', maxWidth: 260 }}>{m.source}</div>
{m.sha256 && <div style={{ marginTop: 2 }}>sha256 {m.sha256.slice(0, 12)}</div>}
</>
) : (
<span>Placed on the volume by hand</span>
<span>{m.declared ? 'From the declared module set' : 'Placed on the volume by hand'}</span>
)}
{m.installedAt && <div style={{ marginTop: 2 }}>{dateTime(m.installedAt)}</div>}
</td>

View File

@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { statusOf, actionsFor, needsRestart, parseHosts } from '../src/lib/moduleAdmin.js'
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.
@@ -28,6 +28,9 @@ const mod = (over = {}) => ({
capabilities: [],
onVolume: true,
canPurge: true,
declared: false,
declaredVersion: null,
declaredError: null,
...over,
})
@@ -224,3 +227,55 @@ test('parseHosts previews exactly what the server will store', () => {
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/)
})