feat(modules): the declarative Docker path (phase 4, slice 3)
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:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user