@bongos/core 1.20.15 → 1.20.17
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 +28 -28
- package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +2 -2
- package/docs/api/openapi.json +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/read-rate-limit.js +6 -4
- package/modules/platform-identity/routes/scouting.js +27 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-config.js +10 -14
- package/scripts/gds/provision-disconnect.js +4 -6
- package/scripts/gds/provision-net.js +5 -8
- package/scripts/gds/provision-repo.js +12 -33
- package/scripts/gds/provision-teardown.js +10 -14
- package/scripts/gds/provision-units.js +3 -4
- package/scripts/gds/provision.js +94 -201
- package/src/module-api.js +1 -1
- package/tests/provision.mjs +15 -134
- package/tests/provision_account_identity.mjs +2 -5
- package/tests/provision_disconnect.mjs +1 -10
- package/tests/provision_restart.mjs +3 -12
- package/tests/provision_settings_apply.mjs +7 -19
- package/tests/provision_teardown_address.mjs +1 -19
- package/tests/recruiter_sliver_boundary.mjs +69 -0
|
@@ -87,7 +87,7 @@ function backupUnitName(inst) { return `${inst.slug}-db-backup`; }
|
|
|
87
87
|
function backupServicePath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.service`; }
|
|
88
88
|
function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.timer`; }
|
|
89
89
|
// The consumer-layout-correct absolute path to db-backup-nightly.sh. Standalone runs
|
|
90
|
-
// it from the instance's own pinned core; co-tenant
|
|
90
|
+
// it from the instance's own pinned core; co-tenant from this checkout's
|
|
91
91
|
// scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
|
|
92
92
|
function backupScriptPath(inst) {
|
|
93
93
|
const rel = 'scripts/gds/db-backup-nightly.sh';
|
|
@@ -269,9 +269,8 @@ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
|
|
|
269
269
|
: '';
|
|
270
270
|
// The pool cap (task 1003928, ADR 0285 §3): a co-located instance shares the box's
|
|
271
271
|
// Postgres with every sibling and the control plane, so its pool may not take pg's
|
|
272
|
-
// default 10 of the server's 97. src/bongos/pool.js reads it bare.
|
|
273
|
-
|
|
274
|
-
const poolLine = inst.hosting_shape === 'dedicated' ? '' : `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
|
|
272
|
+
// default 10 of the server's 97. src/bongos/pool.js reads it bare.
|
|
273
|
+
const poolLine = `PGPOOL_MAX=${CONFIG.instancePoolMax}\n`;
|
|
275
274
|
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
276
275
|
${credComment}
|
|
277
276
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
package/scripts/gds/provision.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// composes the docs/recipes/cloudbongos-standup.md runbook into ONE idempotent,
|
|
14
14
|
// dry-run-default, drift-reconciling flow (ADR 0016 / ADR 0031 §9.4 trust boundary):
|
|
15
15
|
//
|
|
16
|
-
// server (co-tenant
|
|
16
|
+
// server (a process on an allocated port — co-tenant or cloud-host)
|
|
17
17
|
// → createdb + GDS-only migrate (MEDUSA_GDS_ONLY=1)
|
|
18
18
|
// → write config + per-instance /etc/<inst>/web.env
|
|
19
19
|
// → install + enable a per-instance systemd unit (+ allocate a port)
|
|
@@ -31,16 +31,14 @@
|
|
|
31
31
|
// default OFF = bare commands (root / test / co-tenant-legacy contexts unchanged).
|
|
32
32
|
// • Injectable executor. `makeExec` returns a `run(cmd)` that is a pure logger
|
|
33
33
|
// in dry-run and child_process in --apply; tests pass a recording fake, so the
|
|
34
|
-
// whole runbook composition is unit-tested with no
|
|
34
|
+
// whole runbook composition is unit-tested with no box / DB / network.
|
|
35
35
|
// • Idempotent. Re-running a half-done provision is safe (createdb tolerates
|
|
36
36
|
// "already exists"; unit/Caddy writes are overwrites; healthz is a poll).
|
|
37
|
-
// • The step-command generators are PURE functions (exported)
|
|
38
|
-
// strings drive local exec (co-tenant) AND the dedicated droplet's cloud-init.
|
|
37
|
+
// • The step-command generators are PURE functions (exported), tested directly.
|
|
39
38
|
//
|
|
40
39
|
// This is control-plane ops tooling (scripts/), so it may require the core + a
|
|
41
40
|
// module's data slice directly (a module may not, but a CLI may). It
|
|
42
|
-
// reuses modules/provisioning/provisioning.js (the intent queue + state)
|
|
43
|
-
// scripts/gds/do-api.js (the shared DO client extracted in task #1943).
|
|
41
|
+
// reuses modules/provisioning/provisioning.js (the intent queue + state).
|
|
44
42
|
|
|
45
43
|
const fs = require('node:fs');
|
|
46
44
|
const os = require('node:os');
|
|
@@ -49,7 +47,7 @@ const cp = require('node:child_process');
|
|
|
49
47
|
const crypto = require('node:crypto');
|
|
50
48
|
|
|
51
49
|
const { CONFIG, MANIFEST_UA, softFailResult, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
52
|
-
const { accountPreflight,
|
|
50
|
+
const { accountPreflight, appUser, alreadyScaffolded, buildCorePinRefreshCommit, coreCheckoutHasGit, dbCreateCmd, dbName, dbRoleCmd, dbRoleCmdShown, dbRoleGrantsCmd, ensureInstanceUserCmd, generateDbPassword, grantInstanceRepoReadCmd, instanceDbRole, instanceStateDir, instanceUser, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
53
51
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, renamedEnvCarry, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
54
52
|
const { caddyBlock, caddySnippetPath, checkCaddyWiring, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, ingressPlaneGap, ingressPreflight, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
53
|
const { resolveProcessManager } = require('./provision-process.js');
|
|
@@ -222,7 +220,7 @@ async function allocatePort(db, provisioning, { exec } = {}) {
|
|
|
222
220
|
|
|
223
221
|
// ---------------------------------------------------------------------------
|
|
224
222
|
// Compose + run the standup runbook for ONE instance (idempotent, dry-run-safe).
|
|
225
|
-
// deps: { db, provisioning,
|
|
223
|
+
// deps: { db, provisioning, exec, writeFile, log, apply, privileged }
|
|
226
224
|
// ---------------------------------------------------------------------------
|
|
227
225
|
async function provisionInstance(inst, deps) {
|
|
228
226
|
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
@@ -235,7 +233,7 @@ async function provisionInstance(inst, deps) {
|
|
|
235
233
|
// (code sync, migrate, /etc writes, systemd, Caddy, healthz) must run on the SAME
|
|
236
234
|
// box — NOT the remote co-hosting box that PROVISION_REMOTE_HOST routes co-tenant
|
|
237
235
|
// steps to (that box never had the dir; ADR 0130's remote-exec was co-tenant-only).
|
|
238
|
-
// So a standalone uses the control-plane executors; co-tenant
|
|
236
|
+
// So a standalone uses the control-plane executors; co-tenant is
|
|
239
237
|
// unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
|
|
240
238
|
// set, controlExec === exec anyway, so this is a no-op for the all-local default.
|
|
241
239
|
const standalone = inst.hosting_shape === 'cloud-host';
|
|
@@ -252,7 +250,7 @@ async function provisionInstance(inst, deps) {
|
|
|
252
250
|
|
|
253
251
|
if (isByoHost(inst.hosting_shape)) return recordByoHost(inst, deps); // stand up NOTHING, ABOVE every preflight — reasoning in provision-byo-host.js's header (ADR 0323)
|
|
254
252
|
// ── preflight: DNS token scope (task 1980) ─────────────────────────────────
|
|
255
|
-
// BEFORE committing to the address (allocating a port /
|
|
253
|
+
// BEFORE committing to the address (allocating a port / the
|
|
256
254
|
// step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
|
|
257
255
|
// the domain's zone. Turns the Emersonian standup's confusing mid-flow step-5 DNS
|
|
258
256
|
// failure into an up-front, actionable stop with no half-done provision. Read-only.
|
|
@@ -275,13 +273,13 @@ async function provisionInstance(inst, deps) {
|
|
|
275
273
|
// before any mutation: ingress asks whether this plane has a proxy AT ALL, 0303 below whether
|
|
276
274
|
// that proxy is wired for THIS domain. Deliberately NOT domain-gated — a domainless child
|
|
277
275
|
// skipped 0303 entirely, reached step 6 and died on the snippet write.
|
|
278
|
-
const ingressGap = ingressPreflight({ exec: boxExec, apply, log,
|
|
276
|
+
const ingressGap = ingressPreflight({ exec: boxExec, apply, log, probe: deps.ingressPlaneGap });
|
|
279
277
|
if (ingressGap) return { ok: false, error: ingressGap };
|
|
280
278
|
// Account preflight (task 1004018, ADR 0317). The third and last PLANE question — can this
|
|
281
279
|
// box give the child its own unix account? — before 0303 below asks the per-DOMAIN one. A
|
|
282
280
|
// plane that cannot would run every child as one uid, the ADR 0281 vector, and step 1c's
|
|
283
281
|
// useradd would crash mid-provision instead of refusing.
|
|
284
|
-
const accountGap = accountPreflight({ exec: boxExec, apply, log,
|
|
282
|
+
const accountGap = accountPreflight({ exec: boxExec, apply, log, privileged, probe: deps.accountPlaneGap });
|
|
285
283
|
if (accountGap) return { ok: false, error: accountGap };
|
|
286
284
|
// App-user preflight (task 1002719): steps below name the app user, and appUser() refuses
|
|
287
285
|
// root or an unusable name. Refuse HERE, before the status flip, not midway through.
|
|
@@ -289,7 +287,7 @@ async function provisionInstance(inst, deps) {
|
|
|
289
287
|
// Caddy preflight (task 1002860, ADR 0303): a box whose base Caddyfile has no tls-check ask
|
|
290
288
|
// can never certify this domain, so refuse before touching it. Injectable like the DNS check.
|
|
291
289
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring;
|
|
292
|
-
if (inst.domain &&
|
|
290
|
+
if (inst.domain && apply) {
|
|
293
291
|
const gap = caddyCheck(boxExec, { domain: inst.domain });
|
|
294
292
|
if (gap) return { ok: false, error: `Caddy preflight failed: ${gap}` };
|
|
295
293
|
}
|
|
@@ -328,68 +326,39 @@ async function provisionInstance(inst, deps) {
|
|
|
328
326
|
// else PROVISION_PUBLIC_IP (all-local: one and the same machine).
|
|
329
327
|
// Remote-exec set but no control-plane IP configured → null, which
|
|
330
328
|
// makes step 5 fail loud rather than point the domain at the wrong box.
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
log(
|
|
343
|
-
if (apply && doApi) {
|
|
344
|
-
const { droplet } = await doApi.createDroplet({
|
|
345
|
-
name: `cb-${inst.slug}`, region: CONFIG.region, size: inst.tier || 's-2vcpu-4gb',
|
|
346
|
-
image: CONFIG.baseImage, tags: [CONFIG.tag], sshKeys: CONFIG.sshKeyIds,
|
|
347
|
-
userData: dedicatedUserData(inst),
|
|
348
|
-
});
|
|
349
|
-
const settled = await doApi.settleDroplet(droplet.id);
|
|
350
|
-
patch.error_note = null;
|
|
351
|
-
recordIp = settled.ip || null; // the droplet's own IP is this instance's A-record target
|
|
352
|
-
log(` droplet ${droplet.id} @ ${settled.ip || '(pending)'} — app standup rides cloud-init`);
|
|
353
|
-
} else {
|
|
354
|
-
log(' would createDroplet + settle IP, baking cloud-init that runs the standup steps');
|
|
355
|
-
}
|
|
329
|
+
const recordIp = standalone ? CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp) : CONFIG.publicIp;
|
|
330
|
+
// Co-tenant OR standalone: a process bound to an allocated port on the box it runs
|
|
331
|
+
// on. Co-tenant shares that box's checkout ($0, cloudbongos.com model — under
|
|
332
|
+
// remote-exec, the co-hosting box); standalone (ADR 0108) runs from its OWN repo
|
|
333
|
+
// dir with a pinned @bongos/core (synced in step 2) on the CONTROL PLANE.
|
|
334
|
+
// Allocate against the ports of whichever box it runs on (boxExec: remote for a
|
|
335
|
+
// co-tenant, local for a standalone — task 2074), then run the rest there.
|
|
336
|
+
if (!inst.port) {
|
|
337
|
+
const port = await allocatePort(db, provisioning, { exec: boxExec });
|
|
338
|
+
patch.port = port;
|
|
339
|
+
inst = { ...inst, port };
|
|
340
|
+
log(` [server] ${inst.hosting_shape} — allocated port ${port}`);
|
|
356
341
|
} else {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
|
|
370
|
-
}
|
|
371
|
-
// Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
|
|
372
|
-
// materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
|
|
373
|
-
// (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
|
|
374
|
-
// throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
|
|
375
|
-
if (inst.hosting_shape === 'cloud-host') {
|
|
376
|
-
await scaffoldStandaloneRepo(inst, deps);
|
|
377
|
-
// A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's `git pull
|
|
378
|
-
// --ff-only origin main`, which failed unauthenticated on every private standup (N4; ADR 0176).
|
|
379
|
-
await ensurePrivateRepoAccess(inst, deps);
|
|
380
|
-
await require('./provision-pin-key.js').ensurePinWriteKey(inst, deps); // the hub's WRITE key for later pin pushes, public repos too (task 1004291, ADR 0350)
|
|
381
|
-
}
|
|
342
|
+
log(` [server] ${inst.hosting_shape} — port ${inst.port}`);
|
|
343
|
+
}
|
|
344
|
+
// Standalone: SCAFFOLD its own repo if absent — clone the (empty) target_ref →
|
|
345
|
+
// materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
|
|
346
|
+
// (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
|
|
347
|
+
// throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
|
|
348
|
+
if (inst.hosting_shape === 'cloud-host') {
|
|
349
|
+
await scaffoldStandaloneRepo(inst, deps);
|
|
350
|
+
// A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's `git pull
|
|
351
|
+
// --ff-only origin main`, which failed unauthenticated on every private standup (N4; ADR 0176).
|
|
352
|
+
await ensurePrivateRepoAccess(inst, deps);
|
|
353
|
+
await require('./provision-pin-key.js').ensurePinWriteKey(inst, deps); // the hub's WRITE key for later pin pushes, public repos too (task 1004291, ADR 0350)
|
|
382
354
|
}
|
|
383
355
|
if (!inst.db_name) { patch.db_name = inst.slug; inst = { ...inst, db_name: inst.slug }; }
|
|
384
356
|
|
|
385
|
-
// The remaining steps run on the instance's box
|
|
386
|
-
//
|
|
387
|
-
// standalone; task 2074); for dedicated they are baked into cloud-init (above) and
|
|
388
|
-
// re-asserted here as idempotent no-ops on the box.
|
|
389
|
-
const local = inst.hosting_shape !== 'dedicated';
|
|
357
|
+
// The remaining steps run on the instance's box (boxExec/boxWriteFile — remote for
|
|
358
|
+
// co-tenant under remote-exec, control-plane-local for standalone; task 2074).
|
|
390
359
|
|
|
391
360
|
// ── step 2: code sync (standalone) + database ─────────────────────────────
|
|
392
|
-
if (
|
|
361
|
+
if (standalone) {
|
|
393
362
|
// Standalone (ADR 0108 / deploy-instance.sh steps 1-2): sync the instance's own
|
|
394
363
|
// repo + install the pinned @bongos/core, so the migrate + service below run
|
|
395
364
|
// from the INSTANCE root, not this control-plane checkout. cwd = standaloneRoot.
|
|
@@ -412,86 +381,74 @@ async function provisionInstance(inst, deps) {
|
|
|
412
381
|
// ── step 1c: the instance's OWN unix account (task 1003369, audit B3) ─────
|
|
413
382
|
// Idempotent; the group-add is what lets a fresh system account READ the checkout it runs
|
|
414
383
|
// from — without it the unit fails at exec with EACCES. Rationale: provision-repo.js.
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
|
|
419
|
-
} else { log(' (instance unix account created via cloud-init on the droplet)'); }
|
|
384
|
+
log(` [user] ensure unix account ${instanceUser(inst)} + its state dir`);
|
|
385
|
+
boxExec(ensureInstanceUserCmd(inst, { privileged }));
|
|
386
|
+
boxExec(grantInstanceRepoReadCmd(inst, { privileged }));
|
|
420
387
|
|
|
421
388
|
log(' [db] create + GDS-only migrate');
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
} else { log(' (db create + migrate + first-version seed run via cloud-init on the droplet)'); }
|
|
389
|
+
boxExec(dbCreateCmd(inst, { privileged }), { allowFail: true }); // "already exists" is fine
|
|
390
|
+
if (standalone) boxExec(standaloneMigrateCmd(inst, { privileged }), { cwd: standaloneRoot(inst) }); // instance-root migrate
|
|
391
|
+
else boxExec(migrateCmd(inst, { privileged }));
|
|
392
|
+
// Seed the first version so the instance can take tasks from minute one (task 2119,
|
|
393
|
+
// goal 35). Only fires if the versions table is empty — idempotent, never disturbs an
|
|
394
|
+
// existing project. NOT allowFail: a re-run is a clean 0-row no-op, so a real failure
|
|
395
|
+
// here should surface.
|
|
396
|
+
log(' [seed] first version (only if versions is empty)');
|
|
397
|
+
boxExec(seedFirstVersionCmd(inst, { privileged }));
|
|
398
|
+
// Regenerate the whole-file navigation docs from the pinned core so a fresh/redeployed
|
|
399
|
+
// standalone instance's docs/ stays live (ADR 0110 §2, task 1002314) — the deploy-regen the
|
|
400
|
+
// bespoke pull scripts were missing. Best-effort: regen-instance-docs.js exits 0 even on a
|
|
401
|
+
// generator hiccup (LOUD ::ERROR::), so a stale nav doc never fails a provision.
|
|
402
|
+
if (standalone) {
|
|
403
|
+
log(' [docs] regenerate whole-file navigation docs (session-log index)');
|
|
404
|
+
boxExec(standaloneRegenDocsCmd(), { cwd: standaloneRoot(inst), allowFail: true });
|
|
405
|
+
}
|
|
441
406
|
|
|
442
407
|
// ── step 2b: the instance's OWN password-authed PG role (task 1003369, audit B3) ──
|
|
443
408
|
// AFTER the migrate: it runs as `postgres`, which therefore OWNS the tables it creates.
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
boxExec(dbRoleGrantsCmd(inst, { privileged }));
|
|
452
|
-
} else { log(' (per-instance DB role created via cloud-init on the droplet)'); }
|
|
409
|
+
log(' [db] create/rotate the per-instance role + revoke PUBLIC connect');
|
|
410
|
+
// `shown` is MANDATORY: dbRoleCmd embeds the password, and run() logs the raw command
|
|
411
|
+
// on BOTH the dry-run and success paths — the exact leak this task exists to prevent
|
|
412
|
+
// (makeWriteFile passes `shown` for the web.env body for the same reason, and a `shown`
|
|
413
|
+
// call site also suppresses stderr). The GRANTS carry no secret and log in full.
|
|
414
|
+
boxExec(dbRoleCmd(inst, { password: dbPassword, privileged }), { shown: dbRoleCmdShown(inst) });
|
|
415
|
+
boxExec(dbRoleGrantsCmd(inst, { privileged }));
|
|
453
416
|
|
|
454
417
|
// ── step 3: config + per-instance secrets ─────────────────────────────────
|
|
455
418
|
log(' [config] per-instance web.env');
|
|
456
|
-
|
|
457
|
-
else log(' (baked into cloud-init)');
|
|
419
|
+
boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation, ownerLogin: await ownerLoginOf(inst, deps.db), dbPassword }), { mode: 0o600, sudo: true });
|
|
458
420
|
|
|
459
421
|
// ── step 4: install + enable the instance's service ───────────────────────
|
|
460
422
|
const pm = pmFor(deps);
|
|
461
423
|
log(` [service] install + enable the ${pm.key} service`);
|
|
462
|
-
|
|
463
|
-
pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
|
|
464
|
-
} else { log(' (unit baked into cloud-init)'); }
|
|
424
|
+
pm.installService(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
|
|
465
425
|
|
|
466
426
|
// ── step 4b: nightly DB backup — dir + consumer-layout timer/service (task 2213) ──
|
|
467
427
|
// Every instance must be born WITH backups (the task-2210 gap: cloudbongos was
|
|
468
428
|
// provisioned without any). Create the app-user-owned backup dir, install the
|
|
469
429
|
// per-instance timer/service (correct db + consumer-layout script path), enable
|
|
470
430
|
// the timer. Idempotent: `install -d` no-ops on an existing dir, unit writes are
|
|
471
|
-
// overwrites, `enable --now` is idempotent.
|
|
431
|
+
// overwrites, `enable --now` is idempotent.
|
|
472
432
|
log(' [backup] create backup dir + install + enable nightly DB backup timer');
|
|
473
|
-
|
|
474
|
-
pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
|
|
475
|
-
} else { log(' (backup dir + units baked into cloud-init)'); }
|
|
433
|
+
pm.installBackup(inst, { exec: boxExec, writeFile: boxWriteFile, sudoP, log });
|
|
476
434
|
|
|
477
435
|
// ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
|
|
478
436
|
// Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
|
|
479
437
|
// record pointing the domain at its target IP (recordIp), in the domain's own
|
|
480
438
|
// zone, proxied=false so Caddy's on-demand ACME challenge (step 6) reaches the
|
|
481
|
-
// origin (dnsUpsertEnv). It runs on the CONTROL PLANE for
|
|
482
|
-
// needs the Cloudflare token, which never leaves the control plane
|
|
483
|
-
// instance's DNS is upserted from here (not baked into the droplet's cloud-init).
|
|
439
|
+
// origin (dnsUpsertEnv). It runs on the CONTROL PLANE for every shape: the hook
|
|
440
|
+
// needs the Cloudflare token, which never leaves the control plane.
|
|
484
441
|
// Unlike P3's best-effort allowFail, a DNS FAILURE now SURFACES (the task-1180
|
|
485
442
|
// lesson: never report provision success while the A record silently didn't land).
|
|
486
443
|
if (inst.domain && CONFIG.dnsHook) {
|
|
487
444
|
if (!recordIp) {
|
|
488
445
|
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
489
|
-
if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env
|
|
446
|
+
if (apply) throw new Error(`cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env`);
|
|
490
447
|
log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
|
|
491
448
|
} else {
|
|
492
449
|
// Standalone runs control-plane-local under the *.cloudbongos.com wildcard origin
|
|
493
|
-
// cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant
|
|
494
|
-
//
|
|
450
|
+
// cert (task 2074/2077) → proxied=true (CF edge fronts it, IP hidden); co-tenant
|
|
451
|
+
// stays proxied=false so their on-demand ACME challenge reaches the origin.
|
|
495
452
|
const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
|
|
496
453
|
log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
|
|
497
454
|
// controlExec, NOT exec: the DNS hook needs the Cloudflare token, which lives
|
|
@@ -528,12 +485,10 @@ async function provisionInstance(inst, deps) {
|
|
|
528
485
|
// setup) — verified after the write, since only a written snippet proves the import
|
|
529
486
|
// reaches it. The reload is loud: Caddy keeps its old config when it rejects the new one.
|
|
530
487
|
log(' [caddy] write site snippet + reload');
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
boxExec(`${sudoP}systemctl reload caddy`);
|
|
536
|
-
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
488
|
+
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
489
|
+
const gap = inst.domain && apply ? caddyCheck(boxExec, { domain: inst.domain, requireHost: true }) : null;
|
|
490
|
+
if (gap) return { ok: false, error: `Caddy wiring failed: ${gap}` };
|
|
491
|
+
boxExec(`${sudoP}systemctl reload caddy`);
|
|
537
492
|
|
|
538
493
|
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
539
494
|
// The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
|
|
@@ -545,17 +500,15 @@ async function provisionInstance(inst, deps) {
|
|
|
545
500
|
// what tells the owner the truth.
|
|
546
501
|
log(' [healthz] verify the instance answers');
|
|
547
502
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
548
|
-
let healthy = null; // null = not observed (dry-run
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
} else log(' (verified post-boot)');
|
|
503
|
+
let healthy = null; // null = not observed (dry-run)
|
|
504
|
+
const tries = apply ? 10 : 1;
|
|
505
|
+
for (let i = 0; i < tries; i++) {
|
|
506
|
+
const res = boxExec(healthzCmd(inst), { allowFail: true });
|
|
507
|
+
if (!apply) break; // dry-run: exactly one plan line
|
|
508
|
+
if (res && !res.softFailed) { healthy = true; break; }
|
|
509
|
+
healthy = false;
|
|
510
|
+
if (i < tries - 1) await sleep(1000);
|
|
511
|
+
}
|
|
559
512
|
|
|
560
513
|
// ── step 7b: identity ─────────────────────────────────────────────────────
|
|
561
514
|
// Does it answer as ITSELF? A live instance that serves the platform's name instead of its own
|
|
@@ -563,7 +516,7 @@ async function provisionInstance(inst, deps) {
|
|
|
563
516
|
// (task 1003740, which went unnoticed across every hosted instance for months). A WARNING, never
|
|
564
517
|
// a failure: an owner may legitimately name a project after the platform, and a name must not
|
|
565
518
|
// fail a working standup.
|
|
566
|
-
if (
|
|
519
|
+
if (apply && healthy !== false) {
|
|
567
520
|
const res = boxExec(identityCmd(inst), { allowFail: true });
|
|
568
521
|
const raw = res && !res.softFailed ? (res.stdout || '') : '';
|
|
569
522
|
let manifest = null;
|
|
@@ -613,42 +566,6 @@ async function provisionInstance(inst, deps) {
|
|
|
613
566
|
return { ok: true, port: inst.port, healthy };
|
|
614
567
|
}
|
|
615
568
|
|
|
616
|
-
// The cloud-init user-data for a dedicated droplet: run the co-tenant standup
|
|
617
|
-
// steps on first boot. Kept small (well under DO's 64 KiB cap). Built from the
|
|
618
|
-
// SAME pure command generators so local + dedicated never diverge.
|
|
619
|
-
function dedicatedUserData(inst) {
|
|
620
|
-
const i = { ...inst, db_name: inst.db_name || inst.slug };
|
|
621
|
-
// task 2213: a dedicated droplet gets the same nightly backup as local shapes —
|
|
622
|
-
// write the per-instance unit pair (base64-encoded to sidestep YAML block-scalar
|
|
623
|
-
// escaping of the multi-line bodies), create the app-user-owned backup dir, and
|
|
624
|
-
// enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
|
|
625
|
-
const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
|
|
626
|
-
const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
|
|
627
|
-
// task 1003369: the same role the local leg creates at step 2b, same position around the
|
|
628
|
-
// migrate, but PASSWORD-LESS (peerOnly) — DO retains user-data and serves it back, and a
|
|
629
|
-
// dedicated droplet is single-tenant. Whole argument on dbRoleCmd.
|
|
630
|
-
const roleLines = ` - [ bash, -lc, "${dbRoleCmd(i, { peerOnly: true })}" ]\n - [ bash, -lc, "${dbRoleGrantsCmd(i)}" ]\n`;
|
|
631
|
-
return `#cloud-config
|
|
632
|
-
write_files:
|
|
633
|
-
- path: ${backupServicePath(i)}
|
|
634
|
-
encoding: b64
|
|
635
|
-
permissions: '0644'
|
|
636
|
-
content: ${svcB64}
|
|
637
|
-
- path: ${backupTimerPath(i)}
|
|
638
|
-
encoding: b64
|
|
639
|
-
permissions: '0644'
|
|
640
|
-
content: ${timerB64}
|
|
641
|
-
runcmd:
|
|
642
|
-
- [ bash, -lc, "${ensureInstanceUserCmd(i)}" ]
|
|
643
|
-
- [ bash, -lc, "${grantInstanceRepoReadCmd(i)}" ]
|
|
644
|
-
- [ bash, -lc, "${dbCreateCmd(i)} || true" ]
|
|
645
|
-
- [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
|
|
646
|
-
- [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
|
|
647
|
-
${roleLines} - [ bash, -lc, "install -d -o ${appUser()} -g ${appGroupExpr()} -m 0750 ${CONFIG.backupDir}" ]
|
|
648
|
-
- [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
|
|
649
|
-
`;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
569
|
// The addresses a re-point left behind and this run still owes a release on
|
|
653
570
|
// (task 1003237). The one guard this tier can make on its own, and the one that
|
|
654
571
|
// matters: the address the instance is CURRENTLY on is never released, so a row
|
|
@@ -754,10 +671,6 @@ async function domainAttachInstance(inst, deps) {
|
|
|
754
671
|
log(` instance is '${inst.status}' — nothing to wire now (provision owns pre-active domains)`);
|
|
755
672
|
return { ok: true, noop: true };
|
|
756
673
|
}
|
|
757
|
-
if (inst.hosting_shape === 'dedicated') {
|
|
758
|
-
return { ok: false, error: `domain-attach for a dedicated instance is not automated yet — point an A record for ${inst.domain} at the droplet's IP and reload its Caddy` };
|
|
759
|
-
}
|
|
760
|
-
|
|
761
674
|
// DNS preflight (the task-1980 stance): verify token reach BEFORE touching the box.
|
|
762
675
|
if (CONFIG.dnsHook && apply) {
|
|
763
676
|
const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
|
|
@@ -766,7 +679,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
766
679
|
}
|
|
767
680
|
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
768
681
|
}
|
|
769
|
-
const ingressGap = ingressPreflight({ exec: boxExec, apply, log,
|
|
682
|
+
const ingressGap = ingressPreflight({ exec: boxExec, apply, log, probe: deps.ingressPlaneGap, before: 're-writing the Caddy snippet' });
|
|
770
683
|
if (ingressGap) return { ok: false, error: ingressGap }; // Ingress preflight (task 1004017, ADR 0315)
|
|
771
684
|
const caddyCheck = deps.checkCaddyWiring || checkCaddyWiring; // Caddy preflight (task 1002860, ADR 0303)
|
|
772
685
|
if (apply) {
|
|
@@ -891,13 +804,6 @@ async function settingsApplyInstance(inst, deps) {
|
|
|
891
804
|
log(` instance is '${inst.status}' — nothing to push now (standup writes the same values)`);
|
|
892
805
|
return { ok: true, noop: true };
|
|
893
806
|
}
|
|
894
|
-
// A dedicated droplet is stood up by cloud-init and the runner has no post-hoc
|
|
895
|
-
// exec path to it (the placeManifestCreds refusal) — say so rather than report a
|
|
896
|
-
// push that never landed. The route refuses to enqueue for this shape too.
|
|
897
|
-
if (inst.hosting_shape === 'dedicated') {
|
|
898
|
-
return { ok: false, error: `settings push is not automated for a dedicated droplet — set ${shown} in its ${webEnvPath(inst)} and restart the ${inst.slug} service there` };
|
|
899
|
-
}
|
|
900
|
-
|
|
901
807
|
// Read the file as it stands so ONLY the settings lines change. Dry-run runs no
|
|
902
808
|
// command, so it has no file to patch — it PLANS the patch instead of writing a
|
|
903
809
|
// synthetic two-line body (which would read as the exact overwrite this leg
|
|
@@ -1003,11 +909,6 @@ async function restartInstance(inst, deps) {
|
|
|
1003
909
|
log(` instance is '${inst.status}' — nothing running to restart`);
|
|
1004
910
|
return { ok: true, noop: true };
|
|
1005
911
|
}
|
|
1006
|
-
// A dedicated droplet has no runner exec path (the placeManifestCreds refusal).
|
|
1007
|
-
// The route refuses to enqueue for this shape too; this is the second gate.
|
|
1008
|
-
if (inst.hosting_shape === 'dedicated') {
|
|
1009
|
-
return { ok: false, error: `restart is not automated for a dedicated droplet — run 'systemctl restart ${inst.slug}' on that machine` };
|
|
1010
|
-
}
|
|
1011
912
|
if (!apply) {
|
|
1012
913
|
pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
|
|
1013
914
|
boxExec(healthzCmd(inst), { allowFail: true });
|
|
@@ -1115,14 +1016,9 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
|
|
|
1115
1016
|
|
|
1116
1017
|
// Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
|
|
1117
1018
|
// verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
|
|
1118
|
-
// or remote-cohost) and standalone (control-plane-local) are handled identically.
|
|
1119
|
-
// dedicated droplet bakes web.env into cloud-init and has no post-hoc exec path here, so
|
|
1120
|
-
// it is refused with guidance. Returns { ok, appId, appSlug } | { ok:false, error }.
|
|
1019
|
+
// or remote-cohost) and standalone (control-plane-local) are handled identically. Returns { ok, appId, appSlug } | { ok:false, error }.
|
|
1121
1020
|
async function placeManifestCreds(inst, mrow, deps) {
|
|
1122
1021
|
const { log, apply } = deps;
|
|
1123
|
-
if (inst.hosting_shape === 'dedicated') {
|
|
1124
|
-
return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
|
|
1125
|
-
}
|
|
1126
1022
|
if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
|
|
1127
1023
|
const standalone = inst.hosting_shape === 'cloud-host';
|
|
1128
1024
|
const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
|
|
@@ -1324,7 +1220,7 @@ async function cmdRunIntents(deps) {
|
|
|
1324
1220
|
|
|
1325
1221
|
// A light drift reconcile: surface instances stuck mid-flight (provisioning /
|
|
1326
1222
|
// tearing_down) with no open intent — a runner died mid-op. Reports; --apply
|
|
1327
|
-
// re-enqueues a fresh intent so the next tick retries. (Full
|
|
1223
|
+
// re-enqueues a fresh intent so the next tick retries. (Full box-vs-DB drift
|
|
1328
1224
|
// is a later hardening.)
|
|
1329
1225
|
async function cmdReconcile(deps) {
|
|
1330
1226
|
const { db, provisioning, log, apply } = deps;
|
|
@@ -1421,8 +1317,8 @@ async function main() {
|
|
|
1421
1317
|
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1422
1318
|
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
1423
1319
|
|
|
1424
|
-
let pool, provisioning,
|
|
1425
|
-
try { ({ pool, provisioning,
|
|
1320
|
+
let pool, provisioning, platformIdentity, hubKeys, projectCatalog;
|
|
1321
|
+
try { ({ pool, provisioning, platformIdentity, hubKeys, projectCatalog } = loadDeps()); }
|
|
1426
1322
|
catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
|
|
1427
1323
|
|
|
1428
1324
|
// Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
|
|
@@ -1433,14 +1329,11 @@ async function main() {
|
|
|
1433
1329
|
if (CONFIG.remoteHost && !CONFIG.remoteCwd) { console.error('PROVISION_REMOTE_HOST is set but PROVISION_REMOTE_CHECKOUT is not — name the co-hosting box\'s checkout dir (the old hardcoded default was removed, task 1002756).'); process.exit(2); }
|
|
1434
1330
|
const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
|
|
1435
1331
|
const controlExec = makeExec({ apply });
|
|
1436
|
-
let doApiClient = null;
|
|
1437
1332
|
const deps = {
|
|
1438
1333
|
db: pool, provisioning, apply, privileged: CONFIG.privileged, log: console.log,
|
|
1439
1334
|
exec, controlExec,
|
|
1440
1335
|
writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
|
|
1441
1336
|
controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
|
|
1442
|
-
// Built on first READ (task 1002718 F6): an --apply tick whose intents never call DO needs no DO_API_TOKEN.
|
|
1443
|
-
get doApi() { return DoApi ? (doApiClient = doApiClient || new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) })) : null; },
|
|
1444
1337
|
// Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
|
|
1445
1338
|
// dormant; shouldFederate() then skips and the instance owns its auth (as today).
|
|
1446
1339
|
platformIdentity, hubKeys,
|
|
@@ -1484,7 +1377,7 @@ module.exports = {
|
|
|
1484
1377
|
// private-repo deploy key (ADR 0176, task 1002735)
|
|
1485
1378
|
resolveOwnerGithubToken, deployKeyTitle, deployKeyPath, ensurePrivateRepoAccess,
|
|
1486
1379
|
zoneForDomain, dnsUpsertEnv, classifyZoneScope, checkDnsTokenScope,
|
|
1487
|
-
caddyBlock, caddySnippetPath, healthzCmd,
|
|
1380
|
+
caddyBlock, caddySnippetPath, healthzCmd, nextFreePort,
|
|
1488
1381
|
// executors + orchestration (injectable deps for tests)
|
|
1489
1382
|
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1490
1383
|
domainAttachInstance, releasableDomains, cmdRunIntents, cmdReconcile, cmdStatus,
|
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.17'; // 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');
|