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:
@@ -29,6 +29,7 @@ const settings = require('../src/model/settings/settings.model')
|
||||
const loader = require('../src/modules/loader')
|
||||
const lifecycle = require('../src/modules/lifecycle')
|
||||
const install = require('../src/modules/install')
|
||||
const declared = require('../src/modules/declared')
|
||||
const schema = require('../src/modules/schema')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
@@ -67,6 +68,7 @@ const originals = {
|
||||
removeDir: install.removeDir,
|
||||
},
|
||||
schema: { runPurge: schema.runPurge },
|
||||
declared: { state: declared.state },
|
||||
}
|
||||
|
||||
let logged
|
||||
@@ -79,6 +81,7 @@ beforeEach(() => {
|
||||
Object.assign(lifecycle, originals.lifecycle)
|
||||
Object.assign(install, originals.install)
|
||||
Object.assign(schema, originals.schema)
|
||||
Object.assign(declared, originals.declared)
|
||||
|
||||
logged = []
|
||||
activity.log = async (entry) => { logged.push(entry) }
|
||||
@@ -133,6 +136,55 @@ test('list includes a module on the volume that has no row yet', async () => {
|
||||
assert.equal(res.body.modules[0].liveState, 'started')
|
||||
})
|
||||
|
||||
test('list carries what MODULES declares, including a module it could not install', async () => {
|
||||
// Slice 3's fourth source. A declared module that failed to resolve has no
|
||||
// row, no directory and nothing mounted — so it is invisible to the other
|
||||
// three, and the reason it is missing is the one thing the operator needs.
|
||||
modules.list = async () => [{
|
||||
id: 'uo', name: 'UO', version: '0.2.0', state: 'enabled',
|
||||
failureStage: null, failureReason: null, source: null, sha256: null,
|
||||
installedAt: null, startedAt: null,
|
||||
}]
|
||||
loader.list = () => [{ id: 'uo', name: 'UO', version: '0.2.0', state: 'started', stage: null, reason: null, capabilities: [] }]
|
||||
install.isInstalled = (id) => id === 'uo'
|
||||
install.purgeFile = () => null
|
||||
declared.state = () => [
|
||||
{ id: 'uo', version: '0.3.0', url: 'https://x/uo.json', action: 'failed', message: 'the host is down' },
|
||||
{ id: 'market', version: '1.0.0', url: 'https://x/market.json', action: 'failed', message: 'not found' },
|
||||
]
|
||||
|
||||
const res = mockRes()
|
||||
await ctrl.list(req(), res)
|
||||
|
||||
const byId = Object.fromEntries(res.body.modules.map((m) => [m.id, m]))
|
||||
// Running fine at 0.2.0 while the declared upgrade to 0.3.0 is failing: both
|
||||
// facts survive, because collapsing them would have to discard one.
|
||||
assert.equal(byId.uo.liveState, 'started')
|
||||
assert.equal(byId.uo.declared, true)
|
||||
assert.equal(byId.uo.declaredVersion, '0.3.0')
|
||||
assert.equal(byId.uo.declaredError, 'the host is down')
|
||||
// Declared and nowhere: listed anyway, with no version invented for it.
|
||||
assert.equal(byId.market.declared, true)
|
||||
assert.equal(byId.market.version, null)
|
||||
assert.equal(byId.market.state, null)
|
||||
assert.equal(byId.market.declaredError, 'not found')
|
||||
})
|
||||
|
||||
test('a successfully resolved module is marked declared, with no error', async () => {
|
||||
modules.list = async () => []
|
||||
loader.list = () => [{ id: 'uo', name: 'UO', version: '0.3.0', state: 'started', stage: null, reason: null, capabilities: [] }]
|
||||
install.isInstalled = () => true
|
||||
install.purgeFile = () => null
|
||||
declared.state = () => [{ id: 'uo', version: '0.3.0', url: 'https://x/uo.json', action: 'noop', message: null }]
|
||||
|
||||
const res = mockRes()
|
||||
await ctrl.list(req(), res)
|
||||
|
||||
assert.equal(res.body.modules.length, 1, 'declared and present is ONE module, not two')
|
||||
assert.equal(res.body.modules[0].declared, true)
|
||||
assert.equal(res.body.modules[0].declaredError, null)
|
||||
})
|
||||
|
||||
test('list survives a process where the loader never scanned', async () => {
|
||||
loader.isLoaded = () => false
|
||||
loader.list = () => { throw new Error('modules.list() before modules.load()') }
|
||||
|
||||
Reference in New Issue
Block a user