@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
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
const express = require('express');
|
|
14
14
|
const api = require('../../../src/module-api');
|
|
15
15
|
const pi = require('../platform-identity');
|
|
16
|
+
const projectStats = require('../project-stats');
|
|
16
17
|
|
|
17
18
|
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
|
|
18
19
|
|
|
@@ -100,7 +101,26 @@ module.exports = function projectsRoutes() {
|
|
|
100
101
|
// rank: public — a curated public showcase; no auth by design.
|
|
101
102
|
router.get('/projects/featured', async (_req, res) => {
|
|
102
103
|
try {
|
|
103
|
-
|
|
104
|
+
const projects = await pi.listFeaturedProjects({ pool });
|
|
105
|
+
// Builders + tags come from each project's OWN public API, because the
|
|
106
|
+
// apex catalog does not know them and the membership tables would report
|
|
107
|
+
// zero for every hand-wired project (task 1002771; see project-stats.js).
|
|
108
|
+
// Enrichment is best-effort by construction: a project that is down gets
|
|
109
|
+
// nulls, which the table renders as an em dash. It must never fail the
|
|
110
|
+
// listing — a broken stats fetch cannot take the catalog down with it.
|
|
111
|
+
let stats = {};
|
|
112
|
+
try {
|
|
113
|
+
stats = await projectStats.getProjectStats(projects.map((p) => p.origin));
|
|
114
|
+
} catch (err) {
|
|
115
|
+
console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
|
|
116
|
+
}
|
|
117
|
+
res.json({
|
|
118
|
+
projects: projects.map((p) => ({
|
|
119
|
+
...p,
|
|
120
|
+
builders: stats[p.origin]?.builders ?? null,
|
|
121
|
+
tags: stats[p.origin]?.tags ?? null,
|
|
122
|
+
})),
|
|
123
|
+
});
|
|
104
124
|
} catch (err) {
|
|
105
125
|
console.error('[platform-identity] GET /projects/featured', err);
|
|
106
126
|
res.fail('explore_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.103",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.103",
|
|
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.19.
|
|
3
|
+
"version": "1.19.103",
|
|
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",
|
|
@@ -540,6 +540,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
540
540
|
box-shadow:0 0 0 3px color-mix(in srgb,var(--acc) 22%,transparent);}
|
|
541
541
|
.tbl .ptitle{font-family:var(--font-display);font-weight:700;font-size:15px;color:var(--ink);}
|
|
542
542
|
.tbl .ptag{font-size:12.5px;color:var(--ink-faint);font-weight:600;}
|
|
543
|
+
.tbl .num{font-family:var(--font-mono);font-weight:500;}
|
|
544
|
+
.tbl .tags{display:flex;gap:6px;flex-wrap:wrap;}
|
|
545
|
+
.tbl .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;border-radius:999px;
|
|
546
|
+
border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
|
|
543
547
|
.tbl .pill{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
|
|
544
548
|
border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);}
|
|
545
549
|
:root[data-mode="dark"] .tbl .pill{color:var(--sun-lit);}
|
|
@@ -553,6 +557,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
553
557
|
.tbl .colStatus{width:1%;white-space:nowrap;}
|
|
554
558
|
.tbl .colGo{width:1%;white-space:nowrap;padding-left:8px;}
|
|
555
559
|
.tbl .colName{width:auto;}
|
|
560
|
+
@media (max-width:780px){.tbl .colTags{display:none;}}
|
|
556
561
|
|
|
557
562
|
/* ── the project card ──
|
|
558
563
|
An overlay INSIDE the map, not a destination: opening a project must not cost
|
|
@@ -182,6 +182,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
182
182
|
<thead>
|
|
183
183
|
<tr>
|
|
184
184
|
<th class="colName" data-sort="name" aria-sort="ascending">Project</th>
|
|
185
|
+
<th class="right" data-sort="builders">Builders</th>
|
|
186
|
+
<th class="colTags">Runs</th>
|
|
185
187
|
<th class="right colStatus">Status</th>
|
|
186
188
|
<th class="colGo"></th>
|
|
187
189
|
</tr>
|
|
@@ -196,6 +198,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
196
198
|
<td class="colName"><span class="pname"><span class="pdot" style="--acc:var(--accent)"></span>
|
|
197
199
|
<span><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><br>
|
|
198
200
|
<span class="ptag">this platform</span></span></span></td>
|
|
201
|
+
<td class="right num" id="selfBuilders">—</td>
|
|
202
|
+
<td class="colTags" id="selfTags">—</td>
|
|
199
203
|
<td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
|
|
200
204
|
<td class="colGo"><a class="btn small ghost" href="#" data-self-open>Open →</a></td>
|
|
201
205
|
</tr>
|
|
@@ -568,6 +572,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
568
572
|
if (!me) return 'none';
|
|
569
573
|
return me.rank === 'archon' ? 'owner' : 'builder';
|
|
570
574
|
}
|
|
575
|
+
/* A tag is a module the project RUNS beyond the core set — a fact it declares
|
|
576
|
+
about itself, not a guess about what it is. Capped so one heavily-moduled
|
|
577
|
+
project cannot blow the column open; the overflow is counted, never hidden
|
|
578
|
+
silently. null = we could not ask (em dash); [] = asked, runs nothing beyond
|
|
579
|
+
core, which is a real answer and still reads as a dash in the cell. */
|
|
580
|
+
var TAG_CAP = 3;
|
|
581
|
+
function tagsHtml(tags) {
|
|
582
|
+
if (!Array.isArray(tags) || !tags.length) return '—';
|
|
583
|
+
var shown = tags.slice(0, TAG_CAP).map(function (t) { return '<span>' + esc(t) + '</span>'; });
|
|
584
|
+
if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
|
|
585
|
+
return '<span class="tags">' + shown.join('') + '</span>';
|
|
586
|
+
}
|
|
571
587
|
function matchesMapQuery(p, q) {
|
|
572
588
|
if (!q) return true;
|
|
573
589
|
return (String(p.name || '') + ' ' + String(p.tagline || '')).toLowerCase().indexOf(q) !== -1;
|
|
@@ -622,6 +638,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
622
638
|
accentOf(p) + '"></span>' +
|
|
623
639
|
'<span><span class="ptitle">' + esc(p.name) + '</span>' + pill + '<br>' +
|
|
624
640
|
'<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
|
|
641
|
+
/* Builders and Runs come from the project's OWN public API, enriched
|
|
642
|
+
server-side (task 1002771). null is unknown — the project was
|
|
643
|
+
unreachable — and an em dash says exactly that. 0 is a real answer and
|
|
644
|
+
renders as 0, because "nobody has joined yet" is a different fact from
|
|
645
|
+
"we could not ask". */
|
|
646
|
+
'<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
|
|
647
|
+
'<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
|
|
625
648
|
/* liveness on a catalog row is NOT probed by this surface. A chip that
|
|
626
649
|
said "live" here would claim more than anything verified (C6); the
|
|
627
650
|
verification overlay on owned orbs is its own task. */
|
|
@@ -641,6 +664,15 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
641
664
|
});
|
|
642
665
|
list.sort(function (x, y) {
|
|
643
666
|
if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
|
|
667
|
+
/* unknown (null) sorts to the BOTTOM in both directions — it is not a low
|
|
668
|
+
number, and letting it ride the comparator would rank "we could not ask"
|
|
669
|
+
above a project that honestly has one builder */
|
|
670
|
+
if (mapSort.key === 'builders') {
|
|
671
|
+
if (x.builders == null && y.builders == null) return 0;
|
|
672
|
+
if (x.builders == null) return 1;
|
|
673
|
+
if (y.builders == null) return -1;
|
|
674
|
+
return (x.builders - y.builders) * mapSort.dir;
|
|
675
|
+
}
|
|
644
676
|
return 0;
|
|
645
677
|
});
|
|
646
678
|
renderOrbs(list);
|
|
@@ -759,6 +791,25 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
759
791
|
if (ev.key === 'Escape') closeCard();
|
|
760
792
|
});
|
|
761
793
|
|
|
794
|
+
/* The platform's own row is not in the catalog (it is synthesized — task
|
|
795
|
+
1002760), so the server-side enrichment never sees it. It can answer for
|
|
796
|
+
itself though: this is its own origin, so one same-origin call gets the same
|
|
797
|
+
number by the same rule the server applies to everyone else — real builders,
|
|
798
|
+
machine accounts excluded. Its Runs cell stays a dash honestly: every module
|
|
799
|
+
this instance enables is a CORE module, so it declares nothing beyond the
|
|
800
|
+
baseline and there is no tag to show. */
|
|
801
|
+
function loadSelfStats() {
|
|
802
|
+
return fetch(API + '/public/leaderboard', { credentials: 'same-origin' })
|
|
803
|
+
.then(function (res) { return res.ok ? res.json() : null; })
|
|
804
|
+
.then(function (d) {
|
|
805
|
+
var rows = (d && d.leaderboard) || [];
|
|
806
|
+
if (!rows.length) return;
|
|
807
|
+
var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
|
|
808
|
+
$('selfBuilders').textContent = String(real.length);
|
|
809
|
+
})
|
|
810
|
+
.catch(function () { /* unreachable → the dash already in the markup stands */ });
|
|
811
|
+
}
|
|
812
|
+
|
|
762
813
|
function loadCatalog() {
|
|
763
814
|
fetch(API + '/projects/featured', { credentials: 'same-origin' })
|
|
764
815
|
.then(function (res) { if (!res.ok) throw new Error('catalog'); return res.json(); })
|
|
@@ -1467,7 +1518,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
1467
1518
|
})
|
|
1468
1519
|
/* the catalog is PUBLIC, so it loads either way — the map is the view a
|
|
1469
1520
|
signed-out stranger lands on and it must not depend on identity */
|
|
1470
|
-
.then(loadCatalog, loadCatalog)
|
|
1521
|
+
.then(loadCatalog, loadCatalog)
|
|
1522
|
+
/* the platform's own count is public too, so it loads either way */
|
|
1523
|
+
.then(loadSelfStats, loadSelfStats);
|
|
1471
1524
|
})();
|
|
1472
1525
|
</script>
|
|
1473
1526
|
</body>
|
package/scripts/gds/dev-lib.js
CHANGED
|
@@ -112,6 +112,7 @@ function resolveRunParams({
|
|
|
112
112
|
env = process.env,
|
|
113
113
|
userInfo = os.userInfo,
|
|
114
114
|
argv = [],
|
|
115
|
+
mode = null,
|
|
115
116
|
} = {}) {
|
|
116
117
|
const facts = loadBrandingFacts({ instanceRoot, coreRoot, f });
|
|
117
118
|
const modules = readJsonSafe(path.join(instanceRoot, 'config', 'modules.json'), f) || {};
|
|
@@ -119,6 +120,9 @@ function resolveRunParams({
|
|
|
119
120
|
|
|
120
121
|
const portOverride = argFrom(argv, '--port');
|
|
121
122
|
const dbOverride = argFrom(argv, '--db');
|
|
123
|
+
// 'server' = a real host (systemd-supervised, no auto-login, outward health
|
|
124
|
+
// probe); 'local' = the ADR 0149 laptop path. Same six steps either way.
|
|
125
|
+
const runMode = mode || (argv.includes('--server') ? 'server' : 'local');
|
|
122
126
|
|
|
123
127
|
const port = portOverride ? parsePort(`http://x:${portOverride}`, parsePort(facts.publicOrigin)) : parsePort(facts.publicOrigin);
|
|
124
128
|
const dbName = deriveDbName({ brandingDbName: facts.brandingDbName, instanceRoot, override: dbOverride });
|
|
@@ -126,16 +130,19 @@ function resolveRunParams({
|
|
|
126
130
|
let pgUser = env.PGUSER;
|
|
127
131
|
if (!pgUser) { try { pgUser = userInfo().username; } catch { pgUser = undefined; } }
|
|
128
132
|
|
|
133
|
+
const publicOrigin = facts.publicOrigin.replace(/\/+$/, '');
|
|
134
|
+
|
|
129
135
|
return {
|
|
130
136
|
instanceRoot,
|
|
131
137
|
coreRoot,
|
|
138
|
+
mode: runMode,
|
|
132
139
|
port,
|
|
133
|
-
host: HOST,
|
|
140
|
+
host: deriveBindHost({ publicOrigin, mode: runMode, override: argFrom(argv, '--bind') || env.HOST }),
|
|
134
141
|
dbName,
|
|
135
142
|
pgHost: env.PGHOST || 'localhost',
|
|
136
143
|
pgUser,
|
|
137
144
|
pgPassword: env.PGPASSWORD || undefined,
|
|
138
|
-
publicOrigin
|
|
145
|
+
publicOrigin,
|
|
139
146
|
firstAdmin: facts.firstAdmin,
|
|
140
147
|
envPrefix: facts.envPrefix,
|
|
141
148
|
configDir: facts.configDir,
|
|
@@ -257,7 +264,7 @@ function stop(params, deps = {}) {
|
|
|
257
264
|
// /healthz (needs no DB); authConfigured comes from /api/gds/healthz.
|
|
258
265
|
async function health(params, deps = {}) {
|
|
259
266
|
const fetchImpl = deps.fetchImpl || fetch;
|
|
260
|
-
const base = `http://${
|
|
267
|
+
const base = `http://${probeHost(params)}:${params.port}`;
|
|
261
268
|
const timeout = deps.healthTimeoutMs || 4000;
|
|
262
269
|
let up = false;
|
|
263
270
|
try {
|
|
@@ -300,10 +307,209 @@ function depsInstallPlan({ instanceRoot, f = fs } = {}) {
|
|
|
300
307
|
: { needed: true, cmd: 'install', args: ['install'] };
|
|
301
308
|
}
|
|
302
309
|
|
|
310
|
+
// ===================== SERVER MODE (task 1002398, ADR 0162) =====================
|
|
311
|
+
// The SAME boot path — deps -> Postgres -> createdb -> migrate -> env -> server ->
|
|
312
|
+
// health — retargeted at a real host. Four decisions differ from local, and every
|
|
313
|
+
// one of them is a pure function here so tests/dev_lib.mjs can prove it without a
|
|
314
|
+
// host: where to bind, how to start Postgres, how the DB role gets bootstrapped
|
|
315
|
+
// under peer auth, and what supervises the process.
|
|
316
|
+
|
|
317
|
+
// `bongos serve stop` arrives as argv ['--server','stop'] once bin/bongos.js
|
|
318
|
+
// prefixes the mode flag, and `--port 3006 status` puts a VALUE where the old
|
|
319
|
+
// `argv[0]` scan looked for the subcommand. Resolve the subcommand by skipping
|
|
320
|
+
// flags and the values they consume, so flag order never changes the verb.
|
|
321
|
+
const VALUE_FLAGS = new Set(['--port', '--db', '--bind', '--user', '--env-file']);
|
|
322
|
+
|
|
323
|
+
function parseSub(argv = [], fallback = 'up') {
|
|
324
|
+
for (let i = 0; i < argv.length; i++) {
|
|
325
|
+
const a = argv[i];
|
|
326
|
+
if (a === '--') break;
|
|
327
|
+
if (a.startsWith('-')) { if (VALUE_FLAGS.has(a)) i++; continue; }
|
|
328
|
+
return a;
|
|
329
|
+
}
|
|
330
|
+
return fallback;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Where the server binds. Local is always loopback. On a real host the right
|
|
334
|
+
// answer is derivable from the origin the instance is CONFIGURED for, so an
|
|
335
|
+
// operator never has to reason about it:
|
|
336
|
+
// https://… -> a TLS-terminating proxy fronts us -> 127.0.0.1
|
|
337
|
+
// http://localhost:… -> a local-shaped origin on a host -> 127.0.0.1
|
|
338
|
+
// http://<public>:… -> nothing in front of us yet -> 0.0.0.0
|
|
339
|
+
// An explicit --bind (or HOST in the environment) still wins over all of it.
|
|
340
|
+
function deriveBindHost({ publicOrigin, mode = 'local', override } = {}) {
|
|
341
|
+
if (override) return String(override);
|
|
342
|
+
if (mode !== 'server') return HOST;
|
|
343
|
+
let u;
|
|
344
|
+
try { u = new URL(String(publicOrigin)); } catch { return HOST; }
|
|
345
|
+
if (u.protocol === 'https:') return HOST;
|
|
346
|
+
if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') return HOST;
|
|
347
|
+
return '0.0.0.0';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The address to PROBE for health. Binding 0.0.0.0 still answers on loopback;
|
|
351
|
+
// binding one specific address does not, so probe whatever we actually bound.
|
|
352
|
+
function probeHost(params = {}) {
|
|
353
|
+
const h = params.host || HOST;
|
|
354
|
+
return (h === '0.0.0.0' || h === '::') ? HOST : h;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function serviceName(params) { return `bongos-${sanitizeDbName(params.dbName) || 'instance'}`; }
|
|
358
|
+
function unitPath(params, { systemdDir = '/etc/systemd/system' } = {}) {
|
|
359
|
+
return path.posix.join(systemdDir, `${serviceName(params)}.service`);
|
|
360
|
+
}
|
|
361
|
+
// Secrets (PGPASSWORD, GitHub OAuth creds) belong in a root-owned 0600 file the
|
|
362
|
+
// unit references, NEVER in Environment= lines — `systemctl show` and the journal
|
|
363
|
+
// expose those to any user on the box.
|
|
364
|
+
function envFilePath(params, { etcBase = '/etc/bongos' } = {}) {
|
|
365
|
+
return path.posix.join(etcBase, `${serviceName(params)}.env`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The unit's Environment= set: ONLY the keys the boot path resolves. Deliberately
|
|
369
|
+
// not buildEnv() — that spreads the caller's whole environment, which under
|
|
370
|
+
// systemd would bake a builder's shell (tokens included) into a root-owned file.
|
|
371
|
+
function unitEnvironment(params) {
|
|
372
|
+
const pairs = [
|
|
373
|
+
['PORT', String(params.port)],
|
|
374
|
+
['HOST', params.host],
|
|
375
|
+
['PGHOST', params.pgHost],
|
|
376
|
+
['PGDATABASE', params.dbName],
|
|
377
|
+
['NODE_ENV', 'production'],
|
|
378
|
+
];
|
|
379
|
+
if (params.pgUser) pairs.push(['PGUSER', params.pgUser]);
|
|
380
|
+
if (params.gdsOnly) pairs.push(['MEDUSA_GDS_ONLY', '1']);
|
|
381
|
+
pairs.push([`${params.envPrefix}_INSTANCE_ROOT`, params.instanceRoot]);
|
|
382
|
+
return pairs;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// systemd accepts Environment="K=v with spaces"; quote every value so a path with
|
|
386
|
+
// a space (or a quote) can't split the assignment.
|
|
387
|
+
function quoteUnitValue(v) { return `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; }
|
|
388
|
+
|
|
389
|
+
function renderSystemdUnit(params, { nodePath = process.execPath, user, group, envFile } = {}) {
|
|
390
|
+
const env = unitEnvironment(params).map(([k, v]) => `Environment=${quoteUnitValue(`${k}=${v}`)}`);
|
|
391
|
+
const lines = [
|
|
392
|
+
'[Unit]',
|
|
393
|
+
`Description=Cloud Bongos instance ${params.dbName} (bongos serve)`,
|
|
394
|
+
'Wants=network-online.target',
|
|
395
|
+
// postgresql.service may not exist (managed/remote DB) — After= on an absent
|
|
396
|
+
// unit is a no-op, so this is safe either way and orders us correctly when it
|
|
397
|
+
// IS local. No Requires=: a remote-DB host must still boot.
|
|
398
|
+
'After=network-online.target postgresql.service',
|
|
399
|
+
'',
|
|
400
|
+
'[Service]',
|
|
401
|
+
'Type=simple',
|
|
402
|
+
`WorkingDirectory=${params.instanceRoot}`,
|
|
403
|
+
`ExecStart=${nodePath} ${serverEntry(params)}`,
|
|
404
|
+
...env,
|
|
405
|
+
`EnvironmentFile=-${envFile || envFilePath(params)}`,
|
|
406
|
+
// Survive a crash AND a reboot — the whole point of moving off the pid file.
|
|
407
|
+
'Restart=always',
|
|
408
|
+
'RestartSec=3',
|
|
409
|
+
'NoNewPrivileges=yes',
|
|
410
|
+
'StandardOutput=journal',
|
|
411
|
+
'StandardError=journal',
|
|
412
|
+
];
|
|
413
|
+
if (user) lines.push(`User=${user}`);
|
|
414
|
+
if (group) lines.push(`Group=${group}`);
|
|
415
|
+
lines.push('', '[Install]', 'WantedBy=multi-user.target', '');
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Can this host supervise with systemd, and do we need sudo to install the unit?
|
|
420
|
+
function systemdPlan({ platform = process.platform, has = () => false, isRoot = false } = {}) {
|
|
421
|
+
if (platform !== 'linux') return { available: false, reason: `systemd is linux-only (this host is ${platform})` };
|
|
422
|
+
if (!has('systemctl')) return { available: false, reason: 'systemctl is not on PATH' };
|
|
423
|
+
if (isRoot) return { available: true, sudo: false };
|
|
424
|
+
if (!has('sudo')) return { available: false, reason: 'installing a unit needs root, and sudo is not on PATH' };
|
|
425
|
+
return { available: true, sudo: true };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Prefix a step with non-interactive sudo when we are not already root. `sudo -n`
|
|
429
|
+
// never prompts: on a host without passwordless sudo it fails fast with a clear
|
|
430
|
+
// error instead of hanging a deploy on a hidden password prompt.
|
|
431
|
+
function withSudo(step, { isRoot = false, sudo = true } = {}) {
|
|
432
|
+
if (isRoot || !sudo) return step;
|
|
433
|
+
return { ...step, cmd: 'sudo', args: ['-n', step.cmd, ...step.args] };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Ordered candidates for "start Postgres", best-first for the platform. dev.js
|
|
437
|
+
// tries each until pg_isready passes. Homebrew keeps its own branch there (the
|
|
438
|
+
// formula name has to be discovered at runtime); everything else is decided here.
|
|
439
|
+
function pgStartPlan({ platform = process.platform, has = () => false, env = process.env, isRoot = false } = {}) {
|
|
440
|
+
const out = [];
|
|
441
|
+
if (platform === 'darwin' && has('brew')) out.push({ cmd: 'brew', args: ['services', 'start'], needsFormula: true, label: 'Homebrew' });
|
|
442
|
+
if (platform === 'linux') {
|
|
443
|
+
if (has('systemctl')) out.push(withSudo({ cmd: 'systemctl', args: ['start', 'postgresql'], label: 'systemd' }, { isRoot }));
|
|
444
|
+
else if (has('service')) out.push(withSudo({ cmd: 'service', args: ['postgresql', 'start'], label: 'service' }, { isRoot }));
|
|
445
|
+
}
|
|
446
|
+
if (has('pg_ctl') && env.PGDATA) out.push({ cmd: 'pg_ctl', args: ['start', '-w', '-D', env.PGDATA], label: 'pg_ctl' });
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Postgres identifiers are case-folded unless quoted, and a role name reaches the
|
|
451
|
+
// server as SQL text — quote it properly and refuse anything that isn't a plain
|
|
452
|
+
// identifier rather than interpolating a surprise into a CREATE ROLE.
|
|
453
|
+
const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
454
|
+
function quoteIdent(s) { return `"${String(s).replace(/"/g, '""')}"`; }
|
|
455
|
+
|
|
456
|
+
// The fresh-Debian wall: `apt-get install postgresql` creates exactly ONE role,
|
|
457
|
+
// `postgres`, reachable only by peer auth as the postgres OS user. A deploy user
|
|
458
|
+
// running `createdb` gets 'role "deploy" does not exist' — which reads like a
|
|
459
|
+
// broken install and is really just an unbootstrapped cluster. These two steps,
|
|
460
|
+
// run as the postgres superuser, are the whole fix.
|
|
461
|
+
function pgBootstrapPlan({ pgUser, dbName, isRoot = false, has = () => false } = {}) {
|
|
462
|
+
if (!pgUser || !SAFE_IDENT.test(pgUser)) return { ok: false, reason: `refusing to bootstrap an unsafe role name: ${pgUser || '(none)'}` };
|
|
463
|
+
if (!dbName || !SAFE_IDENT.test(dbName)) return { ok: false, reason: `refusing to bootstrap an unsafe database name: ${dbName || '(none)'}` };
|
|
464
|
+
// Reach the postgres superuser the way this host allows: `sudo -u postgres` for
|
|
465
|
+
// a normal deploy user (and for root when sudo exists), else `su postgres -c`
|
|
466
|
+
// when we are already root on a minimal image with no sudo installed.
|
|
467
|
+
let asPostgres;
|
|
468
|
+
if (has('sudo')) asPostgres = (cmd, args, label) => ({ cmd: 'sudo', args: ['-n', '-u', 'postgres', cmd, ...args], label });
|
|
469
|
+
else if (isRoot) asPostgres = (cmd, args, label) => ({ cmd: 'su', args: ['postgres', '-c', shellJoin([cmd, ...args])], label });
|
|
470
|
+
else return { ok: false, reason: 'bootstrapping the role needs the postgres superuser (root or sudo)' };
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
ok: true,
|
|
474
|
+
steps: [
|
|
475
|
+
{ ...asPostgres('psql', ['-v', 'ON_ERROR_STOP=1', '-tAc',
|
|
476
|
+
`SELECT 1 FROM pg_roles WHERE rolname='${pgUser}'`], 'check role'), id: 'check-role' },
|
|
477
|
+
{ ...asPostgres('psql', ['-v', 'ON_ERROR_STOP=1', '-c',
|
|
478
|
+
`CREATE ROLE ${quoteIdent(pgUser)} LOGIN CREATEDB`], 'create role'), id: 'create-role' },
|
|
479
|
+
{ ...asPostgres('createdb', ['-O', pgUser, dbName], 'create database'), id: 'create-db' },
|
|
480
|
+
],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Quote an argv into a single `su -c` string. Only ever fed argv this module
|
|
485
|
+
// built from validated identifiers, but quote anyway — `su -c` is a shell.
|
|
486
|
+
function shellJoin(argv) {
|
|
487
|
+
return argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`)).join(' ');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Health as seen from OUTSIDE — does the origin the instance advertises actually
|
|
491
|
+
// answer? On a server that also exercises DNS, the proxy, and TLS, which the
|
|
492
|
+
// loopback probe cannot see. Advisory: DNS may legitimately not be wired yet.
|
|
493
|
+
async function publicHealth(params, deps = {}) {
|
|
494
|
+
const fetchImpl = deps.fetchImpl || fetch;
|
|
495
|
+
const timeout = deps.healthTimeoutMs || 6000;
|
|
496
|
+
try {
|
|
497
|
+
const r = await fetchImpl(`${params.publicOrigin}/healthz`, { signal: AbortSignal.timeout(timeout) });
|
|
498
|
+
return { reachable: r.status < 500, status: r.status };
|
|
499
|
+
} catch (e) {
|
|
500
|
+
return { reachable: false, error: e && e.message ? e.message : String(e) };
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
303
504
|
module.exports = {
|
|
304
505
|
HOST, DEFAULT_PORT, CORE_ROOT,
|
|
305
506
|
readJsonSafe, loadBrandingFacts, parsePort, sanitizeDbName, deriveDbName,
|
|
306
507
|
resolveRunParams, buildEnv, authWillWork, depsInstallPlan,
|
|
307
508
|
slug, pidPath, logPath, readPid, pidAlive, isRunning, serverEntry,
|
|
308
509
|
startDetached, stop, health, argFrom,
|
|
510
|
+
// server mode (task 1002398). withSudo/quoteIdent/shellJoin stay private —
|
|
511
|
+
// they are implementation details of the plans above, not a callable surface.
|
|
512
|
+
parseSub, deriveBindHost, probeHost, serviceName, unitPath, envFilePath,
|
|
513
|
+
unitEnvironment, renderSystemdUnit, systemdPlan, pgStartPlan,
|
|
514
|
+
pgBootstrapPlan, publicHealth,
|
|
309
515
|
};
|