@bongos/core 1.20.38 → 1.20.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +41 -31
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +251 -248
- package/modules/npm-release/preview/childlog.js +95 -0
- package/modules/npm-release/preview/commands.js +46 -5
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/runtime.js +43 -9
- package/modules/npm-release/preview/supervisor.js +170 -31
- package/modules/npm-release/public/work.css +3 -0
- package/modules/npm-release/public/work.js +6 -1
- package/modules/npm-release/routes/preview.js +1 -0
- package/modules/provisioning/render-standup.js +13 -1
- package/modules/public-landing/public/projects.html +22 -8
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/gds/provision-render.js +13 -1
- package/scripts/gds/render-payload.js +6 -5
- package/src/module-api.js +1 -1
- package/tests/npm_release_preview_commands.mjs +1 -1
- package/tests/npm_release_preview_diagnosis.mjs +376 -0
- package/tests/npm_release_preview_supervisor.mjs +4 -4
- package/tests/projects_hub_app_step.mjs +49 -0
- package/tests/projects_hub_render_connect.mjs +2 -1
- package/tests/provision_render.mjs +23 -2
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/childlog.js — what the preview child printed, kept small and
|
|
4
|
+
// with secrets taken out (task 1004465).
|
|
5
|
+
//
|
|
6
|
+
// WHY. The child used to be spawned with stdio 'ignore', so when a preview started but could
|
|
7
|
+
// not serve a single page, its own error went nowhere and the only way to find out was ssh.
|
|
8
|
+
// This keeps the last CAP bytes of its stdout and stderr, writes them to a file in the
|
|
9
|
+
// preview's scratch directory, and hands the deploy page a short redacted tail.
|
|
10
|
+
//
|
|
11
|
+
// REDACTION IS BY VALUE AND BY SHAPE. Every value in the environment the child was given
|
|
12
|
+
// (and the live one) whose name looks secret is replaced wherever it appears; then the common
|
|
13
|
+
// shapes (a password in a connection URL, `token=...`, a bearer header, a GitHub or API key)
|
|
14
|
+
// are replaced even if nobody listed them. The tail never reaches a caller unredacted: it is
|
|
15
|
+
// redacted at read time as well as at write time, so a secret that arrives split across two
|
|
16
|
+
// chunks is still caught.
|
|
17
|
+
|
|
18
|
+
const CAP = 64 * 1024;
|
|
19
|
+
// The file is rewritten at most this often, asynchronously: the child's output is handled
|
|
20
|
+
// inside the LIVE hall's process, and a chatty or crash-looping child must never turn into a
|
|
21
|
+
// run of blocking disk writes on its event loop.
|
|
22
|
+
const FLUSH_MS = 500;
|
|
23
|
+
const TAIL_LINES = 20;
|
|
24
|
+
const MAX_LINE = 300;
|
|
25
|
+
const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASS|KEY|CREDENTIAL|COOKIE|AUTH)/i;
|
|
26
|
+
|
|
27
|
+
const SHAPES = [
|
|
28
|
+
[/([a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:)[^\s@/]+@/gi, '$1[redacted]@'],
|
|
29
|
+
[/\b((?:api[_-]?key|token|secret|password|passwd|authorization|cookie|signing[_-]?key)\w*\s*[=:]\s*)("[^"]*"|'[^']*'|\S+)/gi, '$1[redacted]'],
|
|
30
|
+
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/g, 'Bearer [redacted]'],
|
|
31
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,})/g, '[redacted]'],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The values in these environments that look like secrets and are long enough to matter. */
|
|
35
|
+
function secretValues(...envs) {
|
|
36
|
+
const out = new Set();
|
|
37
|
+
for (const env of envs) {
|
|
38
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
39
|
+
if (SECRET_NAME.test(k) && typeof v === 'string' && v.length >= 6) out.add(v);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Longest first, so a value that contains another is replaced whole.
|
|
43
|
+
return [...out].sort((a, b) => b.length - a.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Take the secrets out of one piece of text. Pure. */
|
|
47
|
+
function redact(text, secrets = []) {
|
|
48
|
+
let s = String(text);
|
|
49
|
+
for (const v of secrets) s = s.split(v).join('[redacted]');
|
|
50
|
+
for (const [re, to] of SHAPES) s = s.replace(re, to);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} [o]
|
|
56
|
+
* @param {string[]} [o.secrets] exact values to take out (from secretValues)
|
|
57
|
+
* @param {object} [o.fsImpl] fs (writeFile, the callback form) — optional, with `file`
|
|
58
|
+
* @param {string} [o.file] where to keep the capped raw-but-redacted log
|
|
59
|
+
* @param {number} [o.cap]
|
|
60
|
+
*/
|
|
61
|
+
function createChildLog({ secrets = [], fsImpl = null, file = null, cap = CAP, flushMs = FLUSH_MS } = {}) {
|
|
62
|
+
let buf = '';
|
|
63
|
+
let timer = null;
|
|
64
|
+
let closed = false;
|
|
65
|
+
const canWrite = () => !!(fsImpl && file && typeof fsImpl.writeFile === 'function');
|
|
66
|
+
function flush() {
|
|
67
|
+
timer = null;
|
|
68
|
+
if (closed || !canWrite()) return;
|
|
69
|
+
try { fsImpl.writeFile(file, buf, () => { /* best effort */ }); } catch { /* best effort */ }
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
/** Add output. Never throws: a log that cannot be kept must not fail a start. */
|
|
73
|
+
append(chunk) {
|
|
74
|
+
try {
|
|
75
|
+
buf = (buf + redact(chunk, secrets)).slice(-cap);
|
|
76
|
+
if (canWrite() && !timer && !closed) {
|
|
77
|
+
timer = setTimeout(flush, flushMs);
|
|
78
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
79
|
+
}
|
|
80
|
+
} catch { /* best effort */ }
|
|
81
|
+
},
|
|
82
|
+
/** Write the file now (tests; the timer does this in production). */
|
|
83
|
+
flush,
|
|
84
|
+
/** Stop writing: the scratch directory is about to be removed. */
|
|
85
|
+
close() { closed = true; if (timer) { clearTimeout(timer); timer = null; } },
|
|
86
|
+
/** The last `n` non-empty lines, each shortened. */
|
|
87
|
+
tail(n = TAIL_LINES) {
|
|
88
|
+
return redact(buf, secrets).split(/\r?\n/).filter((l) => l.trim())
|
|
89
|
+
.slice(-n).map((l) => (l.length > MAX_LINE ? `${l.slice(0, MAX_LINE)}…` : l));
|
|
90
|
+
},
|
|
91
|
+
size: () => buf.length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { createChildLog, redact, secretValues };
|
|
@@ -36,11 +36,49 @@ function installCommand({ cacheRoot, version }) {
|
|
|
36
36
|
return { cmd: 'npm', args: ['install', '--prefix', versionDir(cacheRoot, version), `${PACKAGE}@${version}`, '--omit=dev'] };
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Drop the copy if one is left over — used before a start and on every stop. --force ends any
|
|
41
|
+
* connection still open on the copy (Postgres 13+), which is exactly the case that used to
|
|
42
|
+
* leave it behind: a child that had not finished exiting. The name is the fixed PREVIEW_DB
|
|
43
|
+
* and nothing is interpolated, so this can never name the live database.
|
|
44
|
+
*/
|
|
40
45
|
function dropDbCommand() {
|
|
46
|
+
return { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Plain drop, for a server whose dropdb has no --force (Postgres 12 and older). */
|
|
50
|
+
function dropDbPlainCommand() {
|
|
41
51
|
return { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/**
|
|
55
|
+
* End every connection to the copy and ONLY the copy: the database name is the literal
|
|
56
|
+
* PREVIEW_DB, never a parameter. Run on the maintenance database, since the copy cannot be
|
|
57
|
+
* connected to while it is being emptied.
|
|
58
|
+
*/
|
|
59
|
+
function terminateBackendsCommand() {
|
|
60
|
+
return {
|
|
61
|
+
cmd: 'psql',
|
|
62
|
+
args: ['-X', '-d', 'postgres', '-tAc',
|
|
63
|
+
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${PREVIEW_DB}' AND pid <> pg_backend_pid()`],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Can this command only ever touch the copy? True for the drop commands above and nothing
|
|
69
|
+
* else. The supervisor checks it before running any drop, so a future edit that pointed one
|
|
70
|
+
* at another database is refused at run time, not discovered on the droplet.
|
|
71
|
+
*/
|
|
72
|
+
function targetsOnlyPreviewDb(command) {
|
|
73
|
+
if (!command || !Array.isArray(command.args)) return false;
|
|
74
|
+
if (command.cmd === 'dropdb') {
|
|
75
|
+
const last = command.args[command.args.length - 1];
|
|
76
|
+
return last === PREVIEW_DB && command.args.slice(0, -1).every((a) => a.startsWith('--'));
|
|
77
|
+
}
|
|
78
|
+
if (command.cmd === 'psql') return JSON.stringify(command.args) === JSON.stringify(terminateBackendsCommand().args);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
/** An empty database for the copy. Deliberately no template (see the header). */
|
|
45
83
|
function createDbCommand() {
|
|
46
84
|
return { cmd: 'createdb', args: [PREVIEW_DB] };
|
|
@@ -65,17 +103,20 @@ function restoreCommand({ dumpFile }) {
|
|
|
65
103
|
* DATABASE_URL is blanked because the pool prefers it to PGDATABASE, and PGDATABASE is
|
|
66
104
|
* pinned to the copy so nothing can be applied to the live database by mistake.
|
|
67
105
|
*/
|
|
68
|
-
function migrateCommand({ cacheRoot, version, env }) {
|
|
106
|
+
function migrateCommand({ cacheRoot, version, env, instanceDir }) {
|
|
69
107
|
const dir = versionDir(cacheRoot, version);
|
|
108
|
+
// With a scratch instance directory, THAT is the instance root (its config/ and
|
|
109
|
+
// migrations/instance/ are copies of the live instance's); without one, the install directory.
|
|
110
|
+
const root = instanceDir || dir;
|
|
70
111
|
return {
|
|
71
112
|
cmd: 'bash',
|
|
72
113
|
args: [path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'migrate.sh')],
|
|
73
|
-
cwd:
|
|
74
|
-
env: { ...env, INIT_CWD:
|
|
114
|
+
cwd: root,
|
|
115
|
+
env: { ...env, INIT_CWD: root, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
|
|
75
116
|
};
|
|
76
117
|
}
|
|
77
118
|
|
|
78
119
|
module.exports = {
|
|
79
120
|
VERSION_RE, PACKAGE, versionDir, entryPath,
|
|
80
|
-
installCommand, dropDbCommand, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
|
|
121
|
+
installCommand, dropDbCommand, dropDbPlainCommand, terminateBackendsCommand, targetsOnlyPreviewDb, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
|
|
81
122
|
};
|
|
@@ -41,12 +41,15 @@ const POLLERS_OFF = Object.freeze([
|
|
|
41
41
|
|
|
42
42
|
// Modules that talk to the outside world, off. npm-release is off too: a preview must not
|
|
43
43
|
// offer previews of its own.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
//
|
|
45
|
+
// EVERY SPELLING OF THE PREFIX. The core reads <PREFIX>_MODULE_<KEY> with the PREFIX the
|
|
46
|
+
// instance's branding pack names, then the legacy ones. The child now runs with a copy of the
|
|
47
|
+
// live instance's config/ (task 1004465), so its prefix may be the live instance's own, and a
|
|
48
|
+
// switch spelled only BONGOS_ would then be ignored — Discord and the agents would come back
|
|
49
|
+
// on in the copy. So each switch is set under every prefix the core can resolve.
|
|
50
|
+
const MODULE_KEYS = Object.freeze(['DISCORD', 'AGENTS', 'PROVISIONING', 'NPM_RELEASE']);
|
|
51
|
+
const PREFIXES = Object.freeze(['BONGOS', 'CLOUDBONGOS', 'OTB', 'GDS', 'PMS']);
|
|
52
|
+
const MODULES_OFF = Object.freeze(PREFIXES.flatMap((pfx) => MODULE_KEYS.map((k) => `${pfx}_MODULE_${k}`)));
|
|
50
53
|
|
|
51
54
|
/**
|
|
52
55
|
* Build the child's environment.
|
|
@@ -55,9 +58,12 @@ const MODULES_OFF = Object.freeze([
|
|
|
55
58
|
* @param {object} [o.env] the live process's environment (only CARRIED names are read)
|
|
56
59
|
* @param {number|string} o.port the loopback port the preview listens on
|
|
57
60
|
* @param {string} o.scratchHome an empty directory to serve as HOME
|
|
61
|
+
* @param {string} [o.envPrefix] the env prefix of the copied instance's branding pack, if any
|
|
62
|
+
* @param {string} [o.instanceRoot] the scratch directory shaped like an instance root (config/,
|
|
63
|
+
* migrations/instance/); the child reads its host content from it
|
|
58
64
|
* @returns {Record<string,string>} a fresh object, never `env` itself
|
|
59
65
|
*/
|
|
60
|
-
function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
|
|
66
|
+
function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, envPrefix } = {}) {
|
|
61
67
|
if (!port) throw new TypeError('buildPreviewEnv: port is required');
|
|
62
68
|
if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
|
|
63
69
|
const out = {};
|
|
@@ -68,10 +74,15 @@ function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
|
|
|
68
74
|
out.PORT = String(port);
|
|
69
75
|
out.HOST = '127.0.0.1';
|
|
70
76
|
out.HOME = scratchHome;
|
|
77
|
+
// Named explicitly: without it the core takes the process's working directory as the instance
|
|
78
|
+
// root, and the install directory is not one (no config/, so no branding, no module switches).
|
|
79
|
+
if (instanceRoot) out.BONGOS_INSTANCE_ROOT = instanceRoot;
|
|
71
80
|
out.PGDATABASE = PREVIEW_DB;
|
|
72
81
|
out.CHAT_DRY_RUN = '1';
|
|
73
82
|
for (const name of POLLERS_OFF) out[name] = '1';
|
|
74
83
|
for (const name of MODULES_OFF) out[name] = '0';
|
|
84
|
+
// The copied instance's own prefix, when it is none of the above.
|
|
85
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(String(envPrefix || ''))) for (const k of MODULE_KEYS) out[`${envPrefix}_MODULE_${k}`] = '0';
|
|
75
86
|
return out;
|
|
76
87
|
}
|
|
77
88
|
|
|
@@ -22,6 +22,7 @@ const REAP_EVERY_MS = 60 * 1000;
|
|
|
22
22
|
const PROGRAMS = Object.freeze({
|
|
23
23
|
npm: (args, o) => spawn('npm', args, o),
|
|
24
24
|
dropdb: (args, o) => spawn('dropdb', args, o),
|
|
25
|
+
psql: (args, o) => spawn('psql', args, o),
|
|
25
26
|
createdb: (args, o) => spawn('createdb', args, o),
|
|
26
27
|
pg_dump: (args, o) => spawn('pg_dump', args, o),
|
|
27
28
|
pg_restore: (args, o) => spawn('pg_restore', args, o),
|
|
@@ -49,27 +50,60 @@ function runCommand({ cmd, args, env, cwd }) {
|
|
|
49
50
|
});
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
-
function
|
|
53
|
+
/** GET one loopback path: { status, body } (body capped), or { error } when nothing answered. */
|
|
54
|
+
function getLoopback(port, urlPath) {
|
|
54
55
|
return new Promise((resolve) => {
|
|
55
|
-
const req = http.get({ host: '127.0.0.1', port, path:
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
const req = http.get({ host: '127.0.0.1', port, path: urlPath, timeout: 3000, headers: { accept: 'application/json' } }, (res) => {
|
|
57
|
+
let body = '';
|
|
58
|
+
res.on('data', (d) => { if (body.length < 16384) body += d; });
|
|
59
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
60
|
+
res.on('error', (e) => resolve({ error: e.message }));
|
|
58
61
|
});
|
|
59
|
-
req.on('error', () => resolve(
|
|
60
|
-
req.on('timeout', () => { req.destroy(); resolve(
|
|
62
|
+
req.on('error', (e) => resolve({ error: e.message }));
|
|
63
|
+
req.on('timeout', () => { req.destroy(); resolve({ error: 'timed out' }); });
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Can the preview SERVE DATA? Not just "does something answer": /healthz touches no database,
|
|
69
|
+
* and on the first live run a preview that answered it 200 returned 500 on every page.
|
|
70
|
+
*
|
|
71
|
+
* 1. /healthz answers 200 (the process is up).
|
|
72
|
+
* 2. /version's schemaPending is not null. It is computed from a read of schema_migrations,
|
|
73
|
+
* and is null exactly when that read fails — so this proves the copy is reachable. A
|
|
74
|
+
* version too old to carry the field at all is passed on to step 3 instead.
|
|
75
|
+
* 3. a database-backed route is not a server error: /api/bongos/me answers 401 or 200 when
|
|
76
|
+
* the database and auth path work, and 500 when they do not.
|
|
77
|
+
*
|
|
78
|
+
* Returns { ok, reason }; the reason is shown to the owner, so it names the failing step.
|
|
79
|
+
*/
|
|
80
|
+
async function probeHealth(port, get = getLoopback) {
|
|
81
|
+
const h = await get(port, '/healthz');
|
|
82
|
+
if (h.error || h.status !== 200) return { ok: false, reason: h.error ? `/healthz: ${h.error}` : `/healthz answered ${h.status}` };
|
|
83
|
+
const v = await get(port, '/version');
|
|
84
|
+
if (v.error || v.status !== 200) return { ok: false, reason: v.error ? `/version: ${v.error}` : `/version answered ${v.status}` };
|
|
85
|
+
let info = null;
|
|
86
|
+
try { info = JSON.parse(v.body); } catch { /* not JSON: handled below */ }
|
|
87
|
+
if (!info || typeof info !== 'object') return { ok: false, reason: '/version did not answer with JSON' };
|
|
88
|
+
if ('schemaPending' in info && info.schemaPending === null) {
|
|
89
|
+
return { ok: false, reason: '/version reports schemaPending null: the database copy could not be read' };
|
|
90
|
+
}
|
|
91
|
+
const me = await get(port, '/api/bongos/me');
|
|
92
|
+
if (me.error || me.status >= 500) {
|
|
93
|
+
return { ok: false, reason: me.error ? `/api/bongos/me: ${me.error}` : `a database-backed read (/api/bongos/me) answered ${me.status}` };
|
|
94
|
+
}
|
|
95
|
+
return { ok: true };
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
/**
|
|
65
99
|
* Build the production supervisor: the port from NPM_RELEASE_PREVIEW_PORT (default 3190,
|
|
66
100
|
* loopback only), installs cached under ~/.cache/bongos-preview, and a timer that stops
|
|
67
101
|
* an idle preview and a hook that stops it when this process does.
|
|
68
102
|
*/
|
|
69
|
-
function createRuntime({ liveDb, log, env = process.env } = {}) {
|
|
103
|
+
function createRuntime({ liveDb, log, env = process.env, instanceRoot = null } = {}) {
|
|
70
104
|
const port = Number(env.NPM_RELEASE_PREVIEW_PORT) || DEFAULT_PORT;
|
|
71
105
|
const supervisor = createSupervisor({
|
|
72
|
-
run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env,
|
|
106
|
+
run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env, instanceRoot,
|
|
73
107
|
cacheRoot: path.join(os.homedir(), '.cache', 'bongos-preview'),
|
|
74
108
|
});
|
|
75
109
|
const timer = setInterval(() => {
|
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// modules/npm-release/preview/supervisor.js — starts, watches and stops the ONE version
|
|
4
|
-
// preview this hall may run (task 1004300, ADR 0349).
|
|
4
|
+
// preview this hall may run (task 1004300, ADR 0349; hardened by task 1004465).
|
|
5
5
|
//
|
|
6
6
|
// A preview is a second Bongos process, running an installed copy of an older core against
|
|
7
7
|
// a COPY of the live database, reached through the live hall's own address (divert.js). This
|
|
8
8
|
// file owns its lifecycle and nothing else: install → copy the database → migrate → launch →
|
|
9
|
-
// wait until it
|
|
10
|
-
// command, spawn for the long-lived child, probe for the health check, sleep, now), which is
|
|
9
|
+
// wait until it can serve data. Every outward step goes through an injected function (run for
|
|
10
|
+
// a command, spawn for the long-lived child, probe for the health check, sleep, now), which is
|
|
11
11
|
// what lets the whole lifecycle be tested without a process, a database or a network.
|
|
12
12
|
//
|
|
13
13
|
// ONE AT A TIME, STOPPED WHEN IDLE. A second preview is refused, not queued; a running one
|
|
14
14
|
// that nothing has touched for IDLE_MS is stopped and its copy of the database dropped. That
|
|
15
15
|
// bound is what keeps a preview from becoming a permanent second tenant of the droplet.
|
|
16
|
+
//
|
|
17
|
+
// THREE THINGS task 1004465 ADDED, each from the first live run:
|
|
18
|
+
// * the child's output is kept (redacted, capped) and shown when a start fails;
|
|
19
|
+
// * "running" means the child can serve DATA, not merely that it answers /healthz;
|
|
20
|
+
// * a stop waits for the child to exit and really drops the copy, or says it could not.
|
|
16
21
|
|
|
17
22
|
const os = require('node:os');
|
|
18
23
|
const fs = require('node:fs');
|
|
19
24
|
const path = require('node:path');
|
|
20
|
-
const { buildPreviewEnv } = require('./env');
|
|
25
|
+
const { buildPreviewEnv, PREVIEW_DB } = require('./env');
|
|
21
26
|
const cmds = require('./commands');
|
|
27
|
+
const { createChildLog, redact, secretValues } = require('./childlog');
|
|
22
28
|
|
|
23
29
|
const IDLE_MS = 20 * 60 * 1000;
|
|
24
30
|
const DEFAULT_PORT = 3190;
|
|
25
31
|
const HEALTH_TRIES = 40;
|
|
26
32
|
const HEALTH_INTERVAL_MS = 1500;
|
|
33
|
+
// How long a stopping child gets to exit on SIGTERM before SIGKILL, and how long after that.
|
|
34
|
+
const KILL_WAIT_MS = 5000;
|
|
35
|
+
const KILL_FINAL_WAIT_MS = 2000;
|
|
36
|
+
|
|
37
|
+
// The ONLY files of the live instance's config/ copied into the preview's scratch instance:
|
|
38
|
+
// the identity and switch packs a hall reads at boot. An allow-list, like the environment
|
|
39
|
+
// (env.js): a deny-list forgets the next credential file someone adds, an allow-list cannot.
|
|
40
|
+
// Anything else in config/ (go-live.json, key files, whatever comes next) stays behind.
|
|
41
|
+
const CONFIG_COPY = /^(branding|modules|government|ideas|design-tokens)(\.neutral)?\.json$/;
|
|
27
42
|
|
|
28
43
|
// How far behind the running version a preview may reach, counted in PUBLISHED versions.
|
|
29
44
|
const WINDOW = 30;
|
|
@@ -52,43 +67,123 @@ function previewable(version, { published, running, window = WINDOW } = {}) {
|
|
|
52
67
|
return { ok: true };
|
|
53
68
|
}
|
|
54
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Copy the live instance's host content the child needs to boot as an instance rather than
|
|
72
|
+
* as a bare install: config/ (branding, module switches, government) and migrations/instance/.
|
|
73
|
+
* A source that is absent is skipped; any other failure fails the start. Only the allow-listed
|
|
74
|
+
* pack files of config/ are copied (CONFIG_COPY).
|
|
75
|
+
*/
|
|
76
|
+
function copyInstance({ fsImpl, from, to }) {
|
|
77
|
+
const parts = [
|
|
78
|
+
// The directory itself always passes the filter; files are copied only when allow-listed.
|
|
79
|
+
['config', (src) => path.basename(src) === 'config' || CONFIG_COPY.test(path.basename(src))],
|
|
80
|
+
[path.join('migrations', 'instance'), () => true],
|
|
81
|
+
];
|
|
82
|
+
for (const [rel, filter] of parts) {
|
|
83
|
+
const src = path.join(from, rel);
|
|
84
|
+
try {
|
|
85
|
+
if (!fsImpl.existsSync(src)) continue;
|
|
86
|
+
fsImpl.mkdirSync(path.dirname(path.join(to, rel)), { recursive: true });
|
|
87
|
+
fsImpl.cpSync(src, path.join(to, rel), { recursive: true, filter });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new Error(`could not copy the instance ${rel} into the preview: ${e && e.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
55
94
|
/**
|
|
56
95
|
* @param {object} o
|
|
57
96
|
* @param {Function} o.run (command) => Promise of { code, stderr } — runs one command to the end
|
|
58
97
|
* @param {Function} o.spawn node:child_process spawn (the long-lived child)
|
|
59
|
-
* @param {Function} o.probe (port) => Promise of boolean —
|
|
98
|
+
* @param {Function} o.probe (port) => Promise of boolean | { ok, reason } — can the preview serve data
|
|
60
99
|
* @param {string} o.liveDb the live database name (the source of the copy)
|
|
61
100
|
* @param {string} o.cacheRoot where versions are installed
|
|
101
|
+
* @param {string} [o.instanceRoot] the live instance's root; its config/ and migrations/instance/
|
|
102
|
+
* are copied into the preview's scratch instance directory
|
|
62
103
|
* @param {object} [o.env] the live environment (read by buildPreviewEnv only)
|
|
63
104
|
*/
|
|
64
105
|
function createSupervisor({
|
|
65
|
-
run, spawn, probe, liveDb, cacheRoot,
|
|
66
|
-
env = process.env, port = DEFAULT_PORT,
|
|
106
|
+
run, spawn, probe, liveDb, cacheRoot, instanceRoot = null,
|
|
107
|
+
env = process.env, port = DEFAULT_PORT, killWaitMs = KILL_WAIT_MS,
|
|
67
108
|
now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
68
109
|
fsImpl = fs, tmpdir = os.tmpdir(), idleMs = IDLE_MS,
|
|
69
110
|
log = { info() {}, warn() {}, error() {} },
|
|
111
|
+
copy = copyInstance,
|
|
70
112
|
} = {}) {
|
|
113
|
+
// The one database a drop may ever name is the fixed copy. If the live database WERE that
|
|
114
|
+
// name, every stop would drop the live hall's data, so refuse to be built at all.
|
|
115
|
+
if (liveDb === PREVIEW_DB) throw new Error(`the live database cannot be named ${PREVIEW_DB}: that name is the preview's copy and is dropped on every stop`);
|
|
116
|
+
|
|
71
117
|
let state = { state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null };
|
|
72
118
|
let child = null;
|
|
73
119
|
let workDir = null;
|
|
120
|
+
let childLog = null; // the running or just-stopped child's output
|
|
121
|
+
let keptTail = []; // its tail, kept after the scratch directory is gone
|
|
122
|
+
let secrets = secretValues(env);
|
|
123
|
+
let exitInfo = null; // { code } once the child has exited
|
|
124
|
+
let exited = Promise.resolve();
|
|
74
125
|
|
|
75
126
|
const set = (patch) => { state = { ...state, ...patch }; };
|
|
76
|
-
const status = () => ({ ...state, port, idle_ms: idleMs });
|
|
127
|
+
const status = () => ({ ...state, port, idle_ms: idleMs, log_tail: childLog ? childLog.tail() : keptTail });
|
|
77
128
|
|
|
78
129
|
async function step(stage, command) {
|
|
79
130
|
set({ stage });
|
|
80
131
|
const r = await run(command);
|
|
81
132
|
if (!r || r.code !== 0) {
|
|
82
|
-
const tail = r && r.stderr ? `: ${String(r.stderr).trim().slice(-300)}` : '';
|
|
133
|
+
const tail = r && r.stderr ? `: ${redact(String(r.stderr).trim().slice(-300), secrets)}` : '';
|
|
83
134
|
throw new Error(`${stage} failed (exit ${r ? r.code : '?'})${tail}`);
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
|
|
138
|
+
// Run a command only if it can touch nothing but the copy. Never throws for a failed command.
|
|
139
|
+
async function runDrop(command) {
|
|
140
|
+
if (!cmds.targetsOnlyPreviewDb(command)) throw new Error(`refusing a database command that does not name only ${PREVIEW_DB}`);
|
|
141
|
+
try { return (await run(command)) || { code: -1, stderr: 'no result' }; } catch (e) { return { code: -1, stderr: e && e.message }; }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Drop the copy, really. dropdb --force first (it ends stray connections itself); if that
|
|
146
|
+
* is refused, end the copy's connections — the copy's alone — and drop plainly. Returns
|
|
147
|
+
* null when the copy is gone, else a sentence saying it is not.
|
|
148
|
+
*/
|
|
149
|
+
async function dropCopy() {
|
|
150
|
+
const first = await runDrop(cmds.dropDbCommand());
|
|
151
|
+
if (first.code === 0) return null;
|
|
152
|
+
await runDrop(cmds.terminateBackendsCommand());
|
|
153
|
+
const second = await runDrop(cmds.dropDbPlainCommand());
|
|
154
|
+
if (second.code === 0) return null;
|
|
155
|
+
const why = redact(String(second.stderr || first.stderr || '').trim().slice(-200), secrets);
|
|
156
|
+
return `the copy database ${PREVIEW_DB} could not be dropped (exit ${second.code})${why ? `: ${why}` : ''}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Ask the child to stop and WAIT for it, then insist. The drop that follows needs the
|
|
160
|
+
// child's database connections gone; killing without waiting is what left the copy behind.
|
|
161
|
+
async function stopChild(c) {
|
|
162
|
+
if (exitInfo) return;
|
|
163
|
+
try { c.kill('SIGTERM'); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); }
|
|
164
|
+
await Promise.race([exited, sleep(killWaitMs)]);
|
|
165
|
+
if (exitInfo) return;
|
|
166
|
+
try { c.kill('SIGKILL'); } catch { /* already gone */ }
|
|
167
|
+
await Promise.race([exited, sleep(KILL_FINAL_WAIT_MS)]);
|
|
168
|
+
}
|
|
169
|
+
|
|
87
170
|
// Everything a start or a stop leaves behind: the child, the copy, the scratch directory.
|
|
171
|
+
// Returns null when all of it is gone, else the sentence saying the copy is not.
|
|
88
172
|
async function cleanup() {
|
|
89
|
-
|
|
90
|
-
|
|
173
|
+
const c = child;
|
|
174
|
+
child = null;
|
|
175
|
+
if (c) await stopChild(c);
|
|
176
|
+
if (childLog) { keptTail = childLog.tail(); childLog.close(); childLog = null; }
|
|
177
|
+
const dropError = await dropCopy();
|
|
178
|
+
if (dropError) log.error(`preview: ${dropError}`);
|
|
91
179
|
if (workDir) { try { fsImpl.rmSync(workDir, { recursive: true, force: true }); } catch { /* best effort */ } workDir = null; }
|
|
180
|
+
return dropError;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A few of the child's last lines, to sit in the error message itself.
|
|
184
|
+
function withTail(message, lg) {
|
|
185
|
+
const lines = lg ? lg.tail(5) : [];
|
|
186
|
+
return lines.length ? `${message}. Its last output: ${lines.join(' | ')}`.slice(0, 900) : message;
|
|
92
187
|
}
|
|
93
188
|
|
|
94
189
|
async function launch(version) {
|
|
@@ -97,41 +192,78 @@ function createSupervisor({
|
|
|
97
192
|
const home = path.join(workDir, 'home');
|
|
98
193
|
fsImpl.mkdirSync(home, { recursive: true });
|
|
99
194
|
const dumpFile = path.join(workDir, 'live.dump');
|
|
100
|
-
|
|
195
|
+
|
|
196
|
+
// The child's working directory is a scratch INSTANCE: the live instance's config/ and
|
|
197
|
+
// migrations/instance/, copied. Run from the bare install directory it had no config/
|
|
198
|
+
// at all (no branding, no module switches) and was not shaped like a hall.
|
|
199
|
+
let instanceDir;
|
|
200
|
+
let prefix;
|
|
201
|
+
if (instanceRoot) {
|
|
202
|
+
instanceDir = path.join(workDir, 'instance');
|
|
203
|
+
fsImpl.mkdirSync(instanceDir, { recursive: true });
|
|
204
|
+
copy({ fsImpl, from: instanceRoot, to: instanceDir });
|
|
205
|
+
try { prefix = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'config', 'branding.json'), 'utf8')).envPrefix; } catch { /* no branding pack: the default prefix */ }
|
|
206
|
+
}
|
|
207
|
+
const childEnv = buildPreviewEnv({ env, port, scratchHome: home, instanceRoot: instanceDir, envPrefix: prefix });
|
|
208
|
+
secrets = secretValues(env, childEnv);
|
|
209
|
+
keptTail = [];
|
|
210
|
+
childLog = createChildLog({ secrets, fsImpl, file: path.join(workDir, 'preview.log') });
|
|
211
|
+
exitInfo = null;
|
|
101
212
|
|
|
102
213
|
await step('install', { ...cmds.installCommand({ cacheRoot, version }), env: childEnv });
|
|
103
|
-
|
|
214
|
+
set({ stage: 'drop-old-copy' });
|
|
215
|
+
const leftover = await dropCopy();
|
|
216
|
+
if (leftover) throw new Error(leftover);
|
|
104
217
|
await step('create-copy', cmds.createDbCommand());
|
|
105
218
|
await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
|
|
106
219
|
await step('restore', cmds.restoreCommand({ dumpFile }));
|
|
107
|
-
await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv }));
|
|
220
|
+
await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv, instanceDir }));
|
|
108
221
|
|
|
109
222
|
set({ stage: 'launch' });
|
|
110
|
-
child
|
|
111
|
-
|
|
223
|
+
// The child's output is kept (redacted, capped): its errors used to go nowhere.
|
|
224
|
+
const mine = childLog;
|
|
225
|
+
const spawned = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
|
|
226
|
+
cwd: instanceDir || cmds.versionDir(cacheRoot, version), env: childEnv, stdio: ['ignore', 'pipe', 'pipe'],
|
|
112
227
|
});
|
|
113
|
-
child
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
228
|
+
child = spawned;
|
|
229
|
+
exited = new Promise((resolve) => {
|
|
230
|
+
spawned.on('exit', (code) => {
|
|
231
|
+
exitInfo = { code };
|
|
232
|
+
resolve();
|
|
233
|
+
// A child that dies on its own must not leave a copy behind or a page waiting on it.
|
|
234
|
+
if (child === spawned && state.state === 'running') {
|
|
235
|
+
log.warn(`preview of ${version} exited (${code})`);
|
|
236
|
+
set({ state: 'failed', stage: 'exited', error: withTail(`the preview process exited (${code})`, mine) });
|
|
237
|
+
cleanup().then((err) => { if (err) set({ error: `${state.error}; ${err}` }); });
|
|
238
|
+
}
|
|
239
|
+
});
|
|
121
240
|
});
|
|
241
|
+
for (const stream of [spawned.stdout, spawned.stderr]) {
|
|
242
|
+
if (stream && typeof stream.on === 'function') stream.on('data', (d) => mine.append(String(d)));
|
|
243
|
+
}
|
|
122
244
|
|
|
123
245
|
set({ stage: 'health' });
|
|
246
|
+
// READY MEANS IT CAN SERVE DATA. The probe must see the database and the auth path work,
|
|
247
|
+
// not just the process answer (probeHealth in runtime.js); a preview that cannot is
|
|
248
|
+
// failed here, with why, rather than reported running.
|
|
124
249
|
let up = false;
|
|
250
|
+
let why = 'it did not answer';
|
|
125
251
|
for (let i = 0; i < HEALTH_TRIES && !up; i++) {
|
|
126
|
-
|
|
127
|
-
|
|
252
|
+
if (exitInfo) throw new Error(withTail(`the preview process exited (${exitInfo.code}) before it could serve data`, mine));
|
|
253
|
+
const r = await probe(port);
|
|
254
|
+
up = r === true || !!(r && r.ok);
|
|
255
|
+
if (!up) {
|
|
256
|
+
if (r && r.reason) why = r.reason;
|
|
257
|
+
await sleep(HEALTH_INTERVAL_MS);
|
|
258
|
+
}
|
|
128
259
|
}
|
|
129
|
-
if (!up) throw new Error(
|
|
260
|
+
if (!up) throw new Error(withTail(`the preview started but could not serve data (${why})`, mine));
|
|
130
261
|
set({ state: 'running', stage: null, startedAt: now(), lastTouch: now() });
|
|
131
262
|
} catch (e) {
|
|
132
263
|
log.error(`preview of ${version} failed: ${e && e.message}`);
|
|
133
|
-
set({ state: 'failed', error: e && e.message ? e.message : String(e) });
|
|
134
|
-
await cleanup();
|
|
264
|
+
set({ state: 'failed', error: redact(e && e.message ? e.message : String(e), secrets) });
|
|
265
|
+
const dropError = await cleanup();
|
|
266
|
+
if (dropError) set({ error: `${state.error}; ${dropError}` });
|
|
135
267
|
}
|
|
136
268
|
}
|
|
137
269
|
|
|
@@ -146,13 +278,20 @@ function createSupervisor({
|
|
|
146
278
|
if (state.version === version) return { ok: true, already: true, status: status(), done: Promise.resolve() };
|
|
147
279
|
return { ok: false, code: 'preview_busy', status: status() };
|
|
148
280
|
}
|
|
281
|
+
keptTail = [];
|
|
149
282
|
set({ state: 'starting', version, stage: 'queued', error: null, startedAt: null, lastTouch: now() });
|
|
150
283
|
return { ok: true, status: status(), done: launch(version) };
|
|
151
284
|
}
|
|
152
285
|
|
|
153
286
|
/** Stop the preview and drop its copy. Safe to call at any point. */
|
|
154
287
|
async function stop() {
|
|
155
|
-
await cleanup();
|
|
288
|
+
const dropError = await cleanup();
|
|
289
|
+
// A copy that is still there is NOT idle: say so, where the deploy page will show it.
|
|
290
|
+
if (dropError) {
|
|
291
|
+
set({ state: 'failed', stage: 'drop', error: dropError, startedAt: null, lastTouch: null });
|
|
292
|
+
return status();
|
|
293
|
+
}
|
|
294
|
+
keptTail = [];
|
|
156
295
|
set({ state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null });
|
|
157
296
|
return status();
|
|
158
297
|
}
|
|
@@ -172,4 +311,4 @@ function createSupervisor({
|
|
|
172
311
|
return { start, stop, status, touch, reapIdle };
|
|
173
312
|
}
|
|
174
313
|
|
|
175
|
-
module.exports = { createSupervisor, previewable, IDLE_MS, DEFAULT_PORT, WINDOW };
|
|
314
|
+
module.exports = { createSupervisor, previewable, copyInstance, IDLE_MS, DEFAULT_PORT, WINDOW };
|
|
@@ -6,3 +6,6 @@
|
|
|
6
6
|
/* A stage's sentence sits under its head (work.js says why) and needs air before the first
|
|
7
7
|
row, which drops its own top rule and padding in oversight.css. */
|
|
8
8
|
#nr-work .ov-group > .ov-row__sub { margin: 8px 0 6px; }
|
|
9
|
+
|
|
10
|
+
/* the preview process output shown under a failed preview (task 1004465) */
|
|
11
|
+
.ov-log { max-height: 16rem; overflow: auto; margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; background: rgba(127, 127, 127, 0.12); border-radius: 6px; }
|
|
@@ -192,7 +192,12 @@
|
|
|
192
192
|
return `<div class="ov-note"><p><strong>Preparing a preview of ${v}</strong> — ${escapeHtml(PREVIEW_STEP[preview.stage] || 'working')}. This can take a few minutes.</p></div>`;
|
|
193
193
|
}
|
|
194
194
|
if (preview.state === 'failed') {
|
|
195
|
-
|
|
195
|
+
// What the preview process itself printed (secrets already removed on the server), so a
|
|
196
|
+
// failed preview can be diagnosed here without a shell on the box (task 1004465).
|
|
197
|
+
const lines = Array.isArray(preview.log_tail) ? preview.log_tail : [];
|
|
198
|
+
const output = lines.length ? `<details open><summary>What the preview printed</summary><pre class="ov-log">${escapeHtml(lines.join('\n'))}</pre></details>` : '';
|
|
199
|
+
const headline = preview.stage === 'drop' ? `The copy of the data for ${v} could not be removed.` : `The preview of ${v} did not start.`;
|
|
200
|
+
return `<div class="ov-note ov-note--warn"><p><strong>${headline}</strong> ${escapeHtml(preview.error || '')}</p>${output}<button type="button" class="ov-toggle" data-preview-stop>${preview.stage === 'drop' ? 'Try again' : 'Clear'}</button></div>`;
|
|
196
201
|
}
|
|
197
202
|
return `<div class="ov-note"><p><strong>Preview of ${v} is running</strong> on a copy of this hall's data. Nothing done there reaches the live hall. It stops by itself after ${Math.round((preview.idle_ms || 0) / 60000)} minutes unused.</p>
|
|
198
203
|
<a class="ov-toggle" href="${API}/npm-release/preview/enter?v=${encodeURIComponent(preview.version)}">Open the preview</a>
|
|
@@ -55,6 +55,7 @@ module.exports = function buildNpmReleasePreviewRouter({
|
|
|
55
55
|
const router = express.Router();
|
|
56
56
|
const sup = supervisor || lazySupervisor(() => require('../preview/runtime').createRuntime({
|
|
57
57
|
liveDb: process.env.PGDATABASE || api.instanceDbName(),
|
|
58
|
+
instanceRoot: api.resolveInstanceRoot(),
|
|
58
59
|
log,
|
|
59
60
|
}));
|
|
60
61
|
const mayPreview = api.requirePermission('core.pin.move');
|