@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.
@@ -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
- const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', `api:archon:${req.builder.id}`);
275
- if (created) {
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
- const { intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self');
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
- const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', 'api:self');
420
- if (created) {
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', actor: 'api:self',
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.59",
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.59",
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.59",
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",
@@ -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
- const result = intent.action === 'provision'
1739
- ? await provisionInstance(inst, deps)
1740
- : await teardownInstance(inst, deps);
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.59'; // CI auto-patch carrier (ADR 0161); see changelog above
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');
@@ -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);