@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.
@@ -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
- /** Drop the copy if one is left over — used before a start and on every stop. */
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: dir,
74
- env: { ...env, INIT_CWD: dir, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
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
- const MODULES_OFF = Object.freeze([
45
- 'BONGOS_MODULE_DISCORD',
46
- 'BONGOS_MODULE_AGENTS',
47
- 'BONGOS_MODULE_PROVISIONING',
48
- 'BONGOS_MODULE_NPM_RELEASE',
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
- /** Does something answer /healthz on the loopback port? */
53
- function probeHealth(port) {
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: '/healthz', timeout: 2000 }, (res) => {
56
- res.resume();
57
- resolve(res.statusCode === 200);
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(false));
60
- req.on('timeout', () => { req.destroy(); resolve(false); });
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 answers. Every outward step goes through an injected function (run for a
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 — does the preview answer its health check
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
- if (child) { try { child.kill(); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); } child = null; }
90
- try { await run(cmds.dropDbCommand()); } catch (e) { log.warn(`preview copy could not be dropped: ${e && e.message}`); }
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
- const childEnv = buildPreviewEnv({ env, port, scratchHome: home });
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
- await step('drop-old-copy', cmds.dropDbCommand());
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 = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
111
- cwd: cmds.versionDir(cacheRoot, version), env: childEnv, stdio: 'ignore',
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.on('exit', (code) => {
114
- // A child that dies on its own must not leave a copy behind or a page waiting on it.
115
- if (child && state.state === 'running') {
116
- log.warn(`preview of ${version} exited (${code})`);
117
- child = null;
118
- set({ state: 'failed', stage: 'exited', error: `the preview process exited (${code})` });
119
- cleanup();
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
- up = await probe(port);
127
- if (!up) await sleep(HEALTH_INTERVAL_MS);
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('the preview did not answer in time');
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
- return `<div class="ov-note ov-note--warn"><p><strong>The preview of ${v} did not start.</strong> ${escapeHtml(preview.error || '')}</p><button type="button" class="ov-toggle" data-preview-stop>Clear</button></div>`;
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');