@bongos/core 1.19.101 → 1.19.103
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 +43 -23
- package/bin/bongos.js +3 -0
- package/docs/adr/0169-bongos-serve-server-bring-up.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/recipes/instance-deploy.md +1 -0
- package/docs/recipes/local-dev.md +3 -0
- package/docs/recipes/server-bring-up.md +112 -0
- package/modules/platform-identity/project-stats.js +207 -0
- package/modules/platform-identity/routes/projects.js +21 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +5 -0
- package/public-landing/projects.html +54 -1
- package/scripts/gds/dev-lib.js +209 -3
- package/scripts/gds/dev.js +292 -21
- package/src/module-api.js +5 -1
- package/tests/bongos_cli.mjs +3 -1
- package/tests/dev_lib.mjs +192 -2
- package/tests/project_stats.mjs +206 -0
- package/tests/projects_hub.mjs +53 -10
package/scripts/gds/dev.js
CHANGED
|
@@ -57,22 +57,36 @@ function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),
|
|
|
57
57
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
58
58
|
|
|
59
59
|
function usage() {
|
|
60
|
-
console.log(`bongos dev — run THIS instance
|
|
60
|
+
console.log(`bongos dev — run THIS instance, in one command.
|
|
61
61
|
|
|
62
62
|
Usage:
|
|
63
63
|
bongos dev [--foreground] [--no-login] [--skip-migrate] [--port <n>] [--db <name>]
|
|
64
64
|
bongos dev stop Stop the local server this launched
|
|
65
65
|
bongos dev status Show whether it's running, its URL, and sign-in state
|
|
66
66
|
|
|
67
|
+
bongos serve Same boot path, on a REAL HOST (= bongos dev --server)
|
|
68
|
+
bongos serve stop Stop + disable the service
|
|
69
|
+
bongos serve status Service state, health, and whether the public origin answers
|
|
70
|
+
|
|
67
71
|
What it does (idempotent — safe to re-run):
|
|
68
|
-
1.
|
|
69
|
-
2.
|
|
70
|
-
3.
|
|
71
|
-
4.
|
|
72
|
-
5.
|
|
72
|
+
1. Install dependencies if this checkout has none
|
|
73
|
+
2. Make sure Postgres is running (Homebrew on a Mac, systemd/service on a host)
|
|
74
|
+
3. Create this instance's database if it doesn't exist
|
|
75
|
+
4. Build/refresh its tables (migrations)
|
|
76
|
+
5. Start the server on the port in config/branding.json
|
|
77
|
+
6. Sign you in (local only — a host prints the command instead)
|
|
78
|
+
|
|
79
|
+
What --server changes:
|
|
80
|
+
• supervision — a systemd unit (enabled at boot, restarts on failure) instead of
|
|
81
|
+
a pid file, falling back to the background process where systemd is absent
|
|
82
|
+
• bind address — 127.0.0.1 behind a TLS proxy (https origin), else 0.0.0.0
|
|
83
|
+
• it bootstraps the Postgres login role on a fresh cluster (peer auth)
|
|
84
|
+
• it checks the PUBLIC origin, not just loopback, and never opens a browser
|
|
85
|
+
Extra flags: --bind <addr>, --user <unix-user> (who the service runs as).
|
|
73
86
|
|
|
74
87
|
Needs Postgres + Node >= 22 installed. macOS/Linux (uses bash for migrations;
|
|
75
|
-
on Windows use WSL or the Docker path in docs/recipes/self-host.md)
|
|
88
|
+
on Windows use WSL or the Docker path in docs/recipes/self-host.md).
|
|
89
|
+
Server walkthrough: docs/recipes/server-bring-up.md`);
|
|
76
90
|
}
|
|
77
91
|
|
|
78
92
|
// psql/createdb take -h/-U only when we have them; libpq fills the rest.
|
|
@@ -86,21 +100,31 @@ function pgReady(params) {
|
|
|
86
100
|
return run('pg_isready', [...pgArgs(params), '-q']).status === 0;
|
|
87
101
|
}
|
|
88
102
|
|
|
89
|
-
//
|
|
90
|
-
|
|
103
|
+
// Am I root? (Only meaningful on POSIX; Windows has no getuid.)
|
|
104
|
+
function isRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; }
|
|
105
|
+
|
|
106
|
+
// Best-effort: start whatever service manager owns Postgres on this platform
|
|
107
|
+
// (Homebrew on a Mac, systemd/service on a Linux host, pg_ctl with PGDATA
|
|
108
|
+
// anywhere), then poll until it accepts connections. The candidate list + sudo
|
|
109
|
+
// prefixing are decided by dev-lib.pgStartPlan; this just executes them in order.
|
|
91
110
|
function ensurePostgres(params) {
|
|
92
111
|
if (pgReady(params)) { ok('Postgres is running'); return true; }
|
|
93
112
|
info('Postgres is not running — trying to start it…');
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
113
|
+
for (const step of dev.pgStartPlan({ has, isRoot: isRoot() })) {
|
|
114
|
+
let args = step.args;
|
|
115
|
+
if (step.needsFormula) {
|
|
116
|
+
// Homebrew's formula name has to be discovered at runtime (postgresql@16,
|
|
117
|
+
// @17, …) — the only start candidate that isn't statically known.
|
|
118
|
+
const formulae = run('brew', ['list', '--formula']).stdout
|
|
119
|
+
.split(/\s+/).filter((x) => /^postgresql(@\d+)?$/.test(x))
|
|
120
|
+
.sort().reverse();
|
|
121
|
+
args = [...step.args, formulae[0] || 'postgresql@16'];
|
|
122
|
+
}
|
|
123
|
+
run(step.cmd, args, { stdio: 'ignore' });
|
|
124
|
+
if (pgReady(params)) { ok(`Postgres started (${step.label})`); return true; }
|
|
102
125
|
}
|
|
103
|
-
// Poll for readiness (~15s)
|
|
126
|
+
// Poll for readiness (~15s) — a service manager returns before the cluster is
|
|
127
|
+
// actually accepting connections. pg_isready is cheap; pause between probes.
|
|
104
128
|
for (let i = 0; i < 15; i++) {
|
|
105
129
|
if (pgReady(params)) { ok('Postgres started'); return true; }
|
|
106
130
|
sleepSync(1000);
|
|
@@ -114,12 +138,44 @@ function dbExists(params) {
|
|
|
114
138
|
return r.status === 0 && r.stdout.trim() === '1';
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
// A fresh `apt-get install postgresql` cluster has exactly one role — `postgres`,
|
|
142
|
+
// peer-auth only — so a deploy user's createdb fails with 'role "deploy" does not
|
|
143
|
+
// exist'. That reads like a broken install and is really an unbootstrapped
|
|
144
|
+
// cluster. Create the login role (and the DB it owns) as the postgres superuser.
|
|
145
|
+
function bootstrapRole(params) {
|
|
146
|
+
const plan = dev.pgBootstrapPlan({ pgUser: params.pgUser, dbName: params.dbName, isRoot: isRoot(), has });
|
|
147
|
+
if (!plan.ok) { warn(plan.reason); return false; }
|
|
148
|
+
info(`bootstrapping the Postgres role "${params.pgUser}" (first run on this host)…`);
|
|
149
|
+
const byId = Object.fromEntries(plan.steps.map((s) => [s.id, s]));
|
|
150
|
+
|
|
151
|
+
const check = byId['check-role'];
|
|
152
|
+
const already = run(check.cmd, check.args).stdout.trim() === '1';
|
|
153
|
+
if (!already) {
|
|
154
|
+
const mk = byId['create-role'];
|
|
155
|
+
const r = run(mk.cmd, mk.args);
|
|
156
|
+
if (r.status !== 0) { fail(`could not create the Postgres role "${params.pgUser}": ${tail(r.stderr, 2)}`); return false; }
|
|
157
|
+
ok(`created Postgres role "${params.pgUser}"`);
|
|
158
|
+
}
|
|
159
|
+
const mkdb = byId['create-db'];
|
|
160
|
+
const r = run(mkdb.cmd, mkdb.args);
|
|
161
|
+
if (r.status === 0 || /already exists/i.test(r.stderr)) { ok(`database "${params.dbName}" ready`); return true; }
|
|
162
|
+
fail(`could not create database "${params.dbName}" as the postgres superuser: ${tail(r.stderr, 2)}`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Does this createdb failure mean "the cluster was never bootstrapped for me"
|
|
167
|
+
// (fixable, see bootstrapRole) rather than a genuine error?
|
|
168
|
+
function needsRoleBootstrap(stderr) {
|
|
169
|
+
return /role .* does not exist|Peer authentication failed|password authentication failed|no password supplied/i.test(String(stderr || ''));
|
|
170
|
+
}
|
|
171
|
+
|
|
117
172
|
function createDb(params) {
|
|
118
173
|
if (dbExists(params)) { ok(`database "${params.dbName}" exists`); return true; }
|
|
119
174
|
const r = run('createdb', [...pgArgs(params), params.dbName]);
|
|
120
175
|
if (r.status === 0) { ok(`created database "${params.dbName}"`); return true; }
|
|
121
176
|
// A race or prior partial run may have created it; treat "already exists" as fine.
|
|
122
177
|
if (/already exists/i.test(r.stderr)) { ok(`database "${params.dbName}" exists`); return true; }
|
|
178
|
+
if (needsRoleBootstrap(r.stderr) && process.platform === 'linux') return bootstrapRole(params);
|
|
123
179
|
fail(`could not create database "${params.dbName}": ${tail(r.stderr, 2) || r.error}`);
|
|
124
180
|
return false;
|
|
125
181
|
}
|
|
@@ -152,6 +208,15 @@ function printOAuthGuidance(params) {
|
|
|
152
208
|
console.log(` Authorization callback URL: ${cb}`);
|
|
153
209
|
console.log(' Then tick "Enable Device Flow" and Save (a separate save).');
|
|
154
210
|
console.log(' 2. Copy the Client ID; generate a client secret.');
|
|
211
|
+
if (params.mode === 'server') {
|
|
212
|
+
// On a host the server runs as a service user with its own (often empty) home,
|
|
213
|
+
// so the env file the unit already reads is the reliable place for creds.
|
|
214
|
+
console.log(` 3. Put both in ${dev.envFilePath(params)} (0600, root-owned):`);
|
|
215
|
+
console.log(` ${params.envPrefix}_GITHUB_CLIENT_ID=…`);
|
|
216
|
+
console.log(` ${params.envPrefix}_GITHUB_CLIENT_SECRET=…`);
|
|
217
|
+
console.log(` 4. systemctl restart ${dev.serviceName(params)}`);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
155
220
|
console.log(` 3. Save both into ${credsPath} as:`);
|
|
156
221
|
console.log(' { "client_id": "…", "client_secret": "…" }');
|
|
157
222
|
console.log(' 4. Re-run `bongos dev`.');
|
|
@@ -182,9 +247,124 @@ function loginNow(params) {
|
|
|
182
247
|
return r.status === 0;
|
|
183
248
|
}
|
|
184
249
|
|
|
250
|
+
// ---- server mode: systemd supervision (task 1002398) ---------------------------
|
|
251
|
+
// On a laptop a detached pid-file process is the right amount of machinery. On a
|
|
252
|
+
// real host it is not: it dies on reboot, it has no restart policy, and its logs
|
|
253
|
+
// are a file in /tmp. So server mode installs a systemd unit and lets the host's
|
|
254
|
+
// own supervisor own the process — the pid-file path stays as the fallback for a
|
|
255
|
+
// host without systemd (a container, a BSD, a non-root operator).
|
|
256
|
+
|
|
257
|
+
function sudoWorks() { return run('sudo', ['-n', 'true']).status === 0; }
|
|
258
|
+
|
|
259
|
+
// Write a root-owned file, directly when we are root and via `sudo tee` when not.
|
|
260
|
+
function writeRootFile(target, contents, useSudo, mode = null) {
|
|
261
|
+
const dir = path.posix.dirname(target);
|
|
262
|
+
if (!useSudo) {
|
|
263
|
+
try {
|
|
264
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
265
|
+
fs.writeFileSync(target, contents, mode ? { mode } : undefined);
|
|
266
|
+
return { ok: true };
|
|
267
|
+
} catch (e) { return { ok: false, err: e.message }; }
|
|
268
|
+
}
|
|
269
|
+
const mk = run('sudo', ['-n', 'mkdir', '-p', dir]);
|
|
270
|
+
if (mk.status !== 0) return { ok: false, err: tail(mk.stderr, 2) };
|
|
271
|
+
const w = run('sudo', ['-n', 'tee', target], { input: contents, stdio: ['pipe', 'ignore', 'pipe'] });
|
|
272
|
+
if (w.status !== 0) return { ok: false, err: tail(w.stderr, 2) };
|
|
273
|
+
if (mode) run('sudo', ['-n', 'chmod', mode.toString(8).padStart(4, '0'), target]);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function rootFileExists(target, useSudo) {
|
|
278
|
+
if (!useSudo) return fs.existsSync(target);
|
|
279
|
+
return run('sudo', ['-n', 'test', '-f', target]).status === 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The secrets sidecar the unit reads. Created ONCE, 0600, never overwritten — a
|
|
283
|
+
// re-run must not clobber credentials an operator pasted in. Values stay out of
|
|
284
|
+
// Environment= lines on purpose: `systemctl show` leaks those to every user.
|
|
285
|
+
function ensureEnvFile(params, useSudo) {
|
|
286
|
+
const target = dev.envFilePath(params);
|
|
287
|
+
if (rootFileExists(target, useSudo)) return { path: target, created: false };
|
|
288
|
+
const body = [
|
|
289
|
+
`# ${dev.serviceName(params)} — secrets for this instance. Root-owned, 0600.`,
|
|
290
|
+
'# Referenced by the systemd unit as EnvironmentFile=- (optional: absent is fine).',
|
|
291
|
+
'# Fill in what applies, then: systemctl restart ' + dev.serviceName(params),
|
|
292
|
+
'',
|
|
293
|
+
'# Postgres password, if the role is not trusted/peer-authenticated:',
|
|
294
|
+
'#PGPASSWORD=',
|
|
295
|
+
'',
|
|
296
|
+
'# GitHub OAuth app for sign-in (see `bongos serve` output):',
|
|
297
|
+
`#${params.envPrefix}_GITHUB_CLIENT_ID=`,
|
|
298
|
+
`#${params.envPrefix}_GITHUB_CLIENT_SECRET=`,
|
|
299
|
+
'',
|
|
300
|
+
].join('\n');
|
|
301
|
+
const w = writeRootFile(target, body, useSudo, 0o600);
|
|
302
|
+
return { path: target, created: w.ok, err: w.err };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const sc = (useSudo, args) => (useSudo ? run('sudo', ['-n', 'systemctl', ...args]) : run('systemctl', args));
|
|
306
|
+
|
|
307
|
+
// Install (or refresh) the unit and bring the service up. Idempotent: rewriting
|
|
308
|
+
// an identical unit is a no-op, and `restart` covers both "not yet running" and
|
|
309
|
+
// "running the previous unit".
|
|
310
|
+
function installUnit(params, plan) {
|
|
311
|
+
const service = dev.serviceName(params);
|
|
312
|
+
const target = dev.unitPath(params);
|
|
313
|
+
const envFile = ensureEnvFile(params, plan.sudo);
|
|
314
|
+
if (envFile.created) ok(`wrote ${envFile.path} (0600 — put secrets here)`);
|
|
315
|
+
else if (envFile.err) warn(`could not create ${envFile.path}: ${envFile.err}`);
|
|
316
|
+
|
|
317
|
+
const unit = dev.renderSystemdUnit(params, {
|
|
318
|
+
nodePath: process.execPath,
|
|
319
|
+
user: arg('--user') || (isRoot() ? null : currentUser()),
|
|
320
|
+
envFile: envFile.path,
|
|
321
|
+
});
|
|
322
|
+
const w = writeRootFile(target, unit, plan.sudo);
|
|
323
|
+
if (!w.ok) { fail(`could not write ${target}: ${w.err}`); return { ok: false }; }
|
|
324
|
+
ok(`installed ${target}`);
|
|
325
|
+
|
|
326
|
+
if (sc(plan.sudo, ['daemon-reload']).status !== 0) { fail('systemctl daemon-reload failed'); return { ok: false }; }
|
|
327
|
+
// enable = survive reboot; restart = pick up this unit whether or not it ran.
|
|
328
|
+
sc(plan.sudo, ['enable', service]);
|
|
329
|
+
const r = sc(plan.sudo, ['restart', service]);
|
|
330
|
+
if (r.status !== 0) {
|
|
331
|
+
fail(`systemctl restart ${service} failed:`);
|
|
332
|
+
console.error(tail(r.stderr, 6));
|
|
333
|
+
console.error(` journalctl -u ${service} -n 50 --no-pager`);
|
|
334
|
+
return { ok: false };
|
|
335
|
+
}
|
|
336
|
+
return { ok: true, service, unitPath: target, envFile: envFile.path };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function currentUser() {
|
|
340
|
+
try { return require('node:os').userInfo().username; } catch { return null; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `systemctl is-active` exits non-zero when inactive, so read stdout, not status.
|
|
344
|
+
function unitActive(params, useSudo) {
|
|
345
|
+
return sc(useSudo, ['is-active', dev.serviceName(params)]).stdout.trim() === 'active';
|
|
346
|
+
}
|
|
347
|
+
|
|
185
348
|
// ---- subcommands ---------------------------------------------------------------
|
|
186
349
|
|
|
187
350
|
function cmdStop(params) {
|
|
351
|
+
// Server mode: if a unit owns this instance, stopping the pid-file process
|
|
352
|
+
// would be a no-op (or worse, systemd would restart it) — stop the unit, and
|
|
353
|
+
// disable it so "stopped" survives a reboot. `bongos serve` re-enables it.
|
|
354
|
+
if (params.mode === 'server') {
|
|
355
|
+
const plan = dev.systemdPlan({ has, isRoot: isRoot() });
|
|
356
|
+
if (plan.available) {
|
|
357
|
+
const useSudo = plan.sudo && sudoWorks();
|
|
358
|
+
const service = dev.serviceName(params);
|
|
359
|
+
if (rootFileExists(dev.unitPath(params), useSudo)) {
|
|
360
|
+
const r = sc(useSudo, ['stop', service]);
|
|
361
|
+
sc(useSudo, ['disable', service]);
|
|
362
|
+
if (r.status === 0) { ok(`stopped + disabled ${service}`); return 0; }
|
|
363
|
+
fail(`could not stop ${service}: ${tail(r.stderr, 2)}`);
|
|
364
|
+
return 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
188
368
|
const res = dev.stop(params);
|
|
189
369
|
if (res.wasRunning) ok(`stopped the local ${params.dbName} server (pid ${res.pid})`);
|
|
190
370
|
else console.log(` (no running ${params.dbName} server to stop)`);
|
|
@@ -192,10 +372,31 @@ function cmdStop(params) {
|
|
|
192
372
|
}
|
|
193
373
|
|
|
194
374
|
async function cmdStatus(params) {
|
|
195
|
-
const
|
|
375
|
+
const server = params.mode === 'server';
|
|
196
376
|
console.log(`instance: ${path.basename(params.instanceRoot)} (${params.publicOrigin})`);
|
|
197
377
|
console.log(`database: ${params.dbName} on ${params.pgHost}`);
|
|
198
|
-
|
|
378
|
+
console.log(`bind: ${params.host}:${params.port}`);
|
|
379
|
+
|
|
380
|
+
if (server) {
|
|
381
|
+
const plan = dev.systemdPlan({ has, isRoot: isRoot() });
|
|
382
|
+
const useSudo = plan.available && plan.sudo && sudoWorks();
|
|
383
|
+
if (plan.available && rootFileExists(dev.unitPath(params), useSudo)) {
|
|
384
|
+
const active = unitActive(params, useSudo);
|
|
385
|
+
console.log(`service: ${dev.serviceName(params)} — ${active ? 'active' : 'inactive'} (${dev.unitPath(params)})`);
|
|
386
|
+
const h = await dev.health(params);
|
|
387
|
+
console.log(`health: ${h.up ? 'OK' : 'unreachable on the bind address'}`);
|
|
388
|
+
console.log(`sign-in: ${h.authConfigured ? 'configured' : 'NOT configured (see `bongos serve`)'}`);
|
|
389
|
+
const pub = await dev.publicHealth(params);
|
|
390
|
+
console.log(`public: ${pub.reachable ? `reachable (${params.publicOrigin})` : `NOT reachable — ${pub.error || `HTTP ${pub.status}`}`}`);
|
|
391
|
+
console.log(`logs: journalctl -u ${dev.serviceName(params)} -f`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
console.log('service: no systemd unit installed — start it with `bongos serve`');
|
|
395
|
+
// fall through: a fallback pid-managed server may still be up.
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const running = dev.isRunning(params);
|
|
399
|
+
if (!running) { console.log(`server: not running — start it with \`bongos ${server ? 'serve' : 'dev'}\``); return 0; }
|
|
199
400
|
const h = await dev.health(params);
|
|
200
401
|
console.log(`server: running (pid ${dev.readPid(params)}), health ${h.up ? 'OK' : 'starting/unreachable'}`);
|
|
201
402
|
console.log(`sign-in: ${h.authConfigured ? 'configured' : 'NOT configured (see `bongos dev`)'}`);
|
|
@@ -203,6 +404,71 @@ async function cmdStatus(params) {
|
|
|
203
404
|
return 0;
|
|
204
405
|
}
|
|
205
406
|
|
|
407
|
+
// The tail of a server-mode `up`: supervise, wait for health, report the outward
|
|
408
|
+
// view. Never auto-signs-in (a host has no browser) — it prints the command.
|
|
409
|
+
async function cmdServeStart(params) {
|
|
410
|
+
const plan = dev.systemdPlan({ has, isRoot: isRoot() });
|
|
411
|
+
let managed = null;
|
|
412
|
+
|
|
413
|
+
if (plan.available) {
|
|
414
|
+
const useSudo = plan.sudo && sudoWorks();
|
|
415
|
+
if (plan.sudo && !useSudo) {
|
|
416
|
+
warn('sudo needs a password here, so I cannot install a systemd unit non-interactively.');
|
|
417
|
+
console.log(' Either run `sudo bongos serve`, or grant passwordless sudo, and I will supervise with systemd.');
|
|
418
|
+
} else {
|
|
419
|
+
managed = installUnit(params, { ...plan, sudo: useSudo });
|
|
420
|
+
if (!managed.ok) return 1;
|
|
421
|
+
ok(`${managed.service} started (enabled at boot, restarts on failure)`);
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
warn(`no systemd here (${plan.reason}) — falling back to a managed background process.`);
|
|
425
|
+
console.log(' It will NOT survive a reboot. Add it to this host’s own supervisor for production.');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!managed) {
|
|
429
|
+
const started = dev.startDetached(params);
|
|
430
|
+
if (started.already) ok(`server already running (pid ${started.pid})`);
|
|
431
|
+
else ok(`server running (pid ${started.pid}), logs: ${started.log}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const h = await waitHealthy(params);
|
|
435
|
+
if (!h.up) {
|
|
436
|
+
fail(`the server did not become healthy on ${params.host}:${params.port}. Recent log:`);
|
|
437
|
+
if (managed) console.error(run('journalctl', ['-u', managed.service, '-n', '20', '--no-pager']).stdout || ` journalctl -u ${managed.service} -n 50 --no-pager`);
|
|
438
|
+
else { try { console.error(tail(fs.readFileSync(dev.logPath(params), 'utf8'), 12)); } catch { /* no log */ } }
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
ok(`healthy on ${params.host}:${params.port}`);
|
|
442
|
+
|
|
443
|
+
// The loopback probe proves the app. This proves the PATH TO IT — DNS, the
|
|
444
|
+
// proxy, TLS — which is the part that actually differs on a real host.
|
|
445
|
+
const pub = await dev.publicHealth(params);
|
|
446
|
+
blank();
|
|
447
|
+
if (pub.reachable) {
|
|
448
|
+
ok(`${params.publicOrigin} is answering`);
|
|
449
|
+
} else {
|
|
450
|
+
warn(`${params.publicOrigin} is not answering yet (${pub.error || `HTTP ${pub.status}`}).`);
|
|
451
|
+
console.log(' The app is up on this host — what is missing is in front of it:');
|
|
452
|
+
if (params.host === '127.0.0.1') console.log(` • a reverse proxy forwarding ${params.publicOrigin} → 127.0.0.1:${params.port} (TLS terminates there)`);
|
|
453
|
+
else console.log(` • DNS for ${new URL(params.publicOrigin).hostname} pointing at this host, and port ${params.port} open in the firewall`);
|
|
454
|
+
console.log(' • see docs/recipes/server-bring-up.md');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
blank();
|
|
458
|
+
console.log(` → ${params.publicOrigin}`);
|
|
459
|
+
if (managed) {
|
|
460
|
+
console.log(` status: bongos serve status logs: journalctl -u ${managed.service} -f`);
|
|
461
|
+
console.log(` stop: bongos serve stop secrets: ${managed.envFile}`);
|
|
462
|
+
} else {
|
|
463
|
+
console.log(` status: bongos serve status stop: bongos serve stop`);
|
|
464
|
+
}
|
|
465
|
+
blank();
|
|
466
|
+
|
|
467
|
+
if (!dev.authWillWork()) { printOAuthGuidance(params); return 0; }
|
|
468
|
+
console.log(` Sign in from your own machine: bongos login ${params.publicOrigin}`);
|
|
469
|
+
return 0;
|
|
470
|
+
}
|
|
471
|
+
|
|
206
472
|
async function cmdUp(params, { foreground, noLogin, skipMigrate }) {
|
|
207
473
|
info(`Standing up ${path.basename(params.instanceRoot)} on ${params.publicOrigin}`);
|
|
208
474
|
|
|
@@ -270,6 +536,9 @@ async function cmdUp(params, { foreground, noLogin, skipMigrate }) {
|
|
|
270
536
|
return r.status == null ? 1 : r.status;
|
|
271
537
|
}
|
|
272
538
|
|
|
539
|
+
// Server mode: hand the process to the host's own supervisor.
|
|
540
|
+
if (params.mode === 'server') return cmdServeStart(params);
|
|
541
|
+
|
|
273
542
|
// Default: managed background server.
|
|
274
543
|
const started = dev.startDetached(params);
|
|
275
544
|
if (started.already) ok(`server already running (pid ${started.pid})`);
|
|
@@ -302,7 +571,9 @@ async function main() {
|
|
|
302
571
|
if (hasFlag('--help') || hasFlag('-h') || argv[0] === 'help') { usage(); return 0; }
|
|
303
572
|
|
|
304
573
|
const params = dev.resolveRunParams({ instanceRoot: process.cwd(), argv });
|
|
305
|
-
|
|
574
|
+
// Flag-order-independent: `bongos serve stop` reaches us as ['--server','stop'],
|
|
575
|
+
// and `--port 3006 status` puts a flag VALUE where a naive argv[0] scan looked.
|
|
576
|
+
const sub = dev.parseSub(argv);
|
|
306
577
|
|
|
307
578
|
if (sub === 'stop') return cmdStop(params);
|
|
308
579
|
if (sub === 'status') return cmdStatus(params);
|
package/src/module-api.js
CHANGED
|
@@ -629,8 +629,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
629
629
|
// landed since 1.19.99 with no explicit bump. run 31751275973. (task 1002620)
|
|
630
630
|
// 1.19.101 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
631
631
|
// landed since 1.19.100 with no explicit bump. run 31752022322. (task 1002620)
|
|
632
|
+
// 1.19.102 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
633
|
+
// landed since 1.19.101 with no explicit bump. run 31753496152. (task 1002620)
|
|
634
|
+
// 1.19.103 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
635
|
+
// landed since 1.19.102 with no explicit bump. run 31754659965. (task 1002620)
|
|
632
636
|
// ---------------------------------------------------------------------------
|
|
633
|
-
const CORE_VERSION = '1.19.
|
|
637
|
+
const CORE_VERSION = '1.19.103'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
634
638
|
|
|
635
639
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
636
640
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -47,8 +47,10 @@ test('VERBS: selfHelp is only claimed by scripts that really print their own usa
|
|
|
47
47
|
// Verified by reading each script (they all branch on --help/-h to a usage()
|
|
48
48
|
// printer). If a verb is added here, verify its script first — a wrong
|
|
49
49
|
// selfHelp:true recreates the blocking-interview bug for that verb.
|
|
50
|
+
// `serve` is dev.js with --server pinned on, so it inherits dev's own usage()
|
|
51
|
+
// printer — verified: `bongos serve --help` prints the card and exits 0.
|
|
50
52
|
const selfHelp = Object.entries(cli.VERBS).filter(([, d]) => d.selfHelp).map(([v]) => v).sort();
|
|
51
|
-
assert.deepEqual(selfHelp, ['dev', 'module', 'status', 'task', 'upgrade']);
|
|
53
|
+
assert.deepEqual(selfHelp, ['dev', 'module', 'serve', 'status', 'task', 'upgrade']);
|
|
52
54
|
});
|
|
53
55
|
|
|
54
56
|
// ---- spawned: the dispatcher answers --help without touching the script ----
|
package/tests/dev_lib.mjs
CHANGED
|
@@ -20,12 +20,18 @@ async function ta(name, fn) {
|
|
|
20
20
|
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// The lib builds paths with path.join, so on Windows they come back with `\`
|
|
24
|
+
// while these fixtures are written POSIX-style. Compare on a normalized form —
|
|
25
|
+
// otherwise every fixture silently "misses" and the lib reads its neutral
|
|
26
|
+
// fallback, which looks like a logic failure and is really a separator mismatch.
|
|
27
|
+
const norm = (p) => String(p).replace(/\\/g, '/');
|
|
28
|
+
|
|
23
29
|
// A fake fs.readFileSync that maps by path suffix; unknown paths throw ENOENT.
|
|
24
30
|
function fakeFs(map) {
|
|
25
31
|
return {
|
|
26
32
|
readFileSync(p) {
|
|
27
33
|
for (const [suffix, body] of Object.entries(map)) {
|
|
28
|
-
if (
|
|
34
|
+
if (norm(p).endsWith(suffix)) return body;
|
|
29
35
|
}
|
|
30
36
|
const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e;
|
|
31
37
|
},
|
|
@@ -184,7 +190,7 @@ t('startDetached: spawns the server entry with the server env, detached', () =>
|
|
|
184
190
|
const r = lib.startDetached(params, deps);
|
|
185
191
|
assert.equal(r.already, false);
|
|
186
192
|
assert.equal(r.pid, 555);
|
|
187
|
-
assert.equal(spawnCall.args[0], '/core/src/platform-server.js');
|
|
193
|
+
assert.equal(norm(spawnCall.args[0]), '/core/src/platform-server.js');
|
|
188
194
|
assert.equal(spawnCall.opts.cwd, '/x/test4');
|
|
189
195
|
assert.equal(spawnCall.opts.detached, true);
|
|
190
196
|
assert.equal(spawnCall.opts.env.PORT, '3006');
|
|
@@ -249,5 +255,189 @@ t('depsInstallPlan: no node_modules + no lockfile → npm install', () => {
|
|
|
249
255
|
assert.deepEqual(plan.args, ['install']);
|
|
250
256
|
});
|
|
251
257
|
|
|
258
|
+
// ---------- server mode (task 1002398) ----------
|
|
259
|
+
// A fake `has(bin)`: the named binaries are on PATH, nothing else is.
|
|
260
|
+
const hasOnly = (...bins) => (b) => bins.includes(b);
|
|
261
|
+
|
|
262
|
+
t('parseSub: flag order never changes the verb', () => {
|
|
263
|
+
assert.equal(lib.parseSub([]), 'up');
|
|
264
|
+
assert.equal(lib.parseSub(['--server']), 'up');
|
|
265
|
+
assert.equal(lib.parseSub(['--server', 'stop']), 'stop'); // what `bongos serve stop` sends
|
|
266
|
+
assert.equal(lib.parseSub(['status', '--server']), 'status');
|
|
267
|
+
assert.equal(lib.parseSub(['--port', '3006', 'status']), 'status'); // 3006 is a VALUE, not the verb
|
|
268
|
+
assert.equal(lib.parseSub(['--db', 'stop', 'status']), 'status'); // a db literally named "stop"
|
|
269
|
+
assert.equal(lib.parseSub(['--foreground']), 'up'); // boolean flag consumes nothing
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
t('deriveBindHost: local is always loopback; server derives from the origin', () => {
|
|
273
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'https://example.com', mode: 'local' }), '127.0.0.1');
|
|
274
|
+
// https => something terminates TLS in front of us => stay off the public NIC.
|
|
275
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'https://example.com', mode: 'server' }), '127.0.0.1');
|
|
276
|
+
// plain http on a public name => nothing is in front yet => serve it ourselves.
|
|
277
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://example.com:3006', mode: 'server' }), '0.0.0.0');
|
|
278
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://localhost:3006', mode: 'server' }), '127.0.0.1');
|
|
279
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'garbage', mode: 'server' }), '127.0.0.1');
|
|
280
|
+
assert.equal(lib.deriveBindHost({ publicOrigin: 'http://example.com', mode: 'server', override: '10.0.0.4' }), '10.0.0.4');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
t('probeHost: probe what we bound (0.0.0.0 still answers on loopback)', () => {
|
|
284
|
+
assert.equal(lib.probeHost({ host: '0.0.0.0' }), '127.0.0.1');
|
|
285
|
+
assert.equal(lib.probeHost({ host: '10.0.0.4' }), '10.0.0.4');
|
|
286
|
+
assert.equal(lib.probeHost({}), '127.0.0.1');
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
t('resolveRunParams: --server flips mode and the bind address', () => {
|
|
290
|
+
const f = fakeFs({
|
|
291
|
+
'config/branding.json': JSON.stringify({ domains: { publicOrigin: 'http://box.example.com:3006' } }),
|
|
292
|
+
'config/branding.neutral.json': NEUTRAL,
|
|
293
|
+
'config/modules.json': JSON.stringify({ modules: {} }),
|
|
294
|
+
});
|
|
295
|
+
const base = { instanceRoot: '/srv/app', coreRoot: '/core', f, env: {}, userInfo: () => ({ username: 'deploy' }) };
|
|
296
|
+
const local = lib.resolveRunParams({ ...base, argv: [] });
|
|
297
|
+
assert.equal(local.mode, 'local');
|
|
298
|
+
assert.equal(local.host, '127.0.0.1');
|
|
299
|
+
const server = lib.resolveRunParams({ ...base, argv: ['--server'] });
|
|
300
|
+
assert.equal(server.mode, 'server');
|
|
301
|
+
assert.equal(server.host, '0.0.0.0');
|
|
302
|
+
assert.equal(server.port, 3006);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
t('resolveRunParams: an https origin keeps the server on loopback (proxy in front)', () => {
|
|
306
|
+
const f = fakeFs({
|
|
307
|
+
'config/branding.json': JSON.stringify({ domains: { publicOrigin: 'https://app.example.com' } }),
|
|
308
|
+
'config/branding.neutral.json': NEUTRAL,
|
|
309
|
+
'config/modules.json': JSON.stringify({ modules: {} }),
|
|
310
|
+
});
|
|
311
|
+
const p = lib.resolveRunParams({ instanceRoot: '/srv/app', coreRoot: '/core', f, env: {}, userInfo: () => ({ username: 'deploy' }), argv: ['--server'] });
|
|
312
|
+
assert.equal(p.host, '127.0.0.1');
|
|
313
|
+
assert.equal(p.port, 3000); // no explicit port => the proxy's upstream
|
|
314
|
+
assert.equal(p.publicOrigin, 'https://app.example.com');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ---------- systemd unit ----------
|
|
318
|
+
const UNIT_PARAMS = {
|
|
319
|
+
instanceRoot: '/srv/app', coreRoot: '/core', dbName: 'app', port: 3006, host: '127.0.0.1',
|
|
320
|
+
pgHost: 'localhost', pgUser: 'deploy', gdsOnly: true, envPrefix: 'CLOUDBONGOS',
|
|
321
|
+
publicOrigin: 'https://app.example.com', mode: 'server',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
t('serviceName / unitPath / envFilePath are keyed per instance', () => {
|
|
325
|
+
assert.equal(lib.serviceName(UNIT_PARAMS), 'bongos-app');
|
|
326
|
+
assert.equal(lib.unitPath(UNIT_PARAMS), '/etc/systemd/system/bongos-app.service');
|
|
327
|
+
assert.equal(lib.envFilePath(UNIT_PARAMS), '/etc/bongos/bongos-app.env');
|
|
328
|
+
assert.notEqual(lib.serviceName({ dbName: 'other' }), lib.serviceName(UNIT_PARAMS));
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
t('unitEnvironment: only resolved keys — never the caller’s environment', () => {
|
|
332
|
+
const keys = lib.unitEnvironment(UNIT_PARAMS).map(([k]) => k);
|
|
333
|
+
assert.deepEqual(keys.sort(), ['CLOUDBONGOS_INSTANCE_ROOT', 'HOST', 'MEDUSA_GDS_ONLY', 'NODE_ENV', 'PGDATABASE', 'PGHOST', 'PGUSER', 'PORT']);
|
|
334
|
+
const env = Object.fromEntries(lib.unitEnvironment(UNIT_PARAMS));
|
|
335
|
+
assert.equal(env.PORT, '3006');
|
|
336
|
+
assert.equal(env.NODE_ENV, 'production');
|
|
337
|
+
assert.equal(env.CLOUDBONGOS_INSTANCE_ROOT, '/srv/app');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
t('unitEnvironment: no secret ever lands in an Environment= line', () => {
|
|
341
|
+
const withSecret = { ...UNIT_PARAMS, pgPassword: 'hunter2' };
|
|
342
|
+
const rendered = lib.renderSystemdUnit(withSecret, { nodePath: '/usr/bin/node', user: 'deploy' });
|
|
343
|
+
assert.ok(!rendered.includes('hunter2'), 'PGPASSWORD must go to the 0600 EnvironmentFile, not the unit');
|
|
344
|
+
assert.match(rendered, /EnvironmentFile=-\/etc\/bongos\/bongos-app\.env/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
t('renderSystemdUnit: boot-survival + restart policy + the right ExecStart', () => {
|
|
348
|
+
// The unit is only ever GENERATED on the host that runs it (linux), so
|
|
349
|
+
// serverEntry's separators are native there; normalize for a Windows test box.
|
|
350
|
+
const u = norm(lib.renderSystemdUnit(UNIT_PARAMS, { nodePath: '/usr/bin/node', user: 'deploy' }));
|
|
351
|
+
assert.match(u, /ExecStart=\/usr\/bin\/node \/core\/src\/platform-server\.js/);
|
|
352
|
+
assert.match(u, /WorkingDirectory=\/srv\/app/);
|
|
353
|
+
assert.match(u, /^Restart=always$/m);
|
|
354
|
+
assert.match(u, /^WantedBy=multi-user\.target$/m); // = survives reboot
|
|
355
|
+
assert.match(u, /^User=deploy$/m);
|
|
356
|
+
assert.match(u, /Environment="PORT=3006"/);
|
|
357
|
+
assert.match(u, /After=network-online\.target postgresql\.service/);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
t('renderSystemdUnit: values are quoted, so a path with a space cannot split the line', () => {
|
|
361
|
+
const u = lib.renderSystemdUnit({ ...UNIT_PARAMS, instanceRoot: '/srv/my app' }, { nodePath: '/usr/bin/node' });
|
|
362
|
+
assert.match(u, /Environment="CLOUDBONGOS_INSTANCE_ROOT=\/srv\/my app"/);
|
|
363
|
+
assert.ok(!/^User=/m.test(u), 'no User= line when no user was given (runs as root)');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
t('systemdPlan: available only on linux with systemctl; sudo unless root', () => {
|
|
367
|
+
assert.equal(lib.systemdPlan({ platform: 'darwin', has: hasOnly('systemctl') }).available, false);
|
|
368
|
+
assert.equal(lib.systemdPlan({ platform: 'linux', has: hasOnly() }).available, false);
|
|
369
|
+
assert.deepEqual(lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl'), isRoot: true }), { available: true, sudo: false });
|
|
370
|
+
assert.deepEqual(lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl', 'sudo') }), { available: true, sudo: true });
|
|
371
|
+
// systemctl present but no way to become root -> unavailable, with a reason.
|
|
372
|
+
const p = lib.systemdPlan({ platform: 'linux', has: hasOnly('systemctl') });
|
|
373
|
+
assert.equal(p.available, false);
|
|
374
|
+
assert.match(p.reason, /sudo/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---------- Postgres bring-up ----------
|
|
378
|
+
t('pgStartPlan: platform-correct candidates, sudo-prefixed when not root', () => {
|
|
379
|
+
const mac = lib.pgStartPlan({ platform: 'darwin', has: hasOnly('brew'), env: {} });
|
|
380
|
+
assert.equal(mac[0].cmd, 'brew');
|
|
381
|
+
assert.equal(mac[0].needsFormula, true);
|
|
382
|
+
|
|
383
|
+
const linux = lib.pgStartPlan({ platform: 'linux', has: hasOnly('systemctl', 'sudo'), env: {} });
|
|
384
|
+
assert.deepEqual(linux[0].args, ['-n', 'systemctl', 'start', 'postgresql']);
|
|
385
|
+
assert.equal(linux[0].cmd, 'sudo');
|
|
386
|
+
|
|
387
|
+
const asRoot = lib.pgStartPlan({ platform: 'linux', has: hasOnly('systemctl'), env: {}, isRoot: true });
|
|
388
|
+
assert.equal(asRoot[0].cmd, 'systemctl');
|
|
389
|
+
|
|
390
|
+
// No systemd: fall back to SysV, then to pg_ctl when PGDATA names a cluster.
|
|
391
|
+
const sysv = lib.pgStartPlan({ platform: 'linux', has: hasOnly('service'), env: {}, isRoot: true });
|
|
392
|
+
assert.equal(sysv[0].cmd, 'service');
|
|
393
|
+
const ctl = lib.pgStartPlan({ platform: 'linux', has: hasOnly('pg_ctl'), env: { PGDATA: '/var/lib/pg' }, isRoot: true });
|
|
394
|
+
assert.deepEqual(ctl[0].args, ['start', '-w', '-D', '/var/lib/pg']);
|
|
395
|
+
assert.deepEqual(lib.pgStartPlan({ platform: 'linux', has: hasOnly('pg_ctl'), env: {} }), []); // pg_ctl needs PGDATA
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
t('pgBootstrapPlan: creates the login role + its db as the postgres superuser', () => {
|
|
399
|
+
const plan = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', has: hasOnly('sudo') });
|
|
400
|
+
assert.equal(plan.ok, true);
|
|
401
|
+
const byId = Object.fromEntries(plan.steps.map((s) => [s.id, s]));
|
|
402
|
+
assert.deepEqual(byId['check-role'].args.slice(0, 4), ['-n', '-u', 'postgres', 'psql']);
|
|
403
|
+
assert.ok(byId['create-role'].args.join(' ').includes('CREATE ROLE "deploy" LOGIN CREATEDB'));
|
|
404
|
+
assert.deepEqual(byId['create-db'].args, ['-n', '-u', 'postgres', 'createdb', '-O', 'deploy', 'app']);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
t('pgBootstrapPlan: root on a sudo-less image uses `su postgres -c`', () => {
|
|
408
|
+
const plan = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', isRoot: true, has: hasOnly() });
|
|
409
|
+
assert.equal(plan.ok, true);
|
|
410
|
+
assert.equal(plan.steps[0].cmd, 'su');
|
|
411
|
+
assert.equal(plan.steps[0].args[0], 'postgres');
|
|
412
|
+
assert.equal(plan.steps[0].args[1], '-c');
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
t('pgBootstrapPlan: refuses injection-shaped identifiers and a rootless host', () => {
|
|
416
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: 'a"; DROP DATABASE x; --', dbName: 'app', has: hasOnly('sudo') }).ok, false);
|
|
417
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'bad-name', has: hasOnly('sudo') }).ok, false);
|
|
418
|
+
assert.equal(lib.pgBootstrapPlan({ pgUser: '', dbName: 'app', has: hasOnly('sudo') }).ok, false);
|
|
419
|
+
const noRoot = lib.pgBootstrapPlan({ pgUser: 'deploy', dbName: 'app', isRoot: false, has: hasOnly() });
|
|
420
|
+
assert.equal(noRoot.ok, false);
|
|
421
|
+
assert.match(noRoot.reason, /root or sudo/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------- outward health ----------
|
|
425
|
+
await ta('publicHealth: proves the path THROUGH the proxy, not just loopback', async () => {
|
|
426
|
+
const params = { publicOrigin: 'https://app.example.com' };
|
|
427
|
+
const up = await lib.publicHealth(params, { fetchImpl: async (u) => { assert.equal(u, 'https://app.example.com/healthz'); return { status: 200 }; } });
|
|
428
|
+
assert.equal(up.reachable, true);
|
|
429
|
+
const down = await lib.publicHealth(params, { fetchImpl: async () => { throw new Error('ENOTFOUND'); } });
|
|
430
|
+
assert.equal(down.reachable, false);
|
|
431
|
+
assert.match(down.error, /ENOTFOUND/);
|
|
432
|
+
const bad = await lib.publicHealth(params, { fetchImpl: async () => ({ status: 502 }) });
|
|
433
|
+
assert.equal(bad.reachable, false); // proxy up, app not wired through it
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await ta('health: probes the address we actually bound', async () => {
|
|
437
|
+
let seen = null;
|
|
438
|
+
await lib.health({ port: 3006, host: '0.0.0.0' }, { fetchImpl: async (u) => { seen = u; return { status: 200, json: async () => ({}) }; } });
|
|
439
|
+
assert.match(seen, /^http:\/\/127\.0\.0\.1:3006\//);
|
|
440
|
+
});
|
|
441
|
+
|
|
252
442
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
253
443
|
process.exit(failed > 0 ? 1 : 0);
|