@bongos/core 1.19.59 → 1.19.61
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 +37 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +104 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/migrations/provisioning_005_domain_attach.sql +20 -0
- package/modules/provisioning/provisioning.js +67 -5
- package/modules/provisioning/routes/provisioning.js +126 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision.js +92 -4
- package/src/module-api.js +5 -1
- package/tests/provision.mjs +134 -0
- package/tests/provisioning_domain_attach.mjs +289 -0
- package/tests/provisioning_teardown_intent.mjs +267 -0
|
@@ -271,16 +271,22 @@ module.exports = function provisioningRoutes() {
|
|
|
271
271
|
if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
|
|
272
272
|
return res.fail('not_teardownable', 409, { status: inst.status });
|
|
273
273
|
}
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
// Same truthful outcome mapping as the owner's teardown (task 1002691).
|
|
275
|
+
const r = await provisioning.enqueueTeardown(pool, id, `api:archon:${req.builder.id}`);
|
|
276
|
+
if (r.conflict) {
|
|
277
|
+
return res.fail('intent_conflict', { status: 409,
|
|
278
|
+
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
279
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
280
|
+
}
|
|
281
|
+
if (r.created) {
|
|
276
282
|
await provisioning.recordEvent(pool, {
|
|
277
283
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
|
|
278
|
-
detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}`,
|
|
284
|
+
detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}${r.superseded ? ' (superseded a stale provision intent)' : ''}`,
|
|
279
285
|
actor: `api:archon:${req.builder.id}`,
|
|
280
286
|
}).catch(() => {});
|
|
281
287
|
}
|
|
282
|
-
res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
|
|
283
|
-
message: created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
288
|
+
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
289
|
+
message: r.created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
284
290
|
} catch (err) {
|
|
285
291
|
console.error('[provisioning] POST /provisioning/instances/:id/force-teardown', err);
|
|
286
292
|
res.fail('teardown_failed', { status: 500, message: 'internal error' });
|
|
@@ -338,8 +344,16 @@ module.exports = function provisioningRoutes() {
|
|
|
338
344
|
if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
|
|
339
345
|
return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
|
|
340
346
|
}
|
|
341
|
-
// Enqueue the provision intent (idempotent: an already-open intent is returned)
|
|
342
|
-
|
|
347
|
+
// Enqueue the provision intent (idempotent: an already-open intent is returned)
|
|
348
|
+
// — but ONLY when the instance actually needs standing up. Blindly enqueueing on
|
|
349
|
+
// a re-request of an ACTIVE instance left a stale open provision intent that then
|
|
350
|
+
// blocked the owner's teardown on the action-blind open-intent index (task 1002691).
|
|
351
|
+
let intent = null, intentCreated = false;
|
|
352
|
+
if (created || provisioning.PROVISION_ENQUEUE_FROM.has(instance.status)) {
|
|
353
|
+
({ intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self'));
|
|
354
|
+
} else {
|
|
355
|
+
intent = await provisioning.getOpenIntent(pool, instance.id);
|
|
356
|
+
}
|
|
343
357
|
if (created) {
|
|
344
358
|
await provisioning.recordEvent(pool, {
|
|
345
359
|
instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
|
|
@@ -347,11 +361,24 @@ module.exports = function provisioningRoutes() {
|
|
|
347
361
|
actor: 'api:self',
|
|
348
362
|
}).catch(() => {});
|
|
349
363
|
}
|
|
364
|
+
// A re-request cannot change an existing instance's domain — SAY so instead of
|
|
365
|
+
// silently dropping it (task 1002692); the domain route is the real path.
|
|
366
|
+
let domainIgnored;
|
|
367
|
+
if (!created && body.domain) {
|
|
368
|
+
const asked = provisioning.normalizeHostname(body.domain);
|
|
369
|
+
if (asked && asked !== provisioning.normalizeHostname(instance.domain)) {
|
|
370
|
+
domainIgnored = {
|
|
371
|
+
requested: asked, current: instance.domain || null,
|
|
372
|
+
hint: `a re-request never changes an existing instance's address — POST /provisioning/instances/${instance.id}/domain instead`,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
350
376
|
res.status(created ? 201 : 200).json({
|
|
351
377
|
instance: provisioning.publicInstance(instance),
|
|
352
378
|
open_intent: intent ? { action: intent.action, state: intent.state } : null,
|
|
353
379
|
queued: intentCreated,
|
|
354
380
|
created,
|
|
381
|
+
...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
|
|
355
382
|
});
|
|
356
383
|
} catch (err) {
|
|
357
384
|
console.error('[provisioning] POST /provisioning/instances', err);
|
|
@@ -416,21 +443,108 @@ module.exports = function provisioningRoutes() {
|
|
|
416
443
|
if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
|
|
417
444
|
return res.fail('not_teardownable', { status: 409, message: `Instance is '${inst.status}' — nothing to tear down right now.`, details: { status: inst.status } });
|
|
418
445
|
}
|
|
419
|
-
|
|
420
|
-
|
|
446
|
+
// enqueueTeardown supersedes a stale pending provision intent and reports each
|
|
447
|
+
// outcome distinctly, so "already queued" is only ever said when it is TRUE
|
|
448
|
+
// (task 1002691 — the old blind enqueue lied here and the instance leaked).
|
|
449
|
+
const r = await provisioning.enqueueTeardown(pool, id, 'api:self');
|
|
450
|
+
if (r.conflict) {
|
|
451
|
+
return res.fail('intent_conflict', { status: 409,
|
|
452
|
+
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
453
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
454
|
+
}
|
|
455
|
+
if (r.created) {
|
|
421
456
|
await provisioning.recordEvent(pool, {
|
|
422
457
|
instanceId: id, ownerBuilderId: req.builder.id, event: 'teardown',
|
|
423
|
-
detail: 'teardown requested by owner'
|
|
458
|
+
detail: r.superseded ? 'teardown requested by owner (superseded a stale provision intent)' : 'teardown requested by owner',
|
|
459
|
+
actor: 'api:self',
|
|
424
460
|
}).catch(() => {});
|
|
425
461
|
}
|
|
426
|
-
res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
|
|
427
|
-
message: created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
462
|
+
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
463
|
+
message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
428
464
|
} catch (err) {
|
|
429
465
|
console.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
|
|
430
466
|
res.fail('teardown_failed', { status: 500, message: 'internal error' });
|
|
431
467
|
}
|
|
432
468
|
});
|
|
433
469
|
|
|
470
|
+
// POST /provisioning/instances/:id/domain — give the caller's OWN instance its
|
|
471
|
+
// address (task 1002692, gap 5: no path existed to add a domain after creation).
|
|
472
|
+
// Validates + records the domain; for an already-ACTIVE instance it also enqueues
|
|
473
|
+
// a 'domain-attach' intent the control-plane runner drains (web tier holds no
|
|
474
|
+
// cloud tokens — ADR 0111 §2). A requested/error instance only records it: the
|
|
475
|
+
// (re)provision run wires the domain itself. Re-pointing an existing address is
|
|
476
|
+
// out of scope — refuse truthfully. Body: { domain (required) }.
|
|
477
|
+
router.post('/provisioning/instances/:id/domain', auth.requireBuilder, async (req, res) => {
|
|
478
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
479
|
+
if (!id) return;
|
|
480
|
+
if (validateOrRespond(req, res, { domain: { required: true, type: 'string', maxLength: 253 } })) return;
|
|
481
|
+
try {
|
|
482
|
+
const domain = provisioning.normalizeHostname((req.body || {}).domain);
|
|
483
|
+
if (!domain) {
|
|
484
|
+
return res.fail('bad_domain', 400, { reason: 'a real multi-label hostname (e.g. myproject.example.com) — no scheme, path, or bare TLD' });
|
|
485
|
+
}
|
|
486
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
487
|
+
if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
|
|
488
|
+
return res.fail('instance_not_found', 404);
|
|
489
|
+
}
|
|
490
|
+
const current = provisioning.normalizeHostname(inst.domain);
|
|
491
|
+
if (current && current === domain) {
|
|
492
|
+
return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
|
|
493
|
+
message: 'This instance already has that address.' });
|
|
494
|
+
}
|
|
495
|
+
if (current) {
|
|
496
|
+
return res.fail('domain_already_set', { status: 409,
|
|
497
|
+
message: `This instance is already addressed as '${inst.domain}' — re-pointing an existing address is not supported here.`,
|
|
498
|
+
details: { current: inst.domain } });
|
|
499
|
+
}
|
|
500
|
+
const holder = await provisioning.getInstanceByDomain(pool, domain);
|
|
501
|
+
if (holder && String(holder.id) !== String(inst.id)) {
|
|
502
|
+
return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
|
|
503
|
+
}
|
|
504
|
+
if (!provisioning.DOMAIN_ATTACH_FROM.has(inst.status)) {
|
|
505
|
+
return res.fail('domain_not_attachable', { status: 409,
|
|
506
|
+
message: `Instance is '${inst.status}' — an address can be attached once it settles ('active') or before standup ('requested').`,
|
|
507
|
+
details: { status: inst.status } });
|
|
508
|
+
}
|
|
509
|
+
// Record BEFORE enqueueing: the runner reads the row's domain, so the intent
|
|
510
|
+
// must never race ahead of the column it wires.
|
|
511
|
+
await provisioning.updateInstanceDomain(pool, id, domain);
|
|
512
|
+
let queued = false;
|
|
513
|
+
if (inst.status === 'active') {
|
|
514
|
+
const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
|
|
515
|
+
if (!created && intent.action !== 'domain-attach') {
|
|
516
|
+
// The open slot is held by other work (e.g. a teardown). The address IS
|
|
517
|
+
// recorded — say exactly what happened and what to do.
|
|
518
|
+
return res.fail('intent_conflict', { status: 409,
|
|
519
|
+
message: `The address was recorded, but a '${intent.action}' run is currently ${intent.state} for this instance — re-send this request once it settles to wire the address.`,
|
|
520
|
+
details: { action: intent.action, state: intent.state, recorded: true } });
|
|
521
|
+
}
|
|
522
|
+
queued = created;
|
|
523
|
+
if (!created) {
|
|
524
|
+
// an identical attach is already open — idempotent
|
|
525
|
+
await provisioning.recordEvent(pool, {
|
|
526
|
+
instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
|
|
527
|
+
detail: `domain ${domain} recorded (attach already queued)`, actor: 'api:self',
|
|
528
|
+
}).catch(() => {});
|
|
529
|
+
return res.json({ ok: true, changed: true, queued: false, domain,
|
|
530
|
+
message: 'That address is already being wired for this instance.' });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
await provisioning.recordEvent(pool, {
|
|
534
|
+
instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
|
|
535
|
+
detail: queued ? `domain ${domain} recorded + attach queued` : `domain ${domain} recorded (wired at provision)`,
|
|
536
|
+
actor: 'api:self',
|
|
537
|
+
}).catch(() => {});
|
|
538
|
+
res.json({ ok: true, changed: true, queued, domain,
|
|
539
|
+
message: queued
|
|
540
|
+
? 'Your instance is being wired to its new address — it will answer there shortly.'
|
|
541
|
+
: 'Address recorded — it will be wired when the instance is provisioned.' });
|
|
542
|
+
} catch (err) {
|
|
543
|
+
console.error('[provisioning] POST /provisioning/instances/:id/domain', err);
|
|
544
|
+
res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
434
548
|
// POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding
|
|
435
549
|
// link for the caller's OWN instance (task 2080, ADR 0133). rank: any authenticated
|
|
436
550
|
// builder (own resource). Returns the pre-filled manifest + the GitHub form-POST target
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.61",
|
|
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.61",
|
|
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.61",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1510,6 +1510,90 @@ runcmd:
|
|
|
1510
1510
|
`;
|
|
1511
1511
|
}
|
|
1512
1512
|
|
|
1513
|
+
// ---------------------------------------------------------------------------
|
|
1514
|
+
// Attach a late-arriving domain to an ALREADY-ACTIVE instance (task 1002692):
|
|
1515
|
+
// re-assert exactly the domain-dependent runbook legs — web.env origins (+ a
|
|
1516
|
+
// service RESTART so the process re-reads them), the Cloudflare A-record UPSERT
|
|
1517
|
+
// (step 5), the Caddy site snippet (step 6) — then healthz. Expected failures
|
|
1518
|
+
// RETURN { ok:false } instead of throwing: the drain loop's catch flips the
|
|
1519
|
+
// instance to 'error', which must never happen to a healthy ACTIVE instance
|
|
1520
|
+
// over a domain hiccup.
|
|
1521
|
+
// ---------------------------------------------------------------------------
|
|
1522
|
+
async function domainAttachInstance(inst, deps) {
|
|
1523
|
+
const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
|
|
1524
|
+
const controlExec = deps.controlExec || exec;
|
|
1525
|
+
// Same box routing as provisionInstance: a standalone runs control-plane-local
|
|
1526
|
+
// (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
|
|
1527
|
+
const standalone = inst.hosting_shape === 'standalone';
|
|
1528
|
+
const boxExec = standalone ? controlExec : exec;
|
|
1529
|
+
const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
|
|
1530
|
+
const sudoP = privileged ? 'sudo ' : '';
|
|
1531
|
+
log(`domain-attach ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status}, domain=${inst.domain || '-'})`);
|
|
1532
|
+
|
|
1533
|
+
if (!inst.domain) return { ok: false, error: 'no domain recorded on the instance — nothing to wire' };
|
|
1534
|
+
// Pre-active states are the provision run's job; post-active states have nothing
|
|
1535
|
+
// to address. A clean no-op (not an error) so a raced teardown/re-request never
|
|
1536
|
+
// burns the intent's retry budget.
|
|
1537
|
+
if (inst.status !== 'active') {
|
|
1538
|
+
log(` instance is '${inst.status}' — nothing to wire now (provision owns pre-active domains)`);
|
|
1539
|
+
return { ok: true, noop: true };
|
|
1540
|
+
}
|
|
1541
|
+
if (inst.hosting_shape === 'dedicated') {
|
|
1542
|
+
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` };
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// DNS preflight (the task-1980 stance): verify token reach BEFORE touching the box.
|
|
1546
|
+
if (CONFIG.dnsHook && apply) {
|
|
1547
|
+
const scope = await (deps.checkDnsTokenScope || checkDnsTokenScope)(inst.domain);
|
|
1548
|
+
if (!scope.reachable) {
|
|
1549
|
+
return { ok: false, error: `DNS preflight failed for ${inst.domain}: ${scope.reason}. ${scope.guidance}` };
|
|
1550
|
+
}
|
|
1551
|
+
log(` [preflight] DNS token can reach zone ${scope.zone} ✓`);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// The A-record target — the same derivation as provisionInstance step 1.
|
|
1555
|
+
const recordIp = standalone
|
|
1556
|
+
? (CONFIG.controlPlaneIp || (CONFIG.remoteHost ? null : CONFIG.publicIp))
|
|
1557
|
+
: CONFIG.publicIp;
|
|
1558
|
+
|
|
1559
|
+
log(' [config] re-write per-instance web.env (origins now carry the domain)');
|
|
1560
|
+
boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation: null }), { mode: 0o600, sudo: true });
|
|
1561
|
+
log(' [service] restart to pick up the new origins');
|
|
1562
|
+
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
1563
|
+
|
|
1564
|
+
// DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
|
|
1565
|
+
// the hook needs the Cloudflare token, which never leaves the control plane).
|
|
1566
|
+
if (CONFIG.dnsHook) {
|
|
1567
|
+
if (!recordIp) {
|
|
1568
|
+
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
1569
|
+
if (apply) return { ok: false, error: `cannot UPSERT DNS for ${inst.domain}: no target IP — set ${ipEnvHint} in the control-plane box.env` };
|
|
1570
|
+
log(` [dns] would upsert A ${inst.domain} once the target IP is known (set ${ipEnvHint})`);
|
|
1571
|
+
} else {
|
|
1572
|
+
const dnsEnv = dnsUpsertEnv(inst, { publicIp: recordIp, proxied: standalone });
|
|
1573
|
+
log(` [dns] upsert A ${inst.domain} -> ${recordIp} (zone ${dnsEnv.CLOUDFLARE_ZONE}, proxied=${dnsEnv.BOX_PROXIED === '1'}) via ${CONFIG.dnsHook}`);
|
|
1574
|
+
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
1575
|
+
}
|
|
1576
|
+
} else {
|
|
1577
|
+
log(' [dns] skipped (BOX_DNS_HOOK unset) — point the A record manually');
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
log(' [caddy] write site snippet + reload');
|
|
1581
|
+
boxWriteFile(caddySnippetPath(inst), caddyBlock(inst), { mode: 0o644, sudo: true });
|
|
1582
|
+
boxExec(`${sudoP}systemctl reload caddy`, { allowFail: true });
|
|
1583
|
+
|
|
1584
|
+
log(' [healthz] verify the instance answers');
|
|
1585
|
+
boxExec(healthzCmd(inst), { allowFail: true });
|
|
1586
|
+
|
|
1587
|
+
if (apply) {
|
|
1588
|
+
await provisioning.recordEvent(db, {
|
|
1589
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
|
|
1590
|
+
detail: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`, actor: 'runner',
|
|
1591
|
+
}).catch(() => {});
|
|
1592
|
+
}
|
|
1593
|
+
log(` ✓ ${inst.slug} addressed as ${inst.domain}`);
|
|
1594
|
+
return { ok: true };
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1513
1597
|
// ---------------------------------------------------------------------------
|
|
1514
1598
|
// Teardown one instance (reverse the runbook; idempotent).
|
|
1515
1599
|
// ---------------------------------------------------------------------------
|
|
@@ -1735,9 +1819,13 @@ async function cmdRunIntents(deps) {
|
|
|
1735
1819
|
try {
|
|
1736
1820
|
const inst = await provisioning.getInstanceById(db, intent.instance_id);
|
|
1737
1821
|
if (!inst) { await provisioning.resolveIntent(db, intent.id, 'error', 'instance row vanished'); errors++; continue; }
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1822
|
+
// Dispatch EXPLICITLY per action — an unrecognized action must surface as an
|
|
1823
|
+
// error, never fall through to the destructive teardown leg (task 1002692).
|
|
1824
|
+
let result;
|
|
1825
|
+
if (intent.action === 'provision') result = await provisionInstance(inst, deps);
|
|
1826
|
+
else if (intent.action === 'teardown') result = await teardownInstance(inst, deps);
|
|
1827
|
+
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1828
|
+
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
1741
1829
|
if (result && result.ok === false) {
|
|
1742
1830
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
1743
1831
|
else await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
|
|
@@ -1907,7 +1995,7 @@ module.exports = {
|
|
|
1907
1995
|
caddyBlock, caddySnippetPath, healthzCmd, dedicatedUserData, nextFreePort,
|
|
1908
1996
|
// executors + orchestration (injectable deps for tests)
|
|
1909
1997
|
makeExec, makeWriteFile, allocatePort, boundLocalPorts, provisionInstance, teardownInstance,
|
|
1910
|
-
cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1998
|
+
domainAttachInstance, cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1911
1999
|
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1912
2000
|
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1913
2001
|
// cross-site identity federation (ADR 0141 §4, task #1002109)
|
package/src/module-api.js
CHANGED
|
@@ -545,8 +545,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
545
545
|
// landed since 1.19.57 with no explicit bump. run 31546064883. (task 1002620)
|
|
546
546
|
// 1.19.59 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
547
547
|
// landed since 1.19.58 with no explicit bump. run 31547103265. (task 1002620)
|
|
548
|
+
// 1.19.60 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
549
|
+
// landed since 1.19.59 with no explicit bump. run 31548021746. (task 1002620)
|
|
550
|
+
// 1.19.61 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
551
|
+
// landed since 1.19.60 with no explicit bump. run 31550061440. (task 1002620)
|
|
548
552
|
// ---------------------------------------------------------------------------
|
|
549
|
-
const CORE_VERSION = '1.19.
|
|
553
|
+
const CORE_VERSION = '1.19.61'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
550
554
|
|
|
551
555
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
552
556
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/provision.mjs
CHANGED
|
@@ -1421,5 +1421,139 @@ await ta('provisionInstance(standalone, hub-configured): still NOT federated —
|
|
|
1421
1421
|
});
|
|
1422
1422
|
});
|
|
1423
1423
|
|
|
1424
|
+
console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its late-arriving address):');
|
|
1425
|
+
|
|
1426
|
+
// Recorder for the attach leg: captures commands (with env), file writes (with
|
|
1427
|
+
// bodies), and recorded events. No status writes to capture — domain-attach must
|
|
1428
|
+
// never touch the instance lifecycle.
|
|
1429
|
+
function attachRecorder({ apply = false } = {}) {
|
|
1430
|
+
const cmds = [], envs = [], writes = [], events = [];
|
|
1431
|
+
const deps = {
|
|
1432
|
+
apply, log: () => {},
|
|
1433
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1434
|
+
provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
|
|
1435
|
+
exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
|
|
1436
|
+
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1437
|
+
doApi: null,
|
|
1438
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1439
|
+
};
|
|
1440
|
+
return { deps, cmds, envs, writes, events };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const activeStandalone = {
|
|
1444
|
+
id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
|
|
1445
|
+
port: 3010, db_name: 'spike', domain: 'spike.cloudbongos.com', target_ref: 'jaxri/spike-harbor',
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
await ta('domainAttachInstance(active standalone): env re-write → restart → DNS upsert → caddy → healthz', async () => {
|
|
1449
|
+
const savedPub = P.CONFIG.publicIp, savedHook = P.CONFIG.dnsHook;
|
|
1450
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1451
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1452
|
+
try {
|
|
1453
|
+
const { deps, cmds, envs, writes } = attachRecorder();
|
|
1454
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1455
|
+
assert.equal(r.ok, true);
|
|
1456
|
+
const env = writes.find((w) => /\/spike\/web\.env$/.test(w.path));
|
|
1457
|
+
assert.ok(env, 're-writes web.env');
|
|
1458
|
+
assert.match(env.body, /https:\/\/spike\.cloudbongos\.com/, 'the new address reaches the origins');
|
|
1459
|
+
const joined = cmds.join('\n');
|
|
1460
|
+
assert.match(joined, /systemctl restart spike/, 'RESTART (not reload) so the process re-reads its env');
|
|
1461
|
+
const di = cmds.indexOf('/opt/infra/box-dns-cloudflare.sh');
|
|
1462
|
+
assert.ok(di >= 0, 'DNS hook invoked by path');
|
|
1463
|
+
assert.equal(envs[di].BOX_HOSTNAME, 'spike.cloudbongos.com');
|
|
1464
|
+
assert.equal(envs[di].BOX_PROXIED, '1', 'standalone rides the CF proxy (task 2077)');
|
|
1465
|
+
assert.ok(writes.some((w) => /\/spike\.caddy$/.test(w.path)), 'writes the Caddy site snippet');
|
|
1466
|
+
assert.match(joined, /reload caddy/);
|
|
1467
|
+
assert.match(joined, /healthz/);
|
|
1468
|
+
const idx = (re) => cmds.findIndex((c) => re.test(c));
|
|
1469
|
+
assert.ok(idx(/restart spike/) < idx(/reload caddy/), 'service restart before caddy reload');
|
|
1470
|
+
} finally { P.CONFIG.publicIp = savedPub; P.CONFIG.dnsHook = savedHook; }
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
await ta('domainAttachInstance: no domain recorded → ok:false, nothing runs', async () => {
|
|
1474
|
+
const { deps, cmds } = attachRecorder();
|
|
1475
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, domain: null }, deps);
|
|
1476
|
+
assert.equal(r.ok, false);
|
|
1477
|
+
assert.match(r.error, /no domain/i);
|
|
1478
|
+
assert.equal(cmds.length, 0);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
await ta('domainAttachInstance: instance no longer active → clean no-op (provision owns pre-active wiring; never burns the retry budget)', async () => {
|
|
1482
|
+
for (const status of ['requested', 'provisioning', 'tearing_down', 'torn_down', 'error']) {
|
|
1483
|
+
const { deps, cmds } = attachRecorder();
|
|
1484
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, status }, deps);
|
|
1485
|
+
assert.equal(r.ok, true, status);
|
|
1486
|
+
assert.equal(r.noop, true, status);
|
|
1487
|
+
assert.equal(cmds.length, 0, `${status}: nothing ran`);
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
await ta('domainAttachInstance: dedicated is not automated — ok:false with guidance, box untouched', async () => {
|
|
1492
|
+
const { deps, cmds } = attachRecorder();
|
|
1493
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, hosting_shape: 'dedicated' }, deps);
|
|
1494
|
+
assert.equal(r.ok, false);
|
|
1495
|
+
assert.match(r.error, /dedicated/);
|
|
1496
|
+
assert.equal(cmds.length, 0);
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
await ta('domainAttachInstance(--apply): a DNS preflight failure returns ok:false — never THROWS (a throw would flip the ACTIVE instance to error in the drain loop)', async () => {
|
|
1500
|
+
const savedHook = P.CONFIG.dnsHook; P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1501
|
+
try {
|
|
1502
|
+
const { deps, cmds } = attachRecorder({ apply: true });
|
|
1503
|
+
deps.checkDnsTokenScope = async () => ({ reachable: false, zone: 'z', reason: 'not in scope', guidance: 'widen the token' });
|
|
1504
|
+
const r = await P.domainAttachInstance(activeStandalone, deps);
|
|
1505
|
+
assert.equal(r.ok, false);
|
|
1506
|
+
assert.match(r.error, /preflight.*not in scope/);
|
|
1507
|
+
assert.ok(!cmds.some((c) => /restart/.test(c)), 'stops before touching the service');
|
|
1508
|
+
} finally { P.CONFIG.dnsHook = savedHook; }
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
await ta('cmdRunIntents (--apply) routes a domain-attach intent to the attach leg and resolves it done', async () => {
|
|
1512
|
+
let claims = 0; const resolved = [];
|
|
1513
|
+
const deps = {
|
|
1514
|
+
apply: true, log: () => {},
|
|
1515
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1516
|
+
provisioning: {
|
|
1517
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 6, instance_id: 18, action: 'domain-attach', attempts: 1 } : null),
|
|
1518
|
+
getInstanceById: async () => ({ ...activeStandalone }),
|
|
1519
|
+
resolveIntent: async (_db, id, state) => { resolved.push({ id, state }); },
|
|
1520
|
+
setInstanceStatus: async () => {},
|
|
1521
|
+
recordEvent: async () => {},
|
|
1522
|
+
claimNextOAuthManifest: async () => null,
|
|
1523
|
+
resolveOAuthManifest: async () => {},
|
|
1524
|
+
},
|
|
1525
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
1526
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1527
|
+
};
|
|
1528
|
+
const r = await P.cmdRunIntents(deps);
|
|
1529
|
+
assert.equal(r.ran, 1);
|
|
1530
|
+
assert.equal(r.ok, 1);
|
|
1531
|
+
assert.deepEqual(resolved, [{ id: 6, state: 'done' }]);
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through to teardown', async () => {
|
|
1535
|
+
let claims = 0; const resolved = []; const cmds = [];
|
|
1536
|
+
const deps = {
|
|
1537
|
+
apply: true, log: () => {},
|
|
1538
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1539
|
+
provisioning: {
|
|
1540
|
+
claimNextIntent: async () => (claims++ === 0 ? { id: 7, instance_id: 18, action: 'oauth-manifest', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
1541
|
+
getInstanceById: async () => ({ ...activeStandalone }),
|
|
1542
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
1543
|
+
setInstanceStatus: async () => {},
|
|
1544
|
+
recordEvent: async () => {},
|
|
1545
|
+
claimNextOAuthManifest: async () => null,
|
|
1546
|
+
resolveOAuthManifest: async () => {},
|
|
1547
|
+
},
|
|
1548
|
+
exec: (c) => { cmds.push(c); return { ok: true }; },
|
|
1549
|
+
writeFile: () => ({ ok: true }), doApi: null,
|
|
1550
|
+
};
|
|
1551
|
+
const r = await P.cmdRunIntents(deps);
|
|
1552
|
+
assert.equal(r.errors, 1, 'surfaced as an error, not silently executed');
|
|
1553
|
+
assert.equal(resolved.length, 1);
|
|
1554
|
+
assert.equal(resolved[0].state, 'error');
|
|
1555
|
+
assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1424
1558
|
console.log(`\nprovision: ${passed} passed, ${failed} failed`);
|
|
1425
1559
|
process.exit(failed ? 1 : 0);
|