@bongos/core 1.20.38 → 1.20.40
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 +47 -37
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +1 -0
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +9 -9
- package/docs/module-api-changelog.md +4 -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/divert.js +16 -6
- package/modules/npm-release/preview/env.js +18 -7
- package/modules/npm-release/preview/proxy.js +4 -1
- 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 +7 -5
- 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 +16 -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_divert.mjs +10 -0
- package/tests/npm_release_preview_proxy.mjs +26 -0
- package/tests/npm_release_preview_routes.mjs +15 -2
- 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
|
@@ -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>
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
const express = require('express');
|
|
22
22
|
const api = require('../../../src/module-api');
|
|
23
|
-
const { createDivert, clearCookie } = require('../preview/divert');
|
|
24
|
-
const { createProxy
|
|
23
|
+
const { createDivert, clearCookie, setCookie } = require('../preview/divert');
|
|
24
|
+
const { createProxy } = require('../preview/proxy');
|
|
25
25
|
const { previewable } = require('../preview/supervisor');
|
|
26
26
|
const { VERSION_RE, PACKAGE } = require('../preview/commands');
|
|
27
27
|
|
|
@@ -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');
|
|
@@ -118,14 +119,15 @@ module.exports = function buildNpmReleasePreviewRouter({
|
|
|
118
119
|
if (!VERSION_RE.test(v) || st.state !== 'running' || st.version !== v) {
|
|
119
120
|
return failure(res, 409, 'preview_not_running', 'That preview is not running.');
|
|
120
121
|
}
|
|
121
|
-
|
|
122
|
-
res.
|
|
122
|
+
res.append('Set-Cookie', setCookie(v, req));
|
|
123
|
+
res.set('Cache-Control', 'no-store');
|
|
123
124
|
sup.touch();
|
|
124
125
|
return res.redirect(302, '/');
|
|
125
126
|
});
|
|
126
127
|
|
|
127
128
|
router.get('/npm-release/preview/exit', api.requireBuilder, mayPreview, (req, res) => {
|
|
128
|
-
res.append('Set-Cookie', clearCookie());
|
|
129
|
+
res.append('Set-Cookie', clearCookie(req));
|
|
130
|
+
res.set('Cache-Control', 'no-store');
|
|
129
131
|
res.redirect(302, '/deploy');
|
|
130
132
|
});
|
|
131
133
|
|
|
@@ -31,6 +31,18 @@ const MODES = ['adopt', 'greenfield'];
|
|
|
31
31
|
// How the owner's app is built (task 1004350): a native runtime with their commands, or
|
|
32
32
|
// `docker`, which builds the repository's own Dockerfile.
|
|
33
33
|
const RUNTIMES = ['node', 'python', 'ruby', 'go', 'rust', 'elixir', 'docker'];
|
|
34
|
+
// Render refuses a native web service with no build command (measured live 2026-09-30,
|
|
35
|
+
// task 1004464), so every native runtime has one: Render's own default for it. The runner
|
|
36
|
+
// sends it when the owner gave none, and the wizard prefills it (projects.html keeps a
|
|
37
|
+
// copy, pinned equal by tests/projects_hub_app_step.mjs).
|
|
38
|
+
const BUILD_DEFAULTS = Object.freeze({
|
|
39
|
+
node: 'npm install',
|
|
40
|
+
python: 'pip install -r requirements.txt',
|
|
41
|
+
ruby: 'bundle install',
|
|
42
|
+
go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
|
|
43
|
+
rust: 'cargo build --release',
|
|
44
|
+
elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile',
|
|
45
|
+
});
|
|
34
46
|
|
|
35
47
|
// Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
|
|
36
48
|
// one); services `srv-…`. Shape checks only — whether the key works, and whether it can
|
|
@@ -134,4 +146,4 @@ async function attachToProvision(db, instanceId, { keyEnc, appHost }) {
|
|
|
134
146
|
return rows[0] || null;
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, badRequest, appHostFrom, enqueueRenderStandup };
|
|
149
|
+
module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, BUILD_DEFAULTS, badRequest, appHostFrom, enqueueRenderStandup };
|
|
@@ -1433,7 +1433,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1433
1433
|
<option value="docker">Docker — use my repository’s Dockerfile</option>
|
|
1434
1434
|
</select>
|
|
1435
1435
|
</label>
|
|
1436
|
-
<label class="wizField" id="renderBuildWrap"><span>Build command <small>—
|
|
1436
|
+
<label class="wizField" id="renderBuildWrap"><span>Build command <small>— Render needs one; the usual one is filled in</small></span>
|
|
1437
1437
|
<input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
|
|
1438
1438
|
</label>
|
|
1439
1439
|
<label class="wizField" id="renderStartWrap"><span>Start command</span>
|
|
@@ -1808,6 +1808,18 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1808
1808
|
function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
|
|
1809
1809
|
var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
|
|
1810
1810
|
var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
|
|
1811
|
+
/* Render refuses a native app with no build command (task 1004464), so the box starts
|
|
1812
|
+
with Render's own default for the runtime — a real value the owner can change, and
|
|
1813
|
+
one that follows the runtime until they do. A copy of render-standup.js's
|
|
1814
|
+
BUILD_DEFAULTS (this page cannot require it), pinned equal by a test. */
|
|
1815
|
+
var RENDER_BUILD_DEFAULTS = {
|
|
1816
|
+
node: 'npm install',
|
|
1817
|
+
python: 'pip install -r requirements.txt',
|
|
1818
|
+
ruby: 'bundle install',
|
|
1819
|
+
go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
|
|
1820
|
+
rust: 'cargo build --release',
|
|
1821
|
+
elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile'
|
|
1822
|
+
};
|
|
1811
1823
|
var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
|
|
1812
1824
|
function planLabel(list, v) {
|
|
1813
1825
|
var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
|
|
@@ -1894,6 +1906,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1894
1906
|
q('f-renderRuntime').value = st.renderRuntime || 'node';
|
|
1895
1907
|
var docker = q('f-renderRuntime').value === 'docker';
|
|
1896
1908
|
q('renderBuildWrap').hidden = docker; q('renderStartWrap').hidden = docker;
|
|
1909
|
+
// the runtime's usual build command until the owner types their own (task 1004464)
|
|
1910
|
+
if (!st.renderBuildTouched) st.renderBuild = RENDER_BUILD_DEFAULTS[q('f-renderRuntime').value] || '';
|
|
1897
1911
|
q('f-renderBuild').value = st.renderBuild || '';
|
|
1898
1912
|
q('f-renderStart').value = st.renderStart || '';
|
|
1899
1913
|
fillPlans(q('f-renderPlan'), RENDER_WEB_PLANS, st.renderPlan || 'starter');
|
|
@@ -1975,13 +1989,12 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1975
1989
|
hooks.save(); syncNew();
|
|
1976
1990
|
});
|
|
1977
1991
|
q('renderNew').addEventListener('input', function (ev) {
|
|
1978
|
-
|
|
1979
|
-
if (ev.target.id === q('f-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
}
|
|
1992
|
+
var typed = null;
|
|
1993
|
+
if (ev.target.id === q('f-renderBuild').id) { st.renderBuild = ev.target.value; st.renderBuildTouched = true; typed = st.renderBuild; }
|
|
1994
|
+
if (ev.target.id === q('f-renderStart').id) { st.renderStart = ev.target.value; typed = st.renderStart; }
|
|
1995
|
+
// a "say how your app starts / is built" ask is answered the moment that box has a
|
|
1996
|
+
// command — it used to stay on screen until the next press of Next
|
|
1997
|
+
if (typed != null && String(typed).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
|
|
1985
1998
|
hooks.save();
|
|
1986
1999
|
});
|
|
1987
2000
|
function validate() {
|
|
@@ -1993,6 +2006,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1993
2006
|
if (st.renderMode === 'adopt') {
|
|
1994
2007
|
if (!services.some(function (s) { return s.id === st.renderServiceId && repoOf(s.repo) === repo().toLowerCase(); })) return bad('Choose the app to link, or create a new one.');
|
|
1995
2008
|
} else if (st.renderMode === 'greenfield') {
|
|
2009
|
+
if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderBuild || '').trim()) return bad('Say how your app is built — Render needs a build command, for example “npm install”.');
|
|
1996
2010
|
if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderStart || '').trim()) return bad('Say how your app starts — the command Render should run, for example “npm start”.');
|
|
1997
2011
|
} else return bad('Choose whether to create a new app or link one you already run.');
|
|
1998
2012
|
hint('h-render', '');
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.40",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.40",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.40",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -8333,5 +8333,21 @@
|
|
|
8333
8333
|
{
|
|
8334
8334
|
"text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
|
|
8335
8335
|
}
|
|
8336
|
+
],
|
|
8337
|
+
"1.20.39": [
|
|
8338
|
+
{
|
|
8339
|
+
"id": "1004465",
|
|
8340
|
+
"text": "A version preview that cannot read its data now fails with the child's own output shown, and stopping it really removes its copy."
|
|
8341
|
+
},
|
|
8342
|
+
{
|
|
8343
|
+
"id": "1004464",
|
|
8344
|
+
"text": "Setting up an app on Render from the project wizard no longer fails when the owner leaves the build command empty. The box now comes filled in with Render's usual build command for the chosen language, which the owner can chan"
|
|
8345
|
+
}
|
|
8346
|
+
],
|
|
8347
|
+
"1.20.40": [
|
|
8348
|
+
{
|
|
8349
|
+
"id": "1004466",
|
|
8350
|
+
"text": "Stop the version preview from overwriting the live hall's cookies, and make leaving a preview reliably clear it."
|
|
8351
|
+
}
|
|
8336
8352
|
]
|
|
8337
8353
|
}
|
|
@@ -117,6 +117,18 @@ async function greenfield(api, inst, host, deps) {
|
|
|
117
117
|
return { ok: true };
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
// The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
|
|
121
|
+
// from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
|
|
122
|
+
// so an answer from Render is re-said as a sentence with Render's own reason kept whole
|
|
123
|
+
// (task 1004464). Anything else is already the leg's own words.
|
|
124
|
+
function plainReason(e) {
|
|
125
|
+
const msg = e && e.message ? e.message : String(e);
|
|
126
|
+
const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
|
|
127
|
+
if (said) return `Render would not set up the app: ${said}`;
|
|
128
|
+
const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
|
|
129
|
+
return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
|
|
130
|
+
}
|
|
131
|
+
|
|
120
132
|
async function renderStandupInstance(inst, deps, intent) {
|
|
121
133
|
const { db, log } = deps;
|
|
122
134
|
const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
|
|
@@ -137,7 +149,7 @@ async function renderStandupInstance(inst, deps, intent) {
|
|
|
137
149
|
|
|
138
150
|
return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
|
|
139
151
|
} catch (e) {
|
|
140
|
-
return refuse(
|
|
152
|
+
return refuse(plainReason(e));
|
|
141
153
|
} finally {
|
|
142
154
|
if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
|
|
143
155
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
'use strict';
|
|
22
22
|
|
|
23
23
|
// The vocabulary lives with the web tier's validation, so the two can never disagree.
|
|
24
|
-
const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES } = require('../../modules/provisioning/render-standup.js');
|
|
24
|
+
const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES, BUILD_DEFAULTS } = require('../../modules/provisioning/render-standup.js');
|
|
25
25
|
const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
|
|
26
26
|
|
|
27
27
|
// A Render resource name from the project's slug. Render allows letters, digits and
|
|
@@ -47,14 +47,15 @@ function plansFor(appHost) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
// How the app is built and started, as the owner said. `docker` builds the repository's
|
|
50
|
-
// own Dockerfile and needs no commands; every other runtime needs a start command
|
|
51
|
-
// build command
|
|
50
|
+
// own Dockerfile and needs no commands; every other runtime needs a start command AND a
|
|
51
|
+
// build command — Render refuses a native service without one (task 1004464), so a row
|
|
52
|
+
// saved with none (including every row from before that fix) gets the runtime's default.
|
|
52
53
|
function buildFor(appHost) {
|
|
53
54
|
const h = appHost || {};
|
|
54
55
|
if (!RUNTIMES.includes(h.runtime)) throw new Error(`unknown Render runtime '${h.runtime || 'none'}'`);
|
|
55
56
|
if (h.runtime === 'docker') return { runtime: 'docker' };
|
|
56
57
|
if (!h.startCommand) throw new Error('the app needs a start command');
|
|
57
|
-
return { runtime: h.runtime, buildCommand: h.buildCommand ||
|
|
58
|
+
return { runtime: h.runtime, buildCommand: h.buildCommand || BUILD_DEFAULTS[h.runtime], startCommand: h.startCommand };
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
// POST /postgres. `plan` is never 'free' — see the header.
|
|
@@ -68,7 +69,7 @@ function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
|
|
|
68
69
|
function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, build, dbUrl }) {
|
|
69
70
|
const envSpecificDetails = build.runtime === 'docker'
|
|
70
71
|
? {}
|
|
71
|
-
: {
|
|
72
|
+
: { buildCommand: build.buildCommand, startCommand: build.startCommand };
|
|
72
73
|
return {
|
|
73
74
|
type: 'web_service',
|
|
74
75
|
name: resourceName(inst),
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -52,7 +52,7 @@ test('the copy is a custom-format dump of the live database, restored with no ow
|
|
|
52
52
|
|
|
53
53
|
test('the copy is created empty and dropped if it is already there', () => {
|
|
54
54
|
assert.deepEqual(c.createDbCommand(), { cmd: 'createdb', args: [PREVIEW_DB] });
|
|
55
|
-
assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] });
|
|
55
|
+
assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] });
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
test('the install is one version of the core into its own directory, production dependencies only', () => {
|