@bongos/core 1.19.59 → 1.19.60
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 +19 -14
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/api/openapi.json +4 -0
- package/modules/provisioning/provisioning.js +33 -2
- package/modules/provisioning/routes/provisioning.js +35 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/provisioning_teardown_intent.mjs +267 -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.60",
|
|
6
|
+
"core_contract": "1.19.60",
|
|
7
|
+
"source_commit": "ce8873473b894cf991da712833f18dc9e5f3f88c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-11T23:
|
|
9
|
+
"built_at": "2026-08-11T23:51:23.264Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 291,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1218,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1530,
|
|
20
|
+
"tree_sha256": "acefccdf8f346aff9dfc425d717f82bc3d87cc5c680d303bd19ffc334dc9b195",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "34b9ebfa393ad892c959bf356c1727188e71c61a8ff9ee0d0eca66433a51614e"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1312,7 +1312,7 @@
|
|
|
1312
1312
|
{
|
|
1313
1313
|
"path": "docs/api/openapi.json",
|
|
1314
1314
|
"mode": "0000644",
|
|
1315
|
-
"sha256": "
|
|
1315
|
+
"sha256": "bad0bdd6cdd07d66ed8b3bbc2bb2751ce1d6a0a819bc2d455952f5b0c18cea64"
|
|
1316
1316
|
},
|
|
1317
1317
|
{
|
|
1318
1318
|
"path": "docs/architecture.md",
|
|
@@ -3732,7 +3732,7 @@
|
|
|
3732
3732
|
{
|
|
3733
3733
|
"path": "modules/provisioning/provisioning.js",
|
|
3734
3734
|
"mode": "0000644",
|
|
3735
|
-
"sha256": "
|
|
3735
|
+
"sha256": "655d7e07f8a50cd12420c67e0cb12ff4f94098e53b218b0985f866b9433e409f"
|
|
3736
3736
|
},
|
|
3737
3737
|
{
|
|
3738
3738
|
"path": "modules/provisioning/rate-limit.js",
|
|
@@ -3742,7 +3742,7 @@
|
|
|
3742
3742
|
{
|
|
3743
3743
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
3744
3744
|
"mode": "0000644",
|
|
3745
|
-
"sha256": "
|
|
3745
|
+
"sha256": "c0a1a015e20c446513ae435075379e1c46cfe0e492574ff9f64d9ef39b081e4b"
|
|
3746
3746
|
},
|
|
3747
3747
|
{
|
|
3748
3748
|
"path": "modules/provisioning/tests/provisioning.mjs",
|
|
@@ -4032,12 +4032,12 @@
|
|
|
4032
4032
|
{
|
|
4033
4033
|
"path": "package-lock.json",
|
|
4034
4034
|
"mode": "0000644",
|
|
4035
|
-
"sha256": "
|
|
4035
|
+
"sha256": "862808e3b9e59ac80baa7bcdda60a08dc8f0f31152750ea99264a5aac24c1140"
|
|
4036
4036
|
},
|
|
4037
4037
|
{
|
|
4038
4038
|
"path": "package.json",
|
|
4039
4039
|
"mode": "0000644",
|
|
4040
|
-
"sha256": "
|
|
4040
|
+
"sha256": "51b6e2905a863ad6c9d25eecf376a8db1f61ee960e00dd5b12f4d5aefcf36625"
|
|
4041
4041
|
},
|
|
4042
4042
|
{
|
|
4043
4043
|
"path": "public-docs/index.html",
|
|
@@ -5657,7 +5657,7 @@
|
|
|
5657
5657
|
{
|
|
5658
5658
|
"path": "src/module-api.js",
|
|
5659
5659
|
"mode": "0000644",
|
|
5660
|
-
"sha256": "
|
|
5660
|
+
"sha256": "dd71e36bfd25965ca6b720f1fdbd94ba47553260915176d693f5246fdaac8d2e"
|
|
5661
5661
|
},
|
|
5662
5662
|
{
|
|
5663
5663
|
"path": "src/module-loader/loader.js",
|
|
@@ -7149,6 +7149,11 @@
|
|
|
7149
7149
|
"mode": "0000644",
|
|
7150
7150
|
"sha256": "9a41d64fba3a8cc8ffdbccb0aea363ca6ccb63909cfeefd4bd0b05ebab052403"
|
|
7151
7151
|
},
|
|
7152
|
+
{
|
|
7153
|
+
"path": "tests/provisioning_teardown_intent.mjs",
|
|
7154
|
+
"mode": "0000644",
|
|
7155
|
+
"sha256": "c5ebacf7e29be4e6afecedf5b8593eef750d497e0c502cfa2be92a89b2cff0a1"
|
|
7156
|
+
},
|
|
7152
7157
|
{
|
|
7153
7158
|
"path": "tests/pruned_lockfile_integrity.mjs",
|
|
7154
7159
|
"mode": "0000644",
|
|
@@ -246,10 +246,10 @@ export interface PostOverrideRequestsIdDecideRequest { decision: string; note?:
|
|
|
246
246
|
export interface PostProjectsInviteRequest { client_id: string; github_login: string }
|
|
247
247
|
export interface PostProjectsRequest { origin: string; name: string; tagline?: string; art_url?: string }
|
|
248
248
|
export interface PostProjectsResponse { project: unknown }
|
|
249
|
-
export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
|
|
249
|
+
export interface PostProvisioningInstancesIdForceTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
|
|
250
250
|
export interface PostProvisioningInstancesIdGithubAppRequest { account?: string }
|
|
251
251
|
export interface PostProvisioningInstancesIdGithubAppResponse { state: unknown; account: unknown; post_url: unknown; personal_url: unknown; org_url: unknown; org_login: unknown; manifest: unknown; redirect_url: unknown; expires_at: unknown }
|
|
252
|
-
export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; message: unknown }
|
|
252
|
+
export interface PostProvisioningInstancesIdTeardownResponse { ok: boolean; queued: unknown; action: unknown; status: unknown; superseded: unknown; message: unknown }
|
|
253
253
|
export interface PostProvisioningInstancesRequest { slug: string; target_ref?: string; hosting_shape?: string; domain?: string; tier?: string; onboard_mode?: string }
|
|
254
254
|
export interface PostSearchMarkRequest { chunk_id?: StringifiableId; useful?: unknown }
|
|
255
255
|
export interface PostSearchRequest { expand?: unknown; q?: string; source_kind?: string; limit?: unknown }
|
package/docs/api/openapi.json
CHANGED
|
@@ -15637,6 +15637,7 @@
|
|
|
15637
15637
|
"queued": {},
|
|
15638
15638
|
"action": {},
|
|
15639
15639
|
"status": {},
|
|
15640
|
+
"superseded": {},
|
|
15640
15641
|
"message": {}
|
|
15641
15642
|
},
|
|
15642
15643
|
"required": [
|
|
@@ -15644,6 +15645,7 @@
|
|
|
15644
15645
|
"queued",
|
|
15645
15646
|
"action",
|
|
15646
15647
|
"status",
|
|
15648
|
+
"superseded",
|
|
15647
15649
|
"message"
|
|
15648
15650
|
]
|
|
15649
15651
|
},
|
|
@@ -15691,6 +15693,7 @@
|
|
|
15691
15693
|
"queued": {},
|
|
15692
15694
|
"action": {},
|
|
15693
15695
|
"status": {},
|
|
15696
|
+
"superseded": {},
|
|
15694
15697
|
"message": {}
|
|
15695
15698
|
},
|
|
15696
15699
|
"required": [
|
|
@@ -15698,6 +15701,7 @@
|
|
|
15698
15701
|
"queued",
|
|
15699
15702
|
"action",
|
|
15700
15703
|
"status",
|
|
15704
|
+
"superseded",
|
|
15701
15705
|
"message"
|
|
15702
15706
|
]
|
|
15703
15707
|
},
|
|
@@ -38,6 +38,12 @@ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest'];
|
|
|
38
38
|
// Mirrors dev-box canTransition('deprovision', …): a requested/torn_down/error
|
|
39
39
|
// instance has nothing running to tear down via the happy path.
|
|
40
40
|
const TEARDOWN_FROM = new Set(['provisioning', 'active', 'tearing_down']);
|
|
41
|
+
// The states from which (re)enqueueing a 'provision' intent is meaningful. An
|
|
42
|
+
// 'active' instance needs no standup — blindly re-enqueueing one on a slug
|
|
43
|
+
// re-request left a stale open provision intent that then blocked the owner's
|
|
44
|
+
// teardown on the action-blind ux_provisioning_intents_open (task 1002691);
|
|
45
|
+
// 'tearing_down' is mid-reclaim (any open slot belongs to the teardown).
|
|
46
|
+
const PROVISION_ENQUEUE_FROM = new Set(['requested', 'provisioning', 'error', 'torn_down']);
|
|
41
47
|
|
|
42
48
|
// Monthly cost estimate (USD) for a dedicated droplet tier — VISIBILITY only
|
|
43
49
|
// (ADR 0111 §6), never a bill. A small local table so this module needs no
|
|
@@ -350,6 +356,31 @@ async function enqueueIntent(db, instanceId, action, requestedBy) {
|
|
|
350
356
|
}
|
|
351
357
|
}
|
|
352
358
|
|
|
359
|
+
// Queue a teardown, superseding a stale PENDING provision intent if one holds the
|
|
360
|
+
// open slot (task 1002691: ux_provisioning_intents_open is action-blind, so the old
|
|
361
|
+
// blind enqueue collided with such an intent and the API falsely said "teardown
|
|
362
|
+
// already queued" — the instance leaked). A pending provision is flipped to 'error'
|
|
363
|
+
// (the state CHECK has no 'superseded'; last_error says why) so the runner never
|
|
364
|
+
// stands it up. A RUNNING provision is mid-flight infra work and is never yanked —
|
|
365
|
+
// the caller gets { conflict: 'open_intent' } with the blocking intent so the route
|
|
366
|
+
// can answer truthfully. Each statement is atomic and the index stays the arbiter:
|
|
367
|
+
// any interleaving with the runner or a concurrent enqueue lands on one of these
|
|
368
|
+
// truthful outcomes.
|
|
369
|
+
async function enqueueTeardown(db, instanceId, requestedBy) {
|
|
370
|
+
const { rows: superseded } = await db.query(
|
|
371
|
+
`UPDATE provisioning_intents
|
|
372
|
+
SET state = 'error', last_error = 'superseded: teardown requested',
|
|
373
|
+
updated_at = now(), resolved_at = now()
|
|
374
|
+
WHERE instance_id = $1 AND action = 'provision' AND state = 'pending'
|
|
375
|
+
RETURNING id`,
|
|
376
|
+
[instanceId]
|
|
377
|
+
);
|
|
378
|
+
const { intent, created } = await enqueueIntent(db, instanceId, 'teardown', requestedBy);
|
|
379
|
+
if (created) return { intent, created: true, superseded: superseded.length > 0 };
|
|
380
|
+
if (intent.action === 'teardown') return { intent, created: false, superseded: false };
|
|
381
|
+
return { intent, created: false, superseded: false, conflict: 'open_intent' };
|
|
382
|
+
}
|
|
383
|
+
|
|
353
384
|
// The instance's currently-open (pending|running) intent, if any.
|
|
354
385
|
async function getOpenIntent(db, instanceId) {
|
|
355
386
|
const { rows } = await db.query(
|
|
@@ -540,7 +571,7 @@ async function costLedger(db, { limit = 500 } = {}) {
|
|
|
540
571
|
module.exports = {
|
|
541
572
|
// constants
|
|
542
573
|
INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, INTENT_ACTIONS, TEARDOWN_FROM,
|
|
543
|
-
TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
574
|
+
PROVISION_ENQUEUE_FROM, TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
|
|
544
575
|
// pure
|
|
545
576
|
isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
|
|
546
577
|
// pure — GitHub App Manifest flow (task 2080)
|
|
@@ -549,7 +580,7 @@ module.exports = {
|
|
|
549
580
|
isProvisionedHostname,
|
|
550
581
|
getInstanceById, getInstanceBySlug, listInstancesForOwner, listAllInstances,
|
|
551
582
|
createInstance, setInstanceStatus,
|
|
552
|
-
enqueueIntent, getOpenIntent, claimNextIntent, resolveIntent,
|
|
583
|
+
enqueueIntent, enqueueTeardown, getOpenIntent, claimNextIntent, resolveIntent,
|
|
553
584
|
recordEvent, instanceStateForSlug, costLedger,
|
|
554
585
|
// data access — GitHub App Manifest attempts (task 2080)
|
|
555
586
|
createOAuthManifest, getOAuthManifestByState, attachManifestCode,
|
|
@@ -271,16 +271,22 @@ module.exports = function provisioningRoutes() {
|
|
|
271
271
|
if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
|
|
272
272
|
return res.fail('not_teardownable', 409, { status: inst.status });
|
|
273
273
|
}
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
// Same truthful outcome mapping as the owner's teardown (task 1002691).
|
|
275
|
+
const r = await provisioning.enqueueTeardown(pool, id, `api:archon:${req.builder.id}`);
|
|
276
|
+
if (r.conflict) {
|
|
277
|
+
return res.fail('intent_conflict', { status: 409,
|
|
278
|
+
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
279
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
280
|
+
}
|
|
281
|
+
if (r.created) {
|
|
276
282
|
await provisioning.recordEvent(pool, {
|
|
277
283
|
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
|
|
278
|
-
detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}`,
|
|
284
|
+
detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}${r.superseded ? ' (superseded a stale provision intent)' : ''}`,
|
|
279
285
|
actor: `api:archon:${req.builder.id}`,
|
|
280
286
|
}).catch(() => {});
|
|
281
287
|
}
|
|
282
|
-
res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
|
|
283
|
-
message: created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
288
|
+
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
289
|
+
message: r.created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
284
290
|
} catch (err) {
|
|
285
291
|
console.error('[provisioning] POST /provisioning/instances/:id/force-teardown', err);
|
|
286
292
|
res.fail('teardown_failed', { status: 500, message: 'internal error' });
|
|
@@ -338,8 +344,16 @@ module.exports = function provisioningRoutes() {
|
|
|
338
344
|
if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
|
|
339
345
|
return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
|
|
340
346
|
}
|
|
341
|
-
// Enqueue the provision intent (idempotent: an already-open intent is returned)
|
|
342
|
-
|
|
347
|
+
// Enqueue the provision intent (idempotent: an already-open intent is returned)
|
|
348
|
+
// — but ONLY when the instance actually needs standing up. Blindly enqueueing on
|
|
349
|
+
// a re-request of an ACTIVE instance left a stale open provision intent that then
|
|
350
|
+
// blocked the owner's teardown on the action-blind open-intent index (task 1002691).
|
|
351
|
+
let intent = null, intentCreated = false;
|
|
352
|
+
if (created || provisioning.PROVISION_ENQUEUE_FROM.has(instance.status)) {
|
|
353
|
+
({ intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self'));
|
|
354
|
+
} else {
|
|
355
|
+
intent = await provisioning.getOpenIntent(pool, instance.id);
|
|
356
|
+
}
|
|
343
357
|
if (created) {
|
|
344
358
|
await provisioning.recordEvent(pool, {
|
|
345
359
|
instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
|
|
@@ -416,15 +430,24 @@ module.exports = function provisioningRoutes() {
|
|
|
416
430
|
if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
|
|
417
431
|
return res.fail('not_teardownable', { status: 409, message: `Instance is '${inst.status}' — nothing to tear down right now.`, details: { status: inst.status } });
|
|
418
432
|
}
|
|
419
|
-
|
|
420
|
-
|
|
433
|
+
// enqueueTeardown supersedes a stale pending provision intent and reports each
|
|
434
|
+
// outcome distinctly, so "already queued" is only ever said when it is TRUE
|
|
435
|
+
// (task 1002691 — the old blind enqueue lied here and the instance leaked).
|
|
436
|
+
const r = await provisioning.enqueueTeardown(pool, id, 'api:self');
|
|
437
|
+
if (r.conflict) {
|
|
438
|
+
return res.fail('intent_conflict', { status: 409,
|
|
439
|
+
message: `A '${r.intent.action}' run is currently ${r.intent.state} for this instance — the teardown could not be queued; retry in a moment.`,
|
|
440
|
+
details: { action: r.intent.action, state: r.intent.state } });
|
|
441
|
+
}
|
|
442
|
+
if (r.created) {
|
|
421
443
|
await provisioning.recordEvent(pool, {
|
|
422
444
|
instanceId: id, ownerBuilderId: req.builder.id, event: 'teardown',
|
|
423
|
-
detail: 'teardown requested by owner'
|
|
445
|
+
detail: r.superseded ? 'teardown requested by owner (superseded a stale provision intent)' : 'teardown requested by owner',
|
|
446
|
+
actor: 'api:self',
|
|
424
447
|
}).catch(() => {});
|
|
425
448
|
}
|
|
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.' });
|
|
449
|
+
res.json({ ok: true, queued: r.created, action: 'teardown', status: inst.status, superseded: r.superseded,
|
|
450
|
+
message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
|
|
428
451
|
} catch (err) {
|
|
429
452
|
console.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
|
|
430
453
|
res.fail('teardown_failed', { status: 500, message: 'internal error' });
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.60",
|
|
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.60",
|
|
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.60",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -545,8 +545,10 @@ 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)
|
|
548
550
|
// ---------------------------------------------------------------------------
|
|
549
|
-
const CORE_VERSION = '1.19.
|
|
551
|
+
const CORE_VERSION = '1.19.60'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
550
552
|
|
|
551
553
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
552
554
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// tests/provisioning_teardown_intent.mjs
|
|
2
|
+
//
|
|
3
|
+
// Teardown vs the action-blind open-intent index (task 1002691). Repro'd live on
|
|
4
|
+
// instance 16: POST /instances re-enqueued a 'provision' intent for an ALREADY-ACTIVE
|
|
5
|
+
// instance (created:false), and the owner's later teardown enqueue collided with that
|
|
6
|
+
// stale open intent on ux_provisioning_intents_open (per-instance, action-blind). The
|
|
7
|
+
// route trusted created:false and answered "A teardown is already queued" — a lie; no
|
|
8
|
+
// teardown was ever recorded and the instance leaked.
|
|
9
|
+
//
|
|
10
|
+
// Covers both layers of the fix:
|
|
11
|
+
// • enqueueTeardown — supersedes a stale PENDING provision intent, never yanks
|
|
12
|
+
// a RUNNING one, and reports each outcome distinctly
|
|
13
|
+
// (fake db that ENFORCES the unique index, so the collision
|
|
14
|
+
// is reproduced faithfully)
|
|
15
|
+
// • the teardown routes — answer truthfully per outcome: "already queued" only when
|
|
16
|
+
// the open intent IS a teardown; a mid-flight provision is a
|
|
17
|
+
// 409 conflict, not a false success
|
|
18
|
+
// • POST /instances — stops enqueueing a provision intent for an instance that
|
|
19
|
+
// needs no standing up (the enabling misstep)
|
|
20
|
+
//
|
|
21
|
+
// DB-free: the pool is never queried — the data-access fns the routes need are stubbed
|
|
22
|
+
// on the cached module object (the provisioning_onboard_mode.mjs pattern). No network.
|
|
23
|
+
//
|
|
24
|
+
// Run: node tests/provisioning_teardown_intent.mjs
|
|
25
|
+
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import http from 'node:http';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
32
|
+
|
|
33
|
+
let passed = 0, failed = 0;
|
|
34
|
+
function t(name, fn) {
|
|
35
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
36
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
37
|
+
}
|
|
38
|
+
async function ta(name, fn) {
|
|
39
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
40
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── enqueueTeardown: the collision mechanics, against an index-faithful fake ──────
|
|
44
|
+
// A stateful fake of provisioning_intents that ENFORCES ux_provisioning_intents_open
|
|
45
|
+
// (at most ONE open pending|running intent per instance, action-blind) — so the exact
|
|
46
|
+
// collision the bug rode on is reproduced, not approximated. Dispatches on SQL shape.
|
|
47
|
+
function fakeIntentDb(seed = []) {
|
|
48
|
+
let nextId = 1;
|
|
49
|
+
const rows = seed.map((r) => ({ id: nextId++, requested_by: null, last_error: null, resolved_at: null, ...r }));
|
|
50
|
+
const open = (instanceId) => rows.filter((r) =>
|
|
51
|
+
String(r.instance_id) === String(instanceId) && (r.state === 'pending' || r.state === 'running'));
|
|
52
|
+
return {
|
|
53
|
+
rows,
|
|
54
|
+
query: async (sql, params) => {
|
|
55
|
+
if (/INSERT INTO provisioning_intents/.test(sql)) {
|
|
56
|
+
const [instance_id, action, requested_by] = params;
|
|
57
|
+
if (open(instance_id).length) {
|
|
58
|
+
throw Object.assign(
|
|
59
|
+
new Error('duplicate key value violates unique constraint "ux_provisioning_intents_open"'),
|
|
60
|
+
{ code: '23505' });
|
|
61
|
+
}
|
|
62
|
+
const row = { id: nextId++, instance_id, action, requested_by, state: 'pending', last_error: null, resolved_at: null };
|
|
63
|
+
rows.push(row);
|
|
64
|
+
return { rows: [row] };
|
|
65
|
+
}
|
|
66
|
+
if (/UPDATE provisioning_intents/.test(sql) && /state = 'error'/.test(sql)) {
|
|
67
|
+
// The supersede's guard must live in the SQL itself: pending-only (a running
|
|
68
|
+
// provision is the runner's mid-flight work) and provision-only (an open
|
|
69
|
+
// teardown is left alone for the idempotent path).
|
|
70
|
+
assert.match(sql, /action = 'provision' AND state = 'pending'/);
|
|
71
|
+
const hit = rows.filter((r) =>
|
|
72
|
+
String(r.instance_id) === String(params[0]) && r.action === 'provision' && r.state === 'pending');
|
|
73
|
+
for (const r of hit) { r.state = 'error'; r.last_error = 'superseded: teardown requested'; }
|
|
74
|
+
return { rows: hit.map((r) => ({ id: r.id })) };
|
|
75
|
+
}
|
|
76
|
+
if (/SELECT \* FROM provisioning_intents/.test(sql)) {
|
|
77
|
+
const o = open(params[0]);
|
|
78
|
+
return { rows: o.length ? [o[o.length - 1]] : [] };
|
|
79
|
+
}
|
|
80
|
+
throw new Error(`fakeIntentDb: unexpected SQL: ${String(sql).slice(0, 80)}`);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
console.log('enqueueTeardown — teardown vs the action-blind open-intent index (task 1002691):');
|
|
86
|
+
|
|
87
|
+
await ta('a stale PENDING provision intent is superseded — the teardown actually queues (the leak, fixed)', async () => {
|
|
88
|
+
const db = fakeIntentDb([{ instance_id: 16, action: 'provision', state: 'pending' }]);
|
|
89
|
+
const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
|
|
90
|
+
assert.equal(r.created, true, 'the teardown was queued');
|
|
91
|
+
assert.equal(r.superseded, true, 'reports that a provision intent was canceled');
|
|
92
|
+
assert.equal(r.intent.action, 'teardown');
|
|
93
|
+
assert.equal(r.intent.state, 'pending');
|
|
94
|
+
const prov = db.rows.find((x) => x.action === 'provision');
|
|
95
|
+
assert.equal(prov.state, 'error', 'the stale provision intent is closed, not left open');
|
|
96
|
+
assert.match(prov.last_error, /superseded/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
await ta('a RUNNING provision intent is never yanked — a distinct conflict outcome, not a false "already queued"', async () => {
|
|
100
|
+
const db = fakeIntentDb([{ instance_id: 16, action: 'provision', state: 'running' }]);
|
|
101
|
+
const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
|
|
102
|
+
assert.equal(r.created, false);
|
|
103
|
+
assert.equal(r.conflict, 'open_intent', 'the route needs a distinct outcome to answer truthfully');
|
|
104
|
+
assert.equal(r.intent.action, 'provision', 'the blocking intent is surfaced as-is');
|
|
105
|
+
assert.equal(r.intent.state, 'running', 'still running — the mid-flight work was not canceled');
|
|
106
|
+
assert.equal(db.rows.filter((x) => x.action === 'teardown').length, 0, 'no teardown row was inserted');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await ta('an already-open TEARDOWN is idempotent — created:false and NO conflict ("already queued" is now true)', async () => {
|
|
110
|
+
const db = fakeIntentDb([{ instance_id: 16, action: 'teardown', state: 'pending' }]);
|
|
111
|
+
const r = await provisioning.enqueueTeardown(db, 16, 'api:self');
|
|
112
|
+
assert.equal(r.created, false);
|
|
113
|
+
assert.equal(r.conflict, undefined, 'no conflict — this is the one genuine already-queued case');
|
|
114
|
+
assert.equal(r.intent.action, 'teardown');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await ta('a clean queue just enqueues (no supersede)', async () => {
|
|
118
|
+
const db = fakeIntentDb([]);
|
|
119
|
+
const r = await provisioning.enqueueTeardown(db, 16, 'api:archon:1');
|
|
120
|
+
assert.equal(r.created, true);
|
|
121
|
+
assert.equal(r.superseded, false);
|
|
122
|
+
assert.equal(r.intent.action, 'teardown');
|
|
123
|
+
assert.equal(r.intent.requested_by, 'api:archon:1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
t('PROVISION_ENQUEUE_FROM: an active or tearing-down instance never re-enqueues a provision intent', () => {
|
|
127
|
+
assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('active'), false, 'active — the enabling misstep');
|
|
128
|
+
assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has('tearing_down'), false, 'tearing_down — mid-reclaim');
|
|
129
|
+
for (const s of ['requested', 'provisioning', 'error', 'torn_down']) {
|
|
130
|
+
assert.equal(provisioning.PROVISION_ENQUEUE_FROM.has(s), true, `${s} may (re)provision`);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ── the routes: truthful responses per outcome, over a real mounted router ────────
|
|
135
|
+
let teardownOutcome = null; // what the next enqueueTeardown call reports
|
|
136
|
+
let teardownCalls = [];
|
|
137
|
+
let enqueueIntentCalls = [];
|
|
138
|
+
let createResult = null; // what the next createInstance call reports
|
|
139
|
+
provisioning.getInstanceById = async (_db, id) =>
|
|
140
|
+
({ id, owner_builder_id: 7, slug: 'spike', status: 'active' });
|
|
141
|
+
provisioning.enqueueTeardown = async (_db, id, requestedBy) => {
|
|
142
|
+
teardownCalls.push({ id, requestedBy });
|
|
143
|
+
return teardownOutcome;
|
|
144
|
+
};
|
|
145
|
+
provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
|
|
146
|
+
enqueueIntentCalls.push({ id, action, requestedBy });
|
|
147
|
+
return { intent: { id: 99, action, state: 'pending' }, created: true };
|
|
148
|
+
};
|
|
149
|
+
provisioning.getOpenIntent = async () => null;
|
|
150
|
+
provisioning.createInstance = async () => createResult;
|
|
151
|
+
provisioning.recordEvent = async () => {};
|
|
152
|
+
|
|
153
|
+
const api = require('../src/module-api.js');
|
|
154
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
155
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
156
|
+
|
|
157
|
+
const express = require('express');
|
|
158
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
159
|
+
const app = express();
|
|
160
|
+
app.use(express.json());
|
|
161
|
+
app.use((_req, res, next) => {
|
|
162
|
+
res.fail = (code, opts, details) => {
|
|
163
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
164
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
165
|
+
};
|
|
166
|
+
next();
|
|
167
|
+
});
|
|
168
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
169
|
+
|
|
170
|
+
const server = app.listen(0);
|
|
171
|
+
await new Promise((r) => server.once('listening', r));
|
|
172
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
173
|
+
|
|
174
|
+
function post(path, body = {}) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const payload = JSON.stringify(body);
|
|
177
|
+
const req = http.request(`${base}${path}`, {
|
|
178
|
+
method: 'POST',
|
|
179
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
180
|
+
}, (res) => {
|
|
181
|
+
let d = '';
|
|
182
|
+
res.on('data', (c) => { d += c; });
|
|
183
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
184
|
+
});
|
|
185
|
+
req.on('error', reject);
|
|
186
|
+
req.end(payload);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
console.log('\nPOST /provisioning/instances/:id/teardown — truthful responses:');
|
|
191
|
+
|
|
192
|
+
await ta('a queued teardown says so (and surfaces that a stale provision was superseded)', async () => {
|
|
193
|
+
teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: true };
|
|
194
|
+
const res = await post('/provisioning/instances/16/teardown');
|
|
195
|
+
assert.equal(res.status, 200, res.raw);
|
|
196
|
+
assert.equal(res.body.queued, true);
|
|
197
|
+
assert.equal(res.body.superseded, true);
|
|
198
|
+
assert.match(res.body.message, /torn down/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
await ta('"already queued" is said ONLY when the open intent IS a teardown', async () => {
|
|
202
|
+
teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: false, superseded: false };
|
|
203
|
+
const res = await post('/provisioning/instances/16/teardown');
|
|
204
|
+
assert.equal(res.status, 200, res.raw);
|
|
205
|
+
assert.equal(res.body.queued, false);
|
|
206
|
+
assert.match(res.body.message, /already queued/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await ta('a mid-flight provision is a 409 conflict — never the false "teardown already queued" (the live repro)', async () => {
|
|
210
|
+
teardownOutcome = { intent: { action: 'provision', state: 'running' }, created: false, superseded: false, conflict: 'open_intent' };
|
|
211
|
+
const res = await post('/provisioning/instances/16/teardown');
|
|
212
|
+
assert.equal(res.status, 409, res.raw);
|
|
213
|
+
assert.equal(res.body.error.code, 'intent_conflict');
|
|
214
|
+
assert.equal(res.body.error.details.action, 'provision', 'names the real blocker');
|
|
215
|
+
assert.doesNotMatch(String(res.body.error.message), /already queued/i, 'the old lie is gone');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
console.log('\nPOST /provisioning/instances/:id/force-teardown — same truthful mapping (archon):');
|
|
219
|
+
|
|
220
|
+
await ta('force-teardown surfaces the same 409 conflict instead of a false success', async () => {
|
|
221
|
+
teardownOutcome = { intent: { action: 'provision', state: 'running' }, created: false, superseded: false, conflict: 'open_intent' };
|
|
222
|
+
const res = await post('/provisioning/instances/16/force-teardown');
|
|
223
|
+
assert.equal(res.status, 409, res.raw);
|
|
224
|
+
assert.equal(res.body.error.code, 'intent_conflict');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await ta('force-teardown queues + reports supersede like the owner route', async () => {
|
|
228
|
+
teardownOutcome = { intent: { action: 'teardown', state: 'pending' }, created: true, superseded: true };
|
|
229
|
+
const res = await post('/provisioning/instances/16/force-teardown');
|
|
230
|
+
assert.equal(res.status, 200, res.raw);
|
|
231
|
+
assert.equal(res.body.queued, true);
|
|
232
|
+
assert.equal(res.body.superseded, true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
console.log('\nPOST /provisioning/instances — no provision intent for an instance needing no standup:');
|
|
236
|
+
|
|
237
|
+
await ta('re-requesting an ACTIVE instance does NOT enqueue a provision intent (the enabling misstep, fixed)', async () => {
|
|
238
|
+
createResult = { instance: { id: 42, owner_builder_id: 7, slug: 'spike', status: 'active' }, created: false };
|
|
239
|
+
enqueueIntentCalls = [];
|
|
240
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone' });
|
|
241
|
+
assert.equal(res.status, 200, res.raw);
|
|
242
|
+
assert.equal(enqueueIntentCalls.length, 0, 'no provision intent was enqueued');
|
|
243
|
+
assert.equal(res.body.queued, false);
|
|
244
|
+
assert.equal(res.body.open_intent, null);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await ta('re-requesting an ERRORED instance still enqueues (the retry path is preserved)', async () => {
|
|
248
|
+
createResult = { instance: { id: 42, owner_builder_id: 7, slug: 'spike', status: 'error' }, created: false };
|
|
249
|
+
enqueueIntentCalls = [];
|
|
250
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone' });
|
|
251
|
+
assert.equal(res.status, 200, res.raw);
|
|
252
|
+
assert.equal(enqueueIntentCalls.length, 1, 'the provision retry was enqueued');
|
|
253
|
+
assert.equal(enqueueIntentCalls[0].action, 'provision');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await ta('a FRESH instance always enqueues its provision intent (unchanged happy path)', async () => {
|
|
257
|
+
createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'fresh', status: 'requested' }, created: true };
|
|
258
|
+
enqueueIntentCalls = [];
|
|
259
|
+
const res = await post('/provisioning/instances', { slug: 'fresh', hosting_shape: 'standalone' });
|
|
260
|
+
assert.equal(res.status, 201, res.raw);
|
|
261
|
+
assert.equal(enqueueIntentCalls.length, 1);
|
|
262
|
+
assert.equal(enqueueIntentCalls[0].action, 'provision');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
server.close();
|
|
266
|
+
console.log(`\nprovisioning_teardown_intent: ${passed} passed, ${failed} failed`);
|
|
267
|
+
process.exit(failed ? 1 : 0);
|