@bongos/core 1.19.127 → 1.19.129
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 +23 -18
- package/modules/provisioning/migrations/provisioning_008_dns_note.sql +15 -0
- package/modules/provisioning/provisioning.js +28 -1
- package/modules/provisioning/routes/provisioning.js +26 -1
- package/modules/provisioning/tests/provisioning.mjs +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +88 -18
- package/scripts/gds/provision.js +115 -19
- package/src/module-api.js +5 -1
- package/tests/provision.mjs +272 -6
- package/tests/provisioning_domain_attach.mjs +40 -0
- package/tests/public_landing_projects.mjs +33 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.129",
|
|
6
|
+
"core_contract": "1.19.129",
|
|
7
|
+
"source_commit": "4f6f1fe2d419e1f7a6b2877ce6d3b2f793308257",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-15T02:03:44.422Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 303,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1246,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1570,
|
|
20
|
+
"tree_sha256": "15adcb143cdb095a549345805c5491c08b64429b4ef6730ef4878344c15d2ea9",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -3844,6 +3844,11 @@
|
|
|
3844
3844
|
"mode": "0000644",
|
|
3845
3845
|
"sha256": "ddbc1f7e659e716fcbb3ba3ec6c1237756f46dbd895bcb27a1dc925a7c782e76"
|
|
3846
3846
|
},
|
|
3847
|
+
{
|
|
3848
|
+
"path": "modules/provisioning/migrations/provisioning_008_dns_note.sql",
|
|
3849
|
+
"mode": "0000644",
|
|
3850
|
+
"sha256": "8dc04a18ad77563a2bd73029bf13cab03fb5dadd1b62850fddbde475257e6a76"
|
|
3851
|
+
},
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/provisioning/module.json",
|
|
3849
3854
|
"mode": "0000644",
|
|
@@ -3862,7 +3867,7 @@
|
|
|
3862
3867
|
{
|
|
3863
3868
|
"path": "modules/provisioning/provisioning.js",
|
|
3864
3869
|
"mode": "0000644",
|
|
3865
|
-
"sha256": "
|
|
3870
|
+
"sha256": "90aa880898f6b3235e8b2a1a553695c63c29920968edb9c9af58364902118e33"
|
|
3866
3871
|
},
|
|
3867
3872
|
{
|
|
3868
3873
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -3872,7 +3877,7 @@
|
|
|
3872
3877
|
{
|
|
3873
3878
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
3874
3879
|
"mode": "0000644",
|
|
3875
|
-
"sha256": "
|
|
3880
|
+
"sha256": "eddf451d5ee5085563af258b5c605c445776c9e226587341f9b4b8b405492240"
|
|
3876
3881
|
},
|
|
3877
3882
|
{
|
|
3878
3883
|
"path": "modules/provisioning/tests/liveness.mjs",
|
|
@@ -3882,7 +3887,7 @@
|
|
|
3882
3887
|
{
|
|
3883
3888
|
"path": "modules/provisioning/tests/provisioning.mjs",
|
|
3884
3889
|
"mode": "0000644",
|
|
3885
|
-
"sha256": "
|
|
3890
|
+
"sha256": "0dc18db288580ca00792194ce5f50a13aae8c445c14d7fc42a2095ab2379c93f"
|
|
3886
3891
|
},
|
|
3887
3892
|
{
|
|
3888
3893
|
"path": "modules/provisioning/ui/.gitkeep",
|
|
@@ -4167,12 +4172,12 @@
|
|
|
4167
4172
|
{
|
|
4168
4173
|
"path": "package-lock.json",
|
|
4169
4174
|
"mode": "0000644",
|
|
4170
|
-
"sha256": "
|
|
4175
|
+
"sha256": "113fa76500c41f49120cb6a76558c693e3862d5f7dbd754c6b6f9e604bd0d45e"
|
|
4171
4176
|
},
|
|
4172
4177
|
{
|
|
4173
4178
|
"path": "package.json",
|
|
4174
4179
|
"mode": "0000644",
|
|
4175
|
-
"sha256": "
|
|
4180
|
+
"sha256": "88877c77760c0d9d44361b8522fa2e871a8a62f85a500454acc9af972db8b00e"
|
|
4176
4181
|
},
|
|
4177
4182
|
{
|
|
4178
4183
|
"path": "public-docs/index.html",
|
|
@@ -4427,7 +4432,7 @@
|
|
|
4427
4432
|
{
|
|
4428
4433
|
"path": "public-landing/projects.html",
|
|
4429
4434
|
"mode": "0000644",
|
|
4430
|
-
"sha256": "
|
|
4435
|
+
"sha256": "ac866c928701c41ac7f055f8807590a3db71eb2538502a54a6fca9ac1d79868d"
|
|
4431
4436
|
},
|
|
4432
4437
|
{
|
|
4433
4438
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5082,7 +5087,7 @@
|
|
|
5082
5087
|
{
|
|
5083
5088
|
"path": "scripts/gds/provision.js",
|
|
5084
5089
|
"mode": "0000644",
|
|
5085
|
-
"sha256": "
|
|
5090
|
+
"sha256": "1925ed112ec553a95388b81cccc1fb003ba2a0e91a5a80c6e315b45895688d0f"
|
|
5086
5091
|
},
|
|
5087
5092
|
{
|
|
5088
5093
|
"path": "scripts/gds/publish-credential-check.js",
|
|
@@ -5757,7 +5762,7 @@
|
|
|
5757
5762
|
{
|
|
5758
5763
|
"path": "src/module-api.js",
|
|
5759
5764
|
"mode": "0000644",
|
|
5760
|
-
"sha256": "
|
|
5765
|
+
"sha256": "0d17e220c27abd9a9064c461404fa8fdf2f352f404e10673b76ff8a34ccd04c2"
|
|
5761
5766
|
},
|
|
5762
5767
|
{
|
|
5763
5768
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7297,7 +7302,7 @@
|
|
|
7297
7302
|
{
|
|
7298
7303
|
"path": "tests/provision.mjs",
|
|
7299
7304
|
"mode": "0000644",
|
|
7300
|
-
"sha256": "
|
|
7305
|
+
"sha256": "5297caf5005fd26c8435aa374511882a6c2d232e43f07cdc6ec19d1168ee1cc1"
|
|
7301
7306
|
},
|
|
7302
7307
|
{
|
|
7303
7308
|
"path": "tests/provisioning_default_domain.mjs",
|
|
@@ -7307,7 +7312,7 @@
|
|
|
7307
7312
|
{
|
|
7308
7313
|
"path": "tests/provisioning_domain_attach.mjs",
|
|
7309
7314
|
"mode": "0000644",
|
|
7310
|
-
"sha256": "
|
|
7315
|
+
"sha256": "856f434c8e1a8e8ad549c81689f1bbe27ff555e4ba08f9b485c3e9d46838a4a5"
|
|
7311
7316
|
},
|
|
7312
7317
|
{
|
|
7313
7318
|
"path": "tests/provisioning_github_app_routes.mjs",
|
|
@@ -7332,7 +7337,7 @@
|
|
|
7332
7337
|
{
|
|
7333
7338
|
"path": "tests/public_landing_projects.mjs",
|
|
7334
7339
|
"mode": "0000644",
|
|
7335
|
-
"sha256": "
|
|
7340
|
+
"sha256": "7f2fe0290fc0a429b54ca53d449453e9009a57e40bb8003b2718beef33be2309"
|
|
7336
7341
|
},
|
|
7337
7342
|
{
|
|
7338
7343
|
"path": "tests/public_mirror_export.mjs",
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- provisioning_008_dns_note.sql — transport the runner's "DNS was skipped" fact
|
|
2
|
+
-- to the owner's card (task 1002720 / QA-walk F16).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. With BOX_DNS_HOOK unset, domain-attach wires env + Caddy but SKIPS the
|
|
5
|
+
-- A-record upsert — a fact that lived only in the runner's log while the owner's
|
|
6
|
+
-- card promised the address "will answer shortly" (it never would). This column
|
|
7
|
+
-- carries the outstanding manual step as a plain sentence the card renders.
|
|
8
|
+
--
|
|
9
|
+
-- OVERLAY, NOT A STATE (the provisioning_006 rule): nullable, no default,
|
|
10
|
+
-- never written by the lifecycle. NULL = nothing outstanding. A later attach
|
|
11
|
+
-- with a working hook clears it.
|
|
12
|
+
ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS dns_note text;
|
|
13
|
+
|
|
14
|
+
INSERT INTO schema_migrations (version) VALUES ('provisioning_008_dns_note')
|
|
15
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -198,6 +198,9 @@ function publicInstance(row) {
|
|
|
198
198
|
// them differently ("live?" vs "unreachable"). Never collapse the two.
|
|
199
199
|
last_verified_at: row.last_verified_at || null,
|
|
200
200
|
last_verified_up: row.last_verified_up == null ? null : !!row.last_verified_up,
|
|
201
|
+
// The outstanding manual-DNS step, if the runner had no hook to point the
|
|
202
|
+
// A record (task 1002720 / F16). null = nothing outstanding.
|
|
203
|
+
dns_note: row.dns_note || null,
|
|
201
204
|
cost_estimate_usd: Number(row.cost_estimate_usd || 0),
|
|
202
205
|
error_note: row.error_note || null,
|
|
203
206
|
created_at: row.created_at,
|
|
@@ -372,6 +375,30 @@ async function recordLivenessProbe(db, id, up) {
|
|
|
372
375
|
return rows[0] || null;
|
|
373
376
|
}
|
|
374
377
|
|
|
378
|
+
// Record (or clear, with null) the outstanding manual-DNS sentence the card
|
|
379
|
+
// renders (task 1002720 / F16). Same narrowness rule as recordLivenessProbe:
|
|
380
|
+
// no `status`, no `updated_at` — the runner's lifecycle authorship and the
|
|
381
|
+
// owner's card ordering stay untouched.
|
|
382
|
+
async function recordDnsNote(db, id, note) {
|
|
383
|
+
const { rows } = await db.query(
|
|
384
|
+
`UPDATE provisioning_instances SET dns_note = $2 WHERE id = $1 RETURNING id, dns_note`,
|
|
385
|
+
[id, note || null]
|
|
386
|
+
);
|
|
387
|
+
return rows[0] || null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Clear the error_note latch (task 1002720 / F19). The retire leg of the intent
|
|
391
|
+
// runner sets it when work stops; a later SUCCESSFUL run must release it, or the
|
|
392
|
+
// card shows "we stopped trying" forever on a healed instance. Same narrowness
|
|
393
|
+
// rule: no status, no updated_at.
|
|
394
|
+
async function clearErrorNote(db, id) {
|
|
395
|
+
const { rows } = await db.query(
|
|
396
|
+
`UPDATE provisioning_instances SET error_note = NULL WHERE id = $1 RETURNING id`,
|
|
397
|
+
[id]
|
|
398
|
+
);
|
|
399
|
+
return rows[0] || null;
|
|
400
|
+
}
|
|
401
|
+
|
|
375
402
|
// The caller's own instances, newest first.
|
|
376
403
|
async function listInstancesForOwner(db, ownerBuilderId) {
|
|
377
404
|
const { rows } = await db.query(
|
|
@@ -701,7 +728,7 @@ module.exports = {
|
|
|
701
728
|
isProvisionedHostname,
|
|
702
729
|
getInstanceById, getInstanceBySlug, getInstanceByDomain, listInstancesForOwner, listAllInstances,
|
|
703
730
|
// data access — verification overlay (task 1002696)
|
|
704
|
-
listActiveInstancesForLiveness, recordLivenessProbe,
|
|
731
|
+
listActiveInstancesForLiveness, recordLivenessProbe, recordDnsNote, clearErrorNote,
|
|
705
732
|
createInstance, setInstanceStatus, updateInstanceDomain,
|
|
706
733
|
enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
|
|
707
734
|
recordEvent, instanceStateForSlug, costLedger,
|
|
@@ -589,6 +589,28 @@ module.exports = function provisioningRoutes() {
|
|
|
589
589
|
}
|
|
590
590
|
const current = provisioning.normalizeHostname(inst.domain);
|
|
591
591
|
if (current && current === domain) {
|
|
592
|
+
// Re-sending the SAME address is the owner's only re-kick when the
|
|
593
|
+
// wiring stopped (task 1002720 / F19) — the mirror of what POST
|
|
594
|
+
// /instances already does for a snagged standup. Only when the wiring
|
|
595
|
+
// DEMONSTRABLY needs it — a retired intent left its error_note, or the
|
|
596
|
+
// probe says the address doesn't answer — and nothing is in flight. A
|
|
597
|
+
// healthy instance (no note, verified up or unprobed) keeps the
|
|
598
|
+
// idempotent no-op below.
|
|
599
|
+
const needsRewire = inst.error_note || inst.last_verified_up === false;
|
|
600
|
+
if (inst.status === 'active' && needsRewire && !(await provisioning.getOpenIntent(pool, id))) {
|
|
601
|
+
const { created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
|
|
602
|
+
if (created) {
|
|
603
|
+
await provisioning.recordEvent(pool, {
|
|
604
|
+
instanceId: id, ownerBuilderId: req.builder.id, event: 'domain',
|
|
605
|
+
detail: `domain ${domain} re-attach queued (owner re-kick)`, actor: 'api:self',
|
|
606
|
+
}).catch(() => {});
|
|
607
|
+
}
|
|
608
|
+
// created:false = a racing enqueue got there first — still truthful.
|
|
609
|
+
return res.json({ ok: true, changed: false, queued: !!created, domain: inst.domain,
|
|
610
|
+
message: created
|
|
611
|
+
? 'Wiring that address again — this card updates when it starts.'
|
|
612
|
+
: 'That address is already being wired — this card updates when it starts.' });
|
|
613
|
+
}
|
|
592
614
|
return res.json({ ok: true, changed: false, queued: false, domain: inst.domain,
|
|
593
615
|
message: 'This instance already has that address.' });
|
|
594
616
|
}
|
|
@@ -640,8 +662,11 @@ module.exports = function provisioningRoutes() {
|
|
|
640
662
|
actor: 'api:self',
|
|
641
663
|
}).catch(() => {});
|
|
642
664
|
res.json({ ok: true, changed: true, queued, domain,
|
|
665
|
+
// State-only sentence (task 1002720 / F16): the web tier holds no cloud
|
|
666
|
+
// tokens and cannot know whether a DNS hook exists on the runner, so
|
|
667
|
+
// "it will answer there shortly" was a promise it could not keep.
|
|
643
668
|
message: queued
|
|
644
|
-
? 'Your instance is being wired to its new address —
|
|
669
|
+
? 'Your instance is being wired to its new address — this card will show whether it answers.'
|
|
645
670
|
: 'Address recorded — it will be wired when the instance is provisioned.' });
|
|
646
671
|
} catch (err) {
|
|
647
672
|
console.error('[provisioning] POST /provisioning/instances/:id/domain', err);
|
|
@@ -235,5 +235,26 @@ t('publicInstance projects the declared type — nullable, never silently droppe
|
|
|
235
235
|
assert.equal(q.type, null, 'a row predating provisioning_007 projects null, not undefined');
|
|
236
236
|
});
|
|
237
237
|
|
|
238
|
+
t('publicInstance projects dns_note — nullable, never silently dropped (task 1002720 / F16)', () => {
|
|
239
|
+
const p = P.publicInstance({ id: 1, slug: 'x', status: 'active', dns_note: 'point the A record at 203.0.113.5' });
|
|
240
|
+
assert.match(p.dns_note, /A record/, 'the owner-facing manual-DNS sentence must reach the card');
|
|
241
|
+
const q = P.publicInstance({ id: 2, slug: 'y', status: 'active' });
|
|
242
|
+
assert.equal(q.dns_note, null, 'a row predating provisioning_008 projects null, not undefined');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no status, no updated_at', async () => {
|
|
246
|
+
let sql = '', params = null;
|
|
247
|
+
const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 5 }] }; } };
|
|
248
|
+
await P.recordDnsNote(db, 5, 'note text');
|
|
249
|
+
assert.match(sql, /SET dns_note = \$2/);
|
|
250
|
+
assert.doesNotMatch(sql, /status|updated_at/, 'the overlay must never reshuffle lifecycle or card order');
|
|
251
|
+
assert.deepEqual(params, [5, 'note text']);
|
|
252
|
+
await P.recordDnsNote(db, 5, null);
|
|
253
|
+
assert.deepEqual(params, [5, null], 'null clears');
|
|
254
|
+
await P.clearErrorNote(db, 5);
|
|
255
|
+
assert.match(sql, /SET error_note = NULL/);
|
|
256
|
+
assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch must not touch the lifecycle');
|
|
257
|
+
});
|
|
258
|
+
|
|
238
259
|
console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
|
|
239
260
|
process.exit(failed ? 1 : 0);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.129",
|
|
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.129",
|
|
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.129",
|
|
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",
|
|
@@ -228,6 +228,17 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
228
228
|
.prog.isLive .pnote{color:color-mix(in srgb,var(--ok) 75%,var(--ink));font-size:14px;}
|
|
229
229
|
.prog.isErr{background:var(--err-soft);border-color:color-mix(in srgb,var(--err) 25%,var(--bg-card));}
|
|
230
230
|
.prog.isErr .pnote{color:var(--err);}
|
|
231
|
+
/* teardown's calm neutral (F22) — transcribed from cosmos's .strip.flowDown,
|
|
232
|
+
the same .prog-mirrors-.strip duplication as the rest of this block */
|
|
233
|
+
.prog.flowDown{border-color:var(--line);background:linear-gradient(180deg,var(--neutral-soft) 0%,var(--bg-card) 100%);}
|
|
234
|
+
.prog.flowDown .stages li:not(:first-child)::before{background:var(--line);}
|
|
235
|
+
.prog.flowDown .stages li.done:not(:first-child)::before{background:var(--ink-faint);}
|
|
236
|
+
.prog.flowDown .sdot{border-color:var(--line);}
|
|
237
|
+
.prog.flowDown .stages li.done .sdot{border-color:var(--ink-faint);background:var(--ink-faint);}
|
|
238
|
+
.prog.flowDown .stages li.now .sdot{border-color:var(--ink-soft);}
|
|
239
|
+
.prog.flowDown .stages li.now .sdot::after{background:var(--ink-soft);}
|
|
240
|
+
.prog.flowDown .slbl{color:var(--ink-faint);}
|
|
241
|
+
.prog.flowDown .stages li.now .slbl,.prog.flowDown .stages li.done .slbl{color:var(--ink-soft);}
|
|
231
242
|
.again{
|
|
232
243
|
display:inline-block;margin-top:14px;font-size:13px;font-weight:700;color:var(--ink-faint);
|
|
233
244
|
background:none;border:none;cursor:pointer;font-family:var(--font-body);text-decoration:underline;
|
|
@@ -2864,25 +2875,55 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2864
2875
|
var watchPending = false; /* a parked progress watch, resumed on re-entry */
|
|
2865
2876
|
var watchGen = 0; /* same orphaning contract as manifestGen above */
|
|
2866
2877
|
function stopPolling() { watchGen += 1; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } }
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2878
|
+
/* The done panel's own flow vocabularies (F22). A local mirror of the mine
|
|
2879
|
+
view's FLOWS — that table lives in the OTHER page IIFE, out of scope here,
|
|
2880
|
+
the same isolation that makes .prog duplicate .strip. */
|
|
2881
|
+
var PROG_FLOWS = {
|
|
2882
|
+
standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
|
|
2883
|
+
revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
|
|
2884
|
+
teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' }
|
|
2885
|
+
};
|
|
2886
|
+
function progressMarkup(stageNow, stagesDone, note, cls, flow) {
|
|
2887
|
+
var f = PROG_FLOWS[flow || 'standup'];
|
|
2888
|
+
return '<div class="prog ' + (f.cls ? f.cls + ' ' : '') + (cls || '') + '" role="status" aria-live="polite">' +
|
|
2889
|
+
'<ul class="stages">' + f.names.map(function (n, i) {
|
|
2871
2890
|
var c = stagesDone > i ? 'done' : (stageNow === i ? 'now' : '');
|
|
2872
2891
|
return '<li class="' + c + '"><span class="sdot"></span><span class="slbl">' + n + '</span></li>';
|
|
2873
2892
|
}).join('') + '</ul>' +
|
|
2874
2893
|
'<p class="pnote">' + note + '</p></div>';
|
|
2875
2894
|
}
|
|
2876
2895
|
/* Renders the current instance state; returns true when the state is terminal
|
|
2877
|
-
(no further polling wanted).
|
|
2896
|
+
(no further polling wanted). The strip is chosen by the OPEN INTENT first,
|
|
2897
|
+
then status (F22): a torn-down instance with a queued revive must never be
|
|
2898
|
+
told to "start another project". */
|
|
2878
2899
|
function renderProgress(inst) {
|
|
2879
2900
|
var el = $('liveProgress');
|
|
2880
2901
|
if (!el) return true;
|
|
2881
2902
|
var s = inst && inst.status;
|
|
2903
|
+
var revive = !!(inst && inst.torn_down_at);
|
|
2904
|
+
var oi = inst && inst.open_intent;
|
|
2905
|
+
/* requested/provisioning/tearing_down speak their STATUS (a requested
|
|
2906
|
+
instance always has its provision intent open — the intent branch
|
|
2907
|
+
would make the status branch dead and diverge from the mine card) */
|
|
2908
|
+
if (oi && s !== 'provisioning' && s !== 'tearing_down' && s !== 'requested') {
|
|
2909
|
+
if (oi.action === 'provision') {
|
|
2910
|
+
el.innerHTML = progressMarkup(0, 0,
|
|
2911
|
+
(s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this panel updates when it starts.',
|
|
2912
|
+
'', s === 'torn_down' ? 'revive' : 'standup');
|
|
2913
|
+
return false;
|
|
2914
|
+
}
|
|
2915
|
+
if (oi.action === 'teardown') {
|
|
2916
|
+
el.innerHTML = progressMarkup(0, 0, 'A teardown is queued — this panel updates when it starts.', '', 'teardown');
|
|
2917
|
+
return false;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2882
2920
|
if (s === 'requested') {
|
|
2883
|
-
el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.');
|
|
2921
|
+
el.innerHTML = progressMarkup(1, 1, 'Waiting for a runner to pick it up. This updates the moment it starts.', '', revive ? 'revive' : 'standup');
|
|
2884
2922
|
} else if (s === 'provisioning') {
|
|
2885
|
-
el.innerHTML = progressMarkup(1, 1,
|
|
2923
|
+
el.innerHTML = progressMarkup(1, 1,
|
|
2924
|
+
revive ? 'Bringing it back on the newest platform version — nothing needed from you.'
|
|
2925
|
+
: 'Raising the server, database, and address — nothing needed from you.',
|
|
2926
|
+
'', revive ? 'revive' : 'standup');
|
|
2886
2927
|
} else if (s === 'active') {
|
|
2887
2928
|
var addr = inst.domain ? 'https://' + inst.domain : null;
|
|
2888
2929
|
el.innerHTML = progressMarkup(-1, 3,
|
|
@@ -2894,9 +2935,13 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2894
2935
|
false "keeps retrying" — retries stop, and the retry door is the mine view */
|
|
2895
2936
|
el.innerHTML = progressMarkup(1, 1,
|
|
2896
2937
|
'The standup stopped partway — it won’t continue on its own. Retry it (and see the technical details) from <a href="?view=mine">your projects page</a>.', 'isErr');
|
|
2897
|
-
} else if (s === 'tearing_down'
|
|
2898
|
-
/* a deliberate end is calm, never styled as a failure (direction §5)
|
|
2899
|
-
|
|
2938
|
+
} else if (s === 'tearing_down') {
|
|
2939
|
+
/* a deliberate end is calm, never styled as a failure (direction §5) —
|
|
2940
|
+
and it speaks the teardown vocabulary, not the standup's (F22) */
|
|
2941
|
+
el.innerHTML = progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
|
|
2942
|
+
(inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown');
|
|
2943
|
+
} else if (s === 'torn_down') {
|
|
2944
|
+
el.innerHTML = progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from <a href="?view=mine">your projects page</a>, or start another below.', '', 'teardown');
|
|
2900
2945
|
return true;
|
|
2901
2946
|
} else {
|
|
2902
2947
|
el.innerHTML = progressMarkup(0, 1, 'Filed. Watching for the standup to start…');
|
|
@@ -2936,6 +2981,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2936
2981
|
.then(function (data) {
|
|
2937
2982
|
if (myGen !== watchGen) return;
|
|
2938
2983
|
var inst = data.instance;
|
|
2984
|
+
if (inst) inst.open_intent = data.open_intent || null; /* F22: the strip reads intents */
|
|
2939
2985
|
/* the checklist reflects live state (task 1002694): a status transition —
|
|
2940
2986
|
e.g. the scaffold landing — re-renders the steps (change-only, so the
|
|
2941
2987
|
5s tick never clobbers an open dropdown or mid-flight button) */
|
|
@@ -3198,6 +3244,10 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3198
3244
|
/* domainless instances get their first address; an assigned default may be
|
|
3199
3245
|
upgraded to a bring-your-own domain (the server allows exactly that re-point) */
|
|
3200
3246
|
function canAttachDomain(inst) {
|
|
3247
|
+
/* the re-kick door (F19): an active instance whose address wiring stopped
|
|
3248
|
+
(error_note, no intent in flight) can re-send its own domain — the route
|
|
3249
|
+
re-enqueues the attach for exactly this shape */
|
|
3250
|
+
if (inst.status === 'active' && inst.domain && inst.error_note && !inst.open_intent) return true;
|
|
3201
3251
|
return (!inst.domain || inst.domain === assignedDefaultFor(inst)) &&
|
|
3202
3252
|
(inst.status === 'requested' || inst.status === 'error' || inst.status === 'active');
|
|
3203
3253
|
}
|
|
@@ -3258,7 +3308,8 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3258
3308
|
: 'Raising the server, database, and address — nothing needed from you.');
|
|
3259
3309
|
}
|
|
3260
3310
|
if (s === 'tearing_down') {
|
|
3261
|
-
return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours;
|
|
3311
|
+
return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; ' +
|
|
3312
|
+
(inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'));
|
|
3262
3313
|
}
|
|
3263
3314
|
if (inst.open_intent) {
|
|
3264
3315
|
var a = inst.open_intent.action;
|
|
@@ -3297,6 +3348,14 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3297
3348
|
/* ── rendering ───────────────────────────────────────────────────────── */
|
|
3298
3349
|
|
|
3299
3350
|
var SHAPE_LABEL = { standalone: 'its own server', 'co-tenant': 'shared server', dedicated: 'its own server' };
|
|
3351
|
+
/* what a teardown ACTUALLY does, per hosting shape (F17 — the DECIDED policy:
|
|
3352
|
+
retention is deliberate on shared boxes; a dedicated machine takes its
|
|
3353
|
+
database with it). Copy follows the runner, never the other way around. */
|
|
3354
|
+
function teardownTruth(inst) {
|
|
3355
|
+
return inst && inst.hosting_shape === 'dedicated'
|
|
3356
|
+
? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched.'
|
|
3357
|
+
: 'The server goes away and the address stops answering. Your repository is untouched. The database is kept, so bringing it back later restores its memory.';
|
|
3358
|
+
}
|
|
3300
3359
|
|
|
3301
3360
|
function addressRow(inst) {
|
|
3302
3361
|
if (inst.domain) {
|
|
@@ -3305,6 +3364,9 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3305
3364
|
heartbeat earns it — never the runner finishing its script */
|
|
3306
3365
|
var link = '<a href="https://' + esc(inst.domain) + '" target="_blank" rel="noopener">' + esc(inst.domain) + '</a>';
|
|
3307
3366
|
if (inst.last_verified_up) return 'Answering at ' + link;
|
|
3367
|
+
/* the runner had no DNS hook (F16): the CAUSE beats the bare symptom —
|
|
3368
|
+
a successful A record flips last_verified_up and retires the note */
|
|
3369
|
+
if (inst.dns_note) return 'Its address is ' + link + '. ' + esc(inst.dns_note);
|
|
3308
3370
|
if (inst.last_verified_up === false) return 'Not answering at ' + link + ' right now.';
|
|
3309
3371
|
return 'Its address is ' + link + ' — confirming it answers.';
|
|
3310
3372
|
}
|
|
@@ -3335,20 +3397,28 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3335
3397
|
var addr = addressRow(inst);
|
|
3336
3398
|
if (addr) body += '<div class="addr">' + addr + '</div>';
|
|
3337
3399
|
if (canAttachDomain(inst)) {
|
|
3400
|
+
var rekick = inst.status === 'active' && inst.domain && inst.error_note;
|
|
3338
3401
|
body += '<div class="attach">' +
|
|
3339
|
-
'<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253"
|
|
3402
|
+
'<input type="text" id="dom-' + iid + '" placeholder="myproject.example.com" autocomplete="off" spellcheck="false" maxlength="253"' +
|
|
3403
|
+
(rekick ? ' value="' + esc(inst.domain) + '"' : '') + '>' +
|
|
3340
3404
|
'<button type="button" class="btn small" data-attach="' + iid + '">' +
|
|
3341
|
-
(inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
|
|
3405
|
+
(rekick ? 'Try the address again' : inst.domain ? 'Use your own domain' : 'Give it an address') + '</button></div>';
|
|
3342
3406
|
}
|
|
3343
|
-
if (inst.status === 'error' && !inst.open_intent) {
|
|
3407
|
+
if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
|
|
3344
3408
|
/* once a retry is queued the strip tells the story — the stale error
|
|
3345
|
-
paragraph would just contradict it (owner walk, 2026-08-12)
|
|
3409
|
+
paragraph would just contradict it (owner walk, 2026-08-12). Also
|
|
3410
|
+
covers an ACTIVE instance whose intent retired with a note (F19) —
|
|
3411
|
+
the work stopped and the card must say so, not stay silent. */
|
|
3346
3412
|
body += '<div class="projMsg err">' + errMarkup(inst) + '</div>';
|
|
3347
3413
|
}
|
|
3348
3414
|
body += stripFor(inst);
|
|
3349
|
-
if (gone) {
|
|
3415
|
+
if (gone && !inst.open_intent) {
|
|
3416
|
+
/* with a revive queued, the strip tells the story — this paragraph would
|
|
3417
|
+
contradict it (F22, the same guard the error paragraph has) */
|
|
3350
3418
|
body += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(shortDate(inst.torn_down_at)) : '') +
|
|
3351
|
-
|
|
3419
|
+
(inst.hosting_shape === 'dedicated'
|
|
3420
|
+
? '. The repository is still yours. Bringing it back stands up a fresh machine with an empty database. Or <a href="?view=new">start another</a>.</div>'
|
|
3421
|
+
: '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>');
|
|
3352
3422
|
}
|
|
3353
3423
|
var lm = lastMsg[inst.id];
|
|
3354
3424
|
body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
|
|
@@ -3522,7 +3592,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3522
3592
|
var act = $('act-' + id);
|
|
3523
3593
|
if (!act) return;
|
|
3524
3594
|
act.innerHTML = '<div class="confirmRow">' +
|
|
3525
|
-
'<span class="plain">Tear it down?
|
|
3595
|
+
'<span class="plain">Tear it down? ' + teardownTruth(byId[id]) + '</span>' +
|
|
3526
3596
|
'<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
|
|
3527
3597
|
'<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
|
|
3528
3598
|
}
|
package/scripts/gds/provision.js
CHANGED
|
@@ -169,6 +169,14 @@ const CONFIG = {
|
|
|
169
169
|
|
|
170
170
|
const MAX_INTENT_ATTEMPTS = numEnv('PROVISION_INTENT_MAX_ATTEMPTS', 3);
|
|
171
171
|
|
|
172
|
+
// Manifest-placement verification budget (task 1002720 / QA-walk F23): a child
|
|
173
|
+
// instance is a full Node boot away from reporting auth_configured=true, and the
|
|
174
|
+
// old 10×1s poll routinely lost that race — recording a failure for a setup that
|
|
175
|
+
// succeeded, then burning the SINGLE-USE manifest code on the retry. ~3 min of
|
|
176
|
+
// patience; the poll runs after the intent loop, so it delays nothing.
|
|
177
|
+
const MANIFEST_VERIFY_TRIES = numEnv('PROVISION_MANIFEST_VERIFY_TRIES', 90);
|
|
178
|
+
const MANIFEST_VERIFY_INTERVAL_MS = numEnv('PROVISION_MANIFEST_VERIFY_INTERVAL_MS', 2000);
|
|
179
|
+
|
|
172
180
|
// ---------------------------------------------------------------------------
|
|
173
181
|
// Lazy dep load (so --help / arg errors need no Postgres) — the box.js pattern.
|
|
174
182
|
// ---------------------------------------------------------------------------
|
|
@@ -1503,9 +1511,21 @@ async function provisionInstance(inst, deps) {
|
|
|
1503
1511
|
// only on the control plane — it must never run on the (token-free) co-hosting
|
|
1504
1512
|
// box even when the box-mutating steps above went there over SSH (task 2065).
|
|
1505
1513
|
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
1514
|
+
// The clear half of the birth-path note (F16): a revive after the operator
|
|
1515
|
+
// configured the hook must retire the stale "point your A record" sentence.
|
|
1516
|
+
if (apply && provisioning.recordDnsNote) {
|
|
1517
|
+
await provisioning.recordDnsNote(db, inst.id, null).catch(() => {});
|
|
1518
|
+
}
|
|
1506
1519
|
}
|
|
1507
1520
|
} else {
|
|
1508
1521
|
log(' [dns] skipped (no domain, or BOX_DNS_HOOK unset)');
|
|
1522
|
+
// Birth-path twin of the domain-attach note (F16): a domain with no hook is
|
|
1523
|
+
// the same silent skip here, so the owner card gets the same sentence.
|
|
1524
|
+
if (apply && inst.domain) {
|
|
1525
|
+
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id,
|
|
1526
|
+
`Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
1527
|
+
).catch(() => {});
|
|
1528
|
+
}
|
|
1509
1529
|
}
|
|
1510
1530
|
|
|
1511
1531
|
// ── step 6: Caddy per-instance site snippet + reload ──────────────────────
|
|
@@ -1520,17 +1540,55 @@ async function provisionInstance(inst, deps) {
|
|
|
1520
1540
|
} else { log(' (Caddy on the droplet via cloud-init)'); }
|
|
1521
1541
|
|
|
1522
1542
|
// ── step 7: healthz ───────────────────────────────────────────────────────
|
|
1543
|
+
// The probe RESULT is recorded, not discarded (task 1002720 / QA-walk F15: a
|
|
1544
|
+
// refused connection used to ride silently into 'active' and the card said
|
|
1545
|
+
// live). Bounded retry so a slow first boot is not slandered — the
|
|
1546
|
+
// placeManifestCreds loop shape. Still ok:true either way: a failed probe
|
|
1547
|
+
// must not flip the intent to failed (cmdRunIntents would burn the 3-attempt
|
|
1548
|
+
// budget on an instance that may simply be slow); the liveness overlay is
|
|
1549
|
+
// what tells the owner the truth.
|
|
1523
1550
|
log(' [healthz] verify the instance answers');
|
|
1524
|
-
|
|
1525
|
-
|
|
1551
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1552
|
+
let healthy = null; // null = not observed (dry-run / dedicated)
|
|
1553
|
+
if (local) {
|
|
1554
|
+
const tries = apply ? 10 : 1;
|
|
1555
|
+
for (let i = 0; i < tries; i++) {
|
|
1556
|
+
const res = boxExec(healthzCmd(inst), { allowFail: true });
|
|
1557
|
+
if (!apply) break; // dry-run: exactly one plan line
|
|
1558
|
+
if (res && !res.softFailed) { healthy = true; break; }
|
|
1559
|
+
healthy = false;
|
|
1560
|
+
if (i < tries - 1) await sleep(1000);
|
|
1561
|
+
}
|
|
1562
|
+
} else log(' (verified post-boot)');
|
|
1526
1563
|
|
|
1527
|
-
// Mark active.
|
|
1564
|
+
// Mark active. error_note: null — a stale note from an earlier failed run
|
|
1565
|
+
// must not ride into the active state (F15's second half; the co-tenant +
|
|
1566
|
+
// standalone paths never cleared it). The runner hands its own observation
|
|
1567
|
+
// to the liveness overlay so the card gets an honest verdict immediately
|
|
1568
|
+
// instead of waiting ~5 min for the first sweep.
|
|
1528
1569
|
if (apply) {
|
|
1529
|
-
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, provisioned_at: new Date().toISOString() });
|
|
1530
|
-
|
|
1570
|
+
await provisioning.setInstanceStatus(db, inst.id, 'active', { ...patch, error_note: null, provisioned_at: new Date().toISOString() });
|
|
1571
|
+
// The overlay column is DOMAIN-scoped when a domain exists (the sweep
|
|
1572
|
+
// probes https://<domain>; loopback only when there is none), and the card
|
|
1573
|
+
// renders up=true as "Answering at <domain>". This probe is loopback, so:
|
|
1574
|
+
// a FAILURE is a verdict on any shape (a dead process answers nowhere),
|
|
1575
|
+
// but a PASS proves only the process — for a domain-bearing instance the
|
|
1576
|
+
// address claim stays "not checked yet" until the sweep earns it.
|
|
1577
|
+
const probeIsVerdict = healthy === false || (healthy === true && !inst.domain);
|
|
1578
|
+
if (probeIsVerdict && provisioning.recordLivenessProbe) {
|
|
1579
|
+
await provisioning.recordLivenessProbe(db, inst.id, healthy).catch(() => {});
|
|
1580
|
+
}
|
|
1581
|
+
await provisioning.recordEvent(db, {
|
|
1582
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
|
|
1583
|
+
detail: `provisioned (${inst.hosting_shape}, port ${inst.port || '-'})` +
|
|
1584
|
+
(healthy === false ? ' — not answering /healthz yet' : ''),
|
|
1585
|
+
actor: 'runner',
|
|
1586
|
+
});
|
|
1531
1587
|
}
|
|
1532
|
-
log(
|
|
1533
|
-
|
|
1588
|
+
log(healthy === false
|
|
1589
|
+
? ` ⚠ ${inst.slug} provisioned, but it is not answering /healthz yet — its card will read "unreachable"`
|
|
1590
|
+
: ` ✓ ${inst.slug} provisioned`);
|
|
1591
|
+
return { ok: true, port: inst.port, healthy };
|
|
1534
1592
|
}
|
|
1535
1593
|
|
|
1536
1594
|
// The cloud-init user-data for a dedicated droplet: run the co-tenant standup
|
|
@@ -1616,6 +1674,7 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1616
1674
|
|
|
1617
1675
|
// DNS A-record UPSERT — mirrors provisionInstance step 5 (control-plane exec only:
|
|
1618
1676
|
// the hook needs the Cloudflare token, which never leaves the control plane).
|
|
1677
|
+
let dnsSkipped = false;
|
|
1619
1678
|
if (CONFIG.dnsHook) {
|
|
1620
1679
|
if (!recordIp) {
|
|
1621
1680
|
const ipEnvHint = standalone ? 'PROVISION_CONTROL_PLANE_IP (standalone)' : 'PROVISION_PUBLIC_IP (co-tenant)';
|
|
@@ -1627,7 +1686,11 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1627
1686
|
controlExec(CONFIG.dnsHook, { env: dnsEnv });
|
|
1628
1687
|
}
|
|
1629
1688
|
} else {
|
|
1630
|
-
|
|
1689
|
+
// Still ok:true below — an unset hook is unretryable, and ok:false would
|
|
1690
|
+
// burn the intent's retry budget on it. The skip is surfaced to the OWNER
|
|
1691
|
+
// via dns_note (F16), not just this operator log line.
|
|
1692
|
+
dnsSkipped = true;
|
|
1693
|
+
log(` [dns] skipped (BOX_DNS_HOOK unset) — point ${inst.domain}'s A record at ${recordIp || 'this server'} manually`);
|
|
1631
1694
|
}
|
|
1632
1695
|
|
|
1633
1696
|
log(' [caddy] write site snippet + reload');
|
|
@@ -1638,12 +1701,24 @@ async function domainAttachInstance(inst, deps) {
|
|
|
1638
1701
|
boxExec(healthzCmd(inst), { allowFail: true });
|
|
1639
1702
|
|
|
1640
1703
|
if (apply) {
|
|
1704
|
+
// Written BOTH ways so a later successful attach clears a stale note.
|
|
1705
|
+
if (provisioning.recordDnsNote) await provisioning.recordDnsNote(db, inst.id, dnsSkipped
|
|
1706
|
+
? `Automatic DNS isn't set up on this platform — point ${inst.domain}'s A record at ${recordIp || 'your server'}, and this card will show when it answers.`
|
|
1707
|
+
: null).catch(() => {});
|
|
1708
|
+
// Release the F19 latch: this run SUCCEEDED, so a "we stopped trying" note
|
|
1709
|
+
// from an earlier retired attach must not outlive its own recovery.
|
|
1710
|
+
if (provisioning.clearErrorNote) await provisioning.clearErrorNote(db, inst.id).catch(() => {});
|
|
1641
1711
|
await provisioning.recordEvent(db, {
|
|
1642
1712
|
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'domain-attached',
|
|
1643
|
-
detail:
|
|
1713
|
+
detail: dnsSkipped
|
|
1714
|
+
? `domain ${inst.domain} wired (env + caddy); DNS SKIPPED — no hook configured`
|
|
1715
|
+
: `domain ${inst.domain} wired (env + DNS + caddy re-asserted)`,
|
|
1716
|
+
actor: 'runner',
|
|
1644
1717
|
}).catch(() => {});
|
|
1645
1718
|
}
|
|
1646
|
-
log(
|
|
1719
|
+
log(dnsSkipped
|
|
1720
|
+
? ` ✓ ${inst.slug} configured for ${inst.domain} — DNS still needs your A record`
|
|
1721
|
+
: ` ✓ ${inst.slug} addressed as ${inst.domain}`);
|
|
1647
1722
|
return { ok: true };
|
|
1648
1723
|
}
|
|
1649
1724
|
|
|
@@ -1750,11 +1825,14 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1750
1825
|
|
|
1751
1826
|
// 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
|
|
1752
1827
|
// the same length/charset guard oauth-secret.js uses against silent truncation).
|
|
1828
|
+
// From here on the single-use manifest code is SPENT (the conversion POST
|
|
1829
|
+
// consumed it) — every return carries codeSpent:true so the drain never
|
|
1830
|
+
// resets the row for a retry that would re-convert a burned code and 404.
|
|
1753
1831
|
const idV = oauthSecret.validateClientId(creds.clientId);
|
|
1754
1832
|
const secV = oauthSecret.validateClientSecret(creds.clientSecret);
|
|
1755
1833
|
if (!idV.ok || !secV.ok) {
|
|
1756
1834
|
const why = [!idV.ok ? `id: ${idV.reason}` : null, !secV.ok ? `secret: ${secV.reason}` : null].filter(Boolean).join('; ');
|
|
1757
|
-
return { ok: false, error: `GitHub returned unusable creds (${why})` };
|
|
1835
|
+
return { ok: false, codeSpent: apply, error: `GitHub returned unusable creds (${why})` };
|
|
1758
1836
|
}
|
|
1759
1837
|
|
|
1760
1838
|
// 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
|
|
@@ -1764,21 +1842,24 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1764
1842
|
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
1765
1843
|
|
|
1766
1844
|
// 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
|
|
1845
|
+
// PATIENT (F23): the child must boot before it can answer; losing this race
|
|
1846
|
+
// used to record a failure for a setup that had succeeded.
|
|
1767
1847
|
if (apply) {
|
|
1848
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1768
1849
|
let authConfigured = false;
|
|
1769
|
-
for (let i = 0; i <
|
|
1850
|
+
for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
|
|
1770
1851
|
const out = boxExec(healthzCmd(inst), { allowFail: true });
|
|
1771
1852
|
if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
|
|
1772
|
-
await
|
|
1853
|
+
await sleep(MANIFEST_VERIFY_INTERVAL_MS);
|
|
1773
1854
|
}
|
|
1774
1855
|
if (!authConfigured) {
|
|
1775
|
-
return { ok: false, error: 'placed
|
|
1856
|
+
return { ok: false, codeSpent: true, error: 'the credentials are placed, but the project did not report sign-in ready in time' };
|
|
1776
1857
|
}
|
|
1777
1858
|
log(' [oauth-manifest] ✓ /healthz auth_configured=true — sign-in is live');
|
|
1778
1859
|
} else {
|
|
1779
1860
|
log(' [oauth-manifest] would probe /healthz for auth_configured after restart');
|
|
1780
1861
|
}
|
|
1781
|
-
return { ok: true, appId: creds.appId, appSlug: creds.appSlug };
|
|
1862
|
+
return { ok: true, codeSpent: apply, appId: creds.appId, appSlug: creds.appSlug };
|
|
1782
1863
|
}
|
|
1783
1864
|
|
|
1784
1865
|
// Drain provisioning_oauth_manifests rows awaiting conversion (status 'code_received').
|
|
@@ -1820,11 +1901,17 @@ async function drainOAuthManifests(deps) {
|
|
|
1820
1901
|
if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
|
|
1821
1902
|
const result = await placeManifestCreds(inst, mrow, deps);
|
|
1822
1903
|
if (result && result.ok === false) {
|
|
1823
|
-
|
|
1904
|
+
// A SPENT code is terminal, always (F23): GitHub's manifest code is
|
|
1905
|
+
// single-use, so resetting the row to 'code_received' guarantees the
|
|
1906
|
+
// next tick re-converts a burned code and 404s forever. Only failures
|
|
1907
|
+
// that happened BEFORE the conversion stay retryable.
|
|
1908
|
+
if (result.codeSpent) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error, github_code: null });
|
|
1909
|
+
else if (mrow.attempts >= MAX_INTENT_ATTEMPTS) await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: result.error });
|
|
1824
1910
|
else await provisioning.resolveOAuthManifest(db, mrow.id, 'code_received', { last_error: result.error });
|
|
1825
1911
|
errors++; log(` ✖ oauth-manifest #${mrow.id}: ${result.error}`);
|
|
1826
1912
|
} else {
|
|
1827
|
-
|
|
1913
|
+
// github_code: null on done too — a finished row holds no re-convertible code.
|
|
1914
|
+
await provisioning.resolveOAuthManifest(db, mrow.id, 'done', { app_id: result.appId, app_slug: result.appSlug, github_code: null });
|
|
1828
1915
|
await provisioning.recordEvent(db, {
|
|
1829
1916
|
instanceId: inst.id, ownerBuilderId: mrow.requested_by, event: 'oauth-manifest-done',
|
|
1830
1917
|
detail: `sign-in wired via GitHub App Manifest (app ${result.appSlug || result.appId || '?'})`, actor: 'runner',
|
|
@@ -1880,8 +1967,17 @@ async function cmdRunIntents(deps) {
|
|
|
1880
1967
|
else if (intent.action === 'domain-attach') result = await domainAttachInstance(inst, deps);
|
|
1881
1968
|
else result = { ok: false, error: `unknown intent action '${intent.action}' — refusing to guess` };
|
|
1882
1969
|
if (result && result.ok === false) {
|
|
1883
|
-
if (intent.attempts >= MAX_INTENT_ATTEMPTS)
|
|
1884
|
-
|
|
1970
|
+
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
|
1971
|
+
await provisioning.resolveIntent(db, intent.id, 'error', result.error || 'op failed');
|
|
1972
|
+
// A retired intent stops silently otherwise (F19): record the reason
|
|
1973
|
+
// on the instance WITHOUT changing its lifecycle status — passing the
|
|
1974
|
+
// CURRENT status makes the status write a no-op, so a healthy active
|
|
1975
|
+
// instance is never marked broken over a domain hiccup, but the card
|
|
1976
|
+
// can say why the work stopped.
|
|
1977
|
+
await provisioning.setInstanceStatus(db, intent.instance_id, inst.status, { error_note: result.error || 'op failed' }).catch(() => {});
|
|
1978
|
+
} else {
|
|
1979
|
+
await db.query(`UPDATE provisioning_intents SET state='pending', last_error=$2, updated_at=now() WHERE id=$1`, [intent.id, result.error || 'op failed']);
|
|
1980
|
+
}
|
|
1885
1981
|
errors++;
|
|
1886
1982
|
} else { await provisioning.resolveIntent(db, intent.id, 'done', null); ok++; }
|
|
1887
1983
|
} catch (e) {
|
package/src/module-api.js
CHANGED
|
@@ -681,8 +681,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
681
681
|
// landed since 1.19.125 with no explicit bump. run 31853055269. (task 1002620)
|
|
682
682
|
// 1.19.127 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
683
683
|
// landed since 1.19.126 with no explicit bump. run 31854730552. (task 1002620)
|
|
684
|
+
// 1.19.128 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
685
|
+
// landed since 1.19.127 with no explicit bump. run 31857842473. (task 1002620)
|
|
686
|
+
// 1.19.129 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
687
|
+
// landed since 1.19.128 with no explicit bump. run 31858158416. (task 1002620)
|
|
684
688
|
// ---------------------------------------------------------------------------
|
|
685
|
-
const CORE_VERSION = '1.19.
|
|
689
|
+
const CORE_VERSION = '1.19.129'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
686
690
|
|
|
687
691
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
688
692
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/provision.mjs
CHANGED
|
@@ -1033,17 +1033,40 @@ await ta('placeManifestCreds rejects a dedicated droplet (no post-hoc exec path)
|
|
|
1033
1033
|
assert.equal(noCode.ok, false); assert.match(noCode.error, /no github_code/);
|
|
1034
1034
|
});
|
|
1035
1035
|
|
|
1036
|
-
await ta('
|
|
1036
|
+
await ta('verification timeout reports the code as SPENT and does not disown the placement (F23)', async () => {
|
|
1037
1037
|
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1038
1038
|
const deps = {
|
|
1039
|
-
apply: true, privileged: false, log: () => {},
|
|
1039
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1040
1040
|
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }),
|
|
1041
1041
|
exec: () => ({ ok: true, stdout: '{"ok":true,"auth_configured":false}' }),
|
|
1042
1042
|
writeFile: () => ({ ok: true }),
|
|
1043
1043
|
};
|
|
1044
1044
|
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1045
1045
|
assert.equal(r.ok, false);
|
|
1046
|
-
assert.
|
|
1046
|
+
assert.equal(r.codeSpent, true, 'the conversion happened — the single-use code is burned');
|
|
1047
|
+
assert.match(r.error, /credentials are placed/, 'the error owns what DID happen');
|
|
1048
|
+
assert.doesNotMatch(r.error, /\b\d+s\b|minute|second/i, 'no wall-clock promises (§5.2)');
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
await ta('verification PATIENCE wins the boot race — one conversion, success (F23)', async () => {
|
|
1052
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1053
|
+
let probes = 0, converts = 0;
|
|
1054
|
+
const deps = {
|
|
1055
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1056
|
+
convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 1, appSlug: 's' }; },
|
|
1057
|
+
exec: (cmd) => {
|
|
1058
|
+
if (/healthz/.test(cmd)) {
|
|
1059
|
+
probes++;
|
|
1060
|
+
return { ok: true, stdout: probes > 20 ? '{"auth_configured":true}' : '{"auth_configured":false}' };
|
|
1061
|
+
}
|
|
1062
|
+
return { ok: true, stdout: '' };
|
|
1063
|
+
},
|
|
1064
|
+
writeFile: () => ({ ok: true }),
|
|
1065
|
+
};
|
|
1066
|
+
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1067
|
+
assert.equal(r.ok, true, 'a slow child boot is waited out, not recorded as a failure');
|
|
1068
|
+
assert.equal(converts, 1, 'the single-use code is converted exactly once');
|
|
1069
|
+
assert.ok(probes > 10, 'the old 10-probe budget would have lost this race');
|
|
1047
1070
|
});
|
|
1048
1071
|
|
|
1049
1072
|
await ta('drainOAuthManifests (--apply): claims a code_received row, places creds, resolves done', async () => {
|
|
@@ -1064,9 +1087,57 @@ await ta('drainOAuthManifests (--apply): claims a code_received row, places cred
|
|
|
1064
1087
|
assert.equal(resolved.length, 1);
|
|
1065
1088
|
assert.equal(resolved[0].status, 'done');
|
|
1066
1089
|
assert.equal(resolved[0].patch.app_slug, 'app-3');
|
|
1090
|
+
assert.equal(resolved[0].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
|
|
1067
1091
|
assert.ok(events.includes('oauth-manifest-done'));
|
|
1068
1092
|
});
|
|
1069
1093
|
|
|
1094
|
+
await ta('a SPENT code is never re-converted — terminal error, no code_received reset (F23)', async () => {
|
|
1095
|
+
// A stateful one-row store: claimNext honors status, resolve writes it back —
|
|
1096
|
+
// exactly the reset loop that used to re-convert a burned code on the next tick.
|
|
1097
|
+
const row = { id: 21, instance_id: 1, github_code: 'BURNED', attempts: 1, status: 'code_received' };
|
|
1098
|
+
const resolves = []; let converts = 0;
|
|
1099
|
+
const deps = {
|
|
1100
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1101
|
+
db: {}, writeFile: () => ({ ok: true }),
|
|
1102
|
+
exec: (cmd) => (/healthz/.test(cmd) ? { ok: true, stdout: '{"auth_configured":false}' } : { ok: true, stdout: '' }),
|
|
1103
|
+
convertManifestCode: async () => { converts++; return { clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 3, appSlug: 'a' }; },
|
|
1104
|
+
provisioning: {
|
|
1105
|
+
claimNextOAuthManifest: async () => (row.status === 'code_received' ? { ...row } : null),
|
|
1106
|
+
getInstanceById: async () => ({ ...coTenant, domain: 'mercury.example.com' }),
|
|
1107
|
+
resolveOAuthManifest: async (_db, _id, status, patch = {}) => {
|
|
1108
|
+
resolves.push({ status, patch });
|
|
1109
|
+
row.status = status;
|
|
1110
|
+
if ('github_code' in patch) row.github_code = patch.github_code;
|
|
1111
|
+
},
|
|
1112
|
+
recordEvent: async () => {},
|
|
1113
|
+
},
|
|
1114
|
+
};
|
|
1115
|
+
await P.drainOAuthManifests(deps); // tick 1: converts, placement verification times out
|
|
1116
|
+
await P.drainOAuthManifests(deps); // tick 2: must find nothing to claim
|
|
1117
|
+
assert.equal(converts, 1, 'the single-use code was converted exactly once across ticks');
|
|
1118
|
+
assert.equal(row.status, 'error', 'a spent code is terminal');
|
|
1119
|
+
assert.equal(row.github_code, null, 'the burned code is scrubbed from the row');
|
|
1120
|
+
assert.ok(!resolves.some((x) => x.status === 'code_received'), 'never reset for a doomed retry');
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
await ta('a PRE-conversion failure stays retryable — the narrowing spared the retry path (F23)', async () => {
|
|
1124
|
+
let claims = 0; const resolves = [];
|
|
1125
|
+
const deps = {
|
|
1126
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1127
|
+
db: {}, exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
|
|
1128
|
+
provisioning: {
|
|
1129
|
+
claimNextOAuthManifest: async () => (claims++ === 0 ? { id: 22, instance_id: 1, github_code: 'C', attempts: 1 } : null),
|
|
1130
|
+
// dedicated shape → placeManifestCreds refuses BEFORE converting (codeSpent absent)
|
|
1131
|
+
getInstanceById: async () => ({ ...coTenant, hosting_shape: 'dedicated', domain: 'd.example.com' }),
|
|
1132
|
+
resolveOAuthManifest: async (_db, _id, status, patch = {}) => { resolves.push({ status, patch }); },
|
|
1133
|
+
recordEvent: async () => {},
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
await P.drainOAuthManifests(deps);
|
|
1137
|
+
assert.equal(resolves.length, 1);
|
|
1138
|
+
assert.equal(resolves[0].status, 'code_received', 'an unconverted code may retry on a later tick');
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1070
1141
|
console.log('\nprivileged mode (PROVISION_SUDO — task 2066):');
|
|
1071
1142
|
|
|
1072
1143
|
t('dbCreateCmd: privileged runs createdb as the postgres role; default is bare (peer auth)', () => {
|
|
@@ -1502,17 +1573,20 @@ console.log('\ndomain-attach leg (task 1002692 — give an ACTIVE instance its l
|
|
|
1502
1573
|
// bodies), and recorded events. No status writes to capture — domain-attach must
|
|
1503
1574
|
// never touch the instance lifecycle.
|
|
1504
1575
|
function attachRecorder({ apply = false } = {}) {
|
|
1505
|
-
const cmds = [], envs = [], writes = [], events = [];
|
|
1576
|
+
const cmds = [], envs = [], writes = [], events = [], dnsNotes = [];
|
|
1506
1577
|
const deps = {
|
|
1507
1578
|
apply, log: () => {},
|
|
1508
1579
|
db: { query: async () => ({ rows: [] }) },
|
|
1509
|
-
provisioning: {
|
|
1580
|
+
provisioning: {
|
|
1581
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
1582
|
+
recordDnsNote: async (_db, id, note) => { dnsNotes.push({ id, note }); },
|
|
1583
|
+
},
|
|
1510
1584
|
exec: (cmd, opts = {}) => { cmds.push(cmd); envs.push((opts && opts.env) || null); return { ok: true, stdout: '' }; },
|
|
1511
1585
|
writeFile: (p, body) => { writes.push({ path: p, body }); return { ok: true }; },
|
|
1512
1586
|
doApi: null,
|
|
1513
1587
|
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1514
1588
|
};
|
|
1515
|
-
return { deps, cmds, envs, writes, events };
|
|
1589
|
+
return { deps, cmds, envs, writes, events, dnsNotes };
|
|
1516
1590
|
}
|
|
1517
1591
|
|
|
1518
1592
|
const activeStandalone = {
|
|
@@ -1630,5 +1704,197 @@ await ta('cmdRunIntents (--apply): an UNKNOWN intent action never falls through
|
|
|
1630
1704
|
assert.ok(!cmds.some((c) => /systemctl (stop|disable)/.test(c)), 'no teardown commands ran');
|
|
1631
1705
|
});
|
|
1632
1706
|
|
|
1707
|
+
console.log('\nprovisioning truthfulness (task 1002720 — QA-walk F15/F16/F19):');
|
|
1708
|
+
|
|
1709
|
+
// An apply-mode recorder for provisionInstance: every command succeeds except
|
|
1710
|
+
// (optionally) the healthz probe, which soft-fails the way a refused connection
|
|
1711
|
+
// does under allowFail.
|
|
1712
|
+
function truthRecorder({ healthzFails = false } = {}) {
|
|
1713
|
+
const cmds = [], statuses = [], patches = [], probes = [], logs = [], events = [], dnsNotes = [];
|
|
1714
|
+
const deps = {
|
|
1715
|
+
apply: true, privileged: false, log: (m) => logs.push(String(m)), sleep: async () => {},
|
|
1716
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1717
|
+
provisioning: {
|
|
1718
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statuses.push(s); patches.push(patch || {}); },
|
|
1719
|
+
recordLivenessProbe: async (_db, _id, up) => { probes.push(up); },
|
|
1720
|
+
recordDnsNote: async (_db, _id, note) => { dnsNotes.push(note); },
|
|
1721
|
+
recordEvent: async (_db, e) => { events.push(e); },
|
|
1722
|
+
getInstanceBySlug: async () => null, getInstanceById: async () => null,
|
|
1723
|
+
claimNextIntent: async () => null, resolveIntent: async () => {},
|
|
1724
|
+
enqueueIntent: async () => ({ created: true }),
|
|
1725
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1726
|
+
},
|
|
1727
|
+
exec: (cmd) => {
|
|
1728
|
+
cmds.push(cmd);
|
|
1729
|
+
if (healthzFails && /healthz/.test(cmd)) return { ok: true, stdout: '', softFailed: true };
|
|
1730
|
+
return { ok: true, stdout: '' };
|
|
1731
|
+
},
|
|
1732
|
+
writeFile: () => ({ ok: true }),
|
|
1733
|
+
doApi: null,
|
|
1734
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'cloudbongos.com' }),
|
|
1735
|
+
getGithubToken: async () => null,
|
|
1736
|
+
};
|
|
1737
|
+
return { deps, cmds, statuses, patches, probes, logs, events, dnsNotes };
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
await ta('F15: a refused /healthz is RECORDED — probe handed to the overlay, note cleared, success line qualified', async () => {
|
|
1741
|
+
const { deps, cmds, statuses, patches, probes, logs, events } = truthRecorder({ healthzFails: true });
|
|
1742
|
+
const r = await P.provisionInstance({ ...coTenant }, deps);
|
|
1743
|
+
assert.equal(r.ok, true, 'a failed probe must NOT fail the run — that would burn the intent retry budget');
|
|
1744
|
+
assert.equal(r.healthy, false);
|
|
1745
|
+
assert.equal(statuses[statuses.length - 1], 'active', 'the lifecycle still activates (the overlay carries the truth)');
|
|
1746
|
+
assert.equal(patches[patches.length - 1].error_note, null, 'a stale error_note never rides into active');
|
|
1747
|
+
assert.deepEqual(probes, [false], 'the runner hands its observation to the liveness overlay');
|
|
1748
|
+
assert.ok(cmds.filter((c) => /healthz/.test(c)).length >= 2, 'a slow boot gets a bounded retry, not one shot');
|
|
1749
|
+
assert.ok(logs.some((l) => /not answering/.test(l)), 'the operator line is qualified, not a bare ✓');
|
|
1750
|
+
const activeEvent = events.find((e) => e.event === 'active');
|
|
1751
|
+
assert.match(activeEvent.detail, /not answering/, 'the event detail carries the observation');
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
await ta('F15: a passing /healthz records up=true immediately — no five-minute "live?" limbo', async () => {
|
|
1755
|
+
const { deps, cmds, probes, logs } = truthRecorder();
|
|
1756
|
+
const r = await P.provisionInstance({ ...coTenant }, deps);
|
|
1757
|
+
assert.equal(r.ok, true);
|
|
1758
|
+
assert.equal(r.healthy, true);
|
|
1759
|
+
assert.deepEqual(probes, [true]);
|
|
1760
|
+
assert.equal(cmds.filter((c) => /healthz/.test(c)).length, 1, 'a pass ends the loop on the first probe');
|
|
1761
|
+
assert.ok(logs.some((l) => /✓ mercury provisioned$/.test(l)), 'the plain success line survives');
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
await ta('F16: a no-hook attach is honest — ok:true, dns_note written, event says SKIPPED', async () => {
|
|
1765
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1766
|
+
P.CONFIG.dnsHook = null; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1767
|
+
try {
|
|
1768
|
+
const { deps, events, dnsNotes } = attachRecorder({ apply: true });
|
|
1769
|
+
const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
|
|
1770
|
+
assert.equal(r.ok, true, 'an unset hook is unretryable — ok:false would burn the retry budget');
|
|
1771
|
+
assert.equal(dnsNotes.length, 1);
|
|
1772
|
+
assert.match(dnsNotes[0].note, /A record/, 'the owner is told the outstanding manual step');
|
|
1773
|
+
assert.match(dnsNotes[0].note, /203\.0\.113\.9/, 'the note names the target IP');
|
|
1774
|
+
assert.doesNotMatch(dnsNotes[0].note, /shortly|minute|second/i, 'no wall-clock promises (§5.2)');
|
|
1775
|
+
const detail = events.map((e) => e.detail).join('\n');
|
|
1776
|
+
assert.match(detail, /DNS SKIPPED/, 'the event stops claiming DNS was re-asserted');
|
|
1777
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
await ta('F16: a hooked attach CLEARS a stale dns_note', async () => {
|
|
1781
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1782
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1783
|
+
try {
|
|
1784
|
+
const { deps, dnsNotes } = attachRecorder({ apply: true });
|
|
1785
|
+
const r = await P.domainAttachInstance({ ...activeStandalone }, deps);
|
|
1786
|
+
assert.equal(r.ok, true);
|
|
1787
|
+
assert.deepEqual(dnsNotes, [{ id: activeStandalone.id, note: null }], 'a successful attach retires the note');
|
|
1788
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
await ta('F19: a RETIRED intent records its reason on the instance without changing its status', async () => {
|
|
1792
|
+
let claims = 0; const resolved = []; const statusWrites = [];
|
|
1793
|
+
const deps = {
|
|
1794
|
+
apply: true, log: () => {},
|
|
1795
|
+
db: { query: async () => ({ rows: [] }) },
|
|
1796
|
+
provisioning: {
|
|
1797
|
+
claimNextIntent: async () => (claims++ === 0
|
|
1798
|
+
? { id: 9, instance_id: 18, action: 'domain-attach', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
1799
|
+
// an ACTIVE instance with no domain → the attach leg returns ok:false (unretryable)
|
|
1800
|
+
getInstanceById: async () => ({ ...activeStandalone, domain: null }),
|
|
1801
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
1802
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
1803
|
+
recordEvent: async () => {},
|
|
1804
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1805
|
+
},
|
|
1806
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
1807
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
1808
|
+
};
|
|
1809
|
+
const r = await P.cmdRunIntents(deps);
|
|
1810
|
+
assert.equal(r.errors, 1);
|
|
1811
|
+
assert.equal(resolved[0].state, 'error', 'the intent retires');
|
|
1812
|
+
assert.equal(statusWrites.length, 1, 'the retire is surfaced on the instance');
|
|
1813
|
+
assert.equal(statusWrites[0].s, 'active', 'status write is a NO-OP on status — a healthy instance is never marked broken');
|
|
1814
|
+
assert.match(statusWrites[0].patch.error_note, /no domain recorded/, 'the reason survives where the card reads it');
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
await ta('F19: a NON-final failure re-pends the intent and writes nothing on the instance', async () => {
|
|
1818
|
+
let claims = 0; const resolved = []; const statusWrites = []; const updates = [];
|
|
1819
|
+
const deps = {
|
|
1820
|
+
apply: true, log: () => {},
|
|
1821
|
+
db: { query: async (sql) => { if (/UPDATE provisioning_intents/.test(sql)) updates.push(sql); return { rows: [] }; } },
|
|
1822
|
+
provisioning: {
|
|
1823
|
+
claimNextIntent: async () => (claims++ === 0
|
|
1824
|
+
? { id: 10, instance_id: 18, action: 'domain-attach', attempts: 1 } : null),
|
|
1825
|
+
getInstanceById: async () => ({ ...activeStandalone, domain: null }),
|
|
1826
|
+
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
1827
|
+
setInstanceStatus: async (_db, _id, s, patch) => { statusWrites.push({ s, patch }); },
|
|
1828
|
+
recordEvent: async () => {},
|
|
1829
|
+
claimNextOAuthManifest: async () => null, resolveOAuthManifest: async () => {},
|
|
1830
|
+
},
|
|
1831
|
+
exec: () => ({ ok: true }), writeFile: () => ({ ok: true }), doApi: null,
|
|
1832
|
+
checkDnsTokenScope: async () => ({ reachable: true, zone: 'z' }),
|
|
1833
|
+
};
|
|
1834
|
+
await P.cmdRunIntents(deps);
|
|
1835
|
+
assert.equal(resolved.length, 0, 'not resolved — it re-pends for a later tick');
|
|
1836
|
+
assert.equal(updates.length, 1, 're-pended via the intents table');
|
|
1837
|
+
assert.equal(statusWrites.length, 0, 'no instance write until the intent actually retires');
|
|
1838
|
+
});
|
|
1839
|
+
|
|
1840
|
+
console.log('\nreview-panel hardening (task 1002720 — the loopback/latch/birth-note seams):');
|
|
1841
|
+
|
|
1842
|
+
await ta('a PASSING loopback probe on a DOMAIN-bearing instance records NO verdict — the address claim belongs to the sweep', async () => {
|
|
1843
|
+
const { deps, probes } = truthRecorder();
|
|
1844
|
+
const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1845
|
+
assert.equal(r.ok, true);
|
|
1846
|
+
assert.equal(r.healthy, true);
|
|
1847
|
+
assert.deepEqual(probes, [], 'loopback-up proves the process, not the domain — "Answering at" must stay unearned');
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
await ta('a FAILED loopback probe is a verdict on any shape — recorded even with a domain', async () => {
|
|
1851
|
+
const { deps, probes } = truthRecorder({ healthzFails: true });
|
|
1852
|
+
const r = await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, deps);
|
|
1853
|
+
assert.equal(r.healthy, false);
|
|
1854
|
+
assert.deepEqual(probes, [false], 'a dead process answers nowhere — down is down');
|
|
1855
|
+
});
|
|
1856
|
+
|
|
1857
|
+
await ta('birth-path dns_note: written when the hook is unset, CLEARED when a later run has one', async () => {
|
|
1858
|
+
const savedHook = P.CONFIG.dnsHook, savedPub = P.CONFIG.publicIp;
|
|
1859
|
+
P.CONFIG.publicIp = '203.0.113.5';
|
|
1860
|
+
try {
|
|
1861
|
+
P.CONFIG.dnsHook = null;
|
|
1862
|
+
const a = truthRecorder();
|
|
1863
|
+
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, a.deps);
|
|
1864
|
+
assert.equal(a.dnsNotes.length, 1, 'the no-hook birth writes the note');
|
|
1865
|
+
assert.match(a.dnsNotes[0], /A record/);
|
|
1866
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh';
|
|
1867
|
+
const b = truthRecorder();
|
|
1868
|
+
await P.provisionInstance({ ...coTenant, domain: 'mercury.example.com' }, b.deps);
|
|
1869
|
+
assert.deepEqual(b.dnsNotes, [null], 'a hooked run (e.g. a revive) retires the stale note');
|
|
1870
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.publicIp = savedPub; }
|
|
1871
|
+
});
|
|
1872
|
+
|
|
1873
|
+
await ta('a successful domain-attach releases the F19 error_note latch', async () => {
|
|
1874
|
+
const savedHook = P.CONFIG.dnsHook, savedCp = P.CONFIG.controlPlaneIp, savedRemote = P.CONFIG.remoteHost;
|
|
1875
|
+
P.CONFIG.dnsHook = '/opt/infra/box-dns-cloudflare.sh'; P.CONFIG.controlPlaneIp = '203.0.113.9'; P.CONFIG.remoteHost = null;
|
|
1876
|
+
try {
|
|
1877
|
+
const { deps } = attachRecorder({ apply: true });
|
|
1878
|
+
const cleared = [];
|
|
1879
|
+
deps.provisioning.clearErrorNote = async (_db, id) => { cleared.push(id); };
|
|
1880
|
+
const r = await P.domainAttachInstance({ ...activeStandalone, error_note: 'DNS preflight failed' }, deps);
|
|
1881
|
+
assert.equal(r.ok, true);
|
|
1882
|
+
assert.deepEqual(cleared, [activeStandalone.id], 'the "we stopped trying" paragraph must not outlive its recovery');
|
|
1883
|
+
} finally { P.CONFIG.dnsHook = savedHook; P.CONFIG.controlPlaneIp = savedCp; P.CONFIG.remoteHost = savedRemote; }
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
await ta('UNUSABLE creds from a successful conversion still report codeSpent — no re-POST of a burned code', async () => {
|
|
1887
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1888
|
+
const deps = {
|
|
1889
|
+
apply: true, privileged: false, log: () => {}, sleep: async () => {},
|
|
1890
|
+
convertManifestCode: async () => ({ clientId: 'short', clientSecret: 'also-short' }),
|
|
1891
|
+
exec: () => ({ ok: true, stdout: '' }), writeFile: () => ({ ok: true }),
|
|
1892
|
+
};
|
|
1893
|
+
const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
|
|
1894
|
+
assert.equal(r.ok, false);
|
|
1895
|
+
assert.match(r.error, /unusable creds/);
|
|
1896
|
+
assert.equal(r.codeSpent, true, 'the conversion RESOLVED — GitHub burned the code even though the creds are junk');
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1633
1899
|
console.log(`\nprovision: ${passed} passed, ${failed} failed`);
|
|
1634
1900
|
process.exit(failed ? 1 : 0);
|
|
@@ -189,6 +189,46 @@ await ta('same domain again is idempotent — 200, changed:false, nothing re-que
|
|
|
189
189
|
assert.equal(enqueueCalls.length, 0);
|
|
190
190
|
});
|
|
191
191
|
|
|
192
|
+
await ta('same domain again RE-ENQUEUES when the wiring stopped — the owner re-kick (task 1002720 / F19)', async () => {
|
|
193
|
+
// A retired domain-attach intent left its error_note; re-sending the same
|
|
194
|
+
// address is the owner's re-kick. The idempotent no-op above still holds for
|
|
195
|
+
// a healthy instance — this branch needs the demonstrated failure.
|
|
196
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'DNS preflight failed' } });
|
|
197
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
198
|
+
assert.equal(res.status, 200, res.raw);
|
|
199
|
+
assert.equal(res.body.changed, false);
|
|
200
|
+
assert.equal(res.body.queued, true, 'the attach intent re-queues');
|
|
201
|
+
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
202
|
+
assert.equal(updateCalls.length, 0, 'the domain itself is untouched');
|
|
203
|
+
assert.match(res.body.message, /Wiring that address again/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await ta('a verified-DOWN address re-enqueues too; an open intent keeps the no-op', async () => {
|
|
207
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', last_verified_up: false } });
|
|
208
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
209
|
+
assert.equal(res.body.queued, true, 'probed-down wiring may be re-kicked');
|
|
210
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
|
|
211
|
+
const savedGetOpen = provisioning.getOpenIntent;
|
|
212
|
+
provisioning.getOpenIntent = async () => ({ id: 5, action: 'domain-attach' });
|
|
213
|
+
try {
|
|
214
|
+
const res2 = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
215
|
+
assert.equal(res2.body.queued, false, 'an in-flight intent is never doubled');
|
|
216
|
+
assert.equal(enqueueCalls.length, 0);
|
|
217
|
+
} finally { provisioning.getOpenIntent = savedGetOpen; }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('re-kick honors enqueueIntent created:false — a racing enqueue is never double-reported', async () => {
|
|
221
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', error_note: 'x' } });
|
|
222
|
+
const savedEnqueue = provisioning.enqueueIntent;
|
|
223
|
+
provisioning.enqueueIntent = async (...a) => { enqueueCalls.push({ action: a[2] }); return { intent: { id: 5 }, created: false }; };
|
|
224
|
+
try {
|
|
225
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
226
|
+
assert.equal(res.status, 200, res.raw);
|
|
227
|
+
assert.equal(res.body.queued, false, 'queued mirrors created, like the sibling attach path');
|
|
228
|
+
assert.match(res.body.message, /already being wired/);
|
|
229
|
+
} finally { provisioning.enqueueIntent = savedEnqueue; }
|
|
230
|
+
});
|
|
231
|
+
|
|
192
232
|
await ta('a DIFFERENT domain already set is a truthful 409 domain_already_set (re-pointing is not this route)', async () => {
|
|
193
233
|
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'old.cloudbongos.com' } });
|
|
194
234
|
const res = await post('/provisioning/instances/18/domain', { domain: 'new.cloudbongos.com' });
|
|
@@ -117,6 +117,39 @@ test('the empty state points a new owner at the wizard view — never at a hall'
|
|
|
117
117
|
assert.doesNotMatch(body, /Enter the Hall/i);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test('teardown copy states what the runner actually does, per hosting shape (F17)', async () => {
|
|
121
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
122
|
+
// the DECIDED policy: shared shapes keep the database; a dedicated machine
|
|
123
|
+
// takes its database with it — the copy branches on the shape field
|
|
124
|
+
assert.match(body, /The database is kept/, 'the kept-shape promise is present');
|
|
125
|
+
assert.match(body, /database goes with (it|the machine)/, 'the dedicated branch exists');
|
|
126
|
+
assert.match(body, /hosting_shape === 'dedicated'/, 'shape-driven, not flat prose');
|
|
127
|
+
assert.match(body, /repository is untouched|repository stays yours|repository is still yours/i);
|
|
128
|
+
// the pre-walk over-claim can never come back
|
|
129
|
+
assert.doesNotMatch(body, /server and database go away/i);
|
|
130
|
+
assert.doesNotMatch(body, /database (is|will be) (deleted|dropped|destroyed|removed)/i);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the done panel reads the open intent — a queued revive is never told to start another (F22)', async () => {
|
|
134
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
135
|
+
const fn = (body.match(/function renderProgress\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
136
|
+
assert.ok(fn, 'renderProgress() must be extractable');
|
|
137
|
+
assert.match(fn, /open_intent/, 'the strip is chosen from the open intent, not status alone');
|
|
138
|
+
assert.match(fn, /revive/, 'a queued revive on a torn-down instance gets the revive vocabulary');
|
|
139
|
+
// a requested instance ALWAYS has its provision intent open — the status
|
|
140
|
+
// branch must win there or it goes dead and diverges from the mine card
|
|
141
|
+
assert.match(fn, /!== 'requested'/, 'requested speaks its status, not its (always-present) intent');
|
|
142
|
+
assert.match(body, /inst\.open_intent = data\.open_intent \|\| null/, 'the watch hands the intent through');
|
|
143
|
+
// teardown speaks its own stage names in the done panel, in its own palette
|
|
144
|
+
assert.match(fn, /'teardown'/, 'the teardown flow is used');
|
|
145
|
+
assert.match(body, /\.prog\.flowDown/, 'the .prog strip has the teardown palette (transcribed from .strip.flowDown)');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('the address row surfaces the manual-DNS note the runner recorded (F16)', async () => {
|
|
149
|
+
const { body } = await request('/projects', 'game.test.local');
|
|
150
|
+
assert.match(body, /inst\.dns_note/, 'the card reads the dns_note the runner writes');
|
|
151
|
+
});
|
|
152
|
+
|
|
120
153
|
test('the watch give-up hands the owner their projects view — "the hall has it" is dead', async () => {
|
|
121
154
|
// the wizard (and its 33-minute watch cap) lives on /projects now
|
|
122
155
|
const { body } = await request('/projects', 'game.test.local');
|