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:
@@ -79,6 +79,13 @@ class InstallError extends Error {
|
||||
|
||||
// ── The allowlist ──────────────────────────────────────────────────────────
|
||||
|
||||
// The settings row the allowlist lives in (decision 6): seeded from
|
||||
// MODULE_SOURCE_HOSTS on a fresh install and admin-managed from then on. The KEY
|
||||
// lives here rather than in the admin controller because it is now read from two
|
||||
// places — the controller, and the boot-time resolution of the declared module
|
||||
// set (modules/declared.js), which has no route and no request.
|
||||
const HOSTS_SETTING = 'module_source_hosts'
|
||||
|
||||
/**
|
||||
* Parse the stored allowlist setting into hostnames.
|
||||
*
|
||||
@@ -313,13 +320,34 @@ function purgeFile(id) {
|
||||
* scratch directory that is removed on any failure, and the move into place is
|
||||
* the last step.
|
||||
*
|
||||
* `expect` is what the CALLER was promised, as opposed to what the manifest
|
||||
* promises about itself — the declared module set (modules/declared.js) pins an
|
||||
* id and a version in the environment, and a URL that resolves to something else
|
||||
* has to be refused rather than installed. Checked against the manifest, before
|
||||
* a byte is downloaded: catching it after the unpack would mean the undeclared
|
||||
* module is already on the volume when the objection is raised. The admin panel
|
||||
* passes nothing, because there a URL is the whole of what was asked for.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.url the install manifest URL the admin pasted
|
||||
* @param {string[]} args.hosts the allowlist, already parsed
|
||||
* @param {{id?: string, version?: string}} [args.expect] what the caller pinned
|
||||
* @returns {Promise<{id,name,version,sha256,source,bytes,replaced}>}
|
||||
*/
|
||||
async function install({ url, hosts, fetchImpl = fetch }) {
|
||||
async function install({ url, hosts, expect = null, fetchImpl = fetch }) {
|
||||
const manifest = await fetchManifest(url, hosts, fetchImpl)
|
||||
|
||||
if (expect && expect.id && manifest.id !== expect.id) {
|
||||
throw new InstallError(
|
||||
`that URL installs the module "${manifest.id}", but "${expect.id}" was asked for`,
|
||||
)
|
||||
}
|
||||
if (expect && expect.version && manifest.version !== expect.version) {
|
||||
throw new InstallError(
|
||||
`that URL installs ${manifest.id} v${manifest.version}, but v${expect.version} was asked for`,
|
||||
)
|
||||
}
|
||||
|
||||
const target = moduleDir(manifest.id)
|
||||
const scratch = await fsp.mkdtemp(path.join(loader.dir(), `.install-${manifest.id}-`))
|
||||
const tarball = path.join(scratch, 'bundle.tar.gz')
|
||||
@@ -407,6 +435,7 @@ async function removeDir(id) {
|
||||
|
||||
module.exports = {
|
||||
InstallError,
|
||||
HOSTS_SETTING,
|
||||
parseHosts,
|
||||
checkUrl,
|
||||
get,
|
||||
|
||||
Reference in New Issue
Block a user