@bongos/core 1.19.59 → 1.19.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +37 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +104 -3
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/provisioning/migrations/provisioning_005_domain_attach.sql +20 -0
- package/modules/provisioning/provisioning.js +67 -5
- package/modules/provisioning/routes/provisioning.js +126 -12
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision.js +92 -4
- package/src/module-api.js +5 -1
- package/tests/provision.mjs +134 -0
- package/tests/provisioning_domain_attach.mjs +289 -0
- package/tests/provisioning_teardown_intent.mjs +267 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/provisioning_domain_attach.mjs
|
|
2
|
+
//
|
|
3
|
+
// The domain-attach path (task 1002692, gap 5 of the creation-flow audit / idea
|
|
4
|
+
// 1000677): before this, NO route could give an EXISTING instance its address — a
|
|
5
|
+
// slug re-request silently dropped the domain in the body, and a domainless
|
|
6
|
+
// instance (e.g. Spike Harbor, instance 18) was stuck domainless forever, which
|
|
7
|
+
// also blocked GitHub sign-in (the github-app manifest route 409s without a domain).
|
|
8
|
+
//
|
|
9
|
+
// Covers:
|
|
10
|
+
// • updateInstanceDomain / getInstanceByDomain — the service slice (recording fakes)
|
|
11
|
+
// • INTENT_ACTIONS lockstep — 'domain-attach' rides the intent queue (migration 005)
|
|
12
|
+
// • POST /provisioning/instances/:id/domain — validate + record + enqueue, with
|
|
13
|
+
// truthful answers for every refusal (bad domain, taken domain, wrong state,
|
|
14
|
+
// open-intent conflict) — driven over the real mounted router
|
|
15
|
+
// • POST /provisioning/instances — a slug re-request carrying a domain now SAYS
|
|
16
|
+
// the domain was ignored instead of silently dropping it
|
|
17
|
+
// • POST /provisioning/instances/:id/github-app — accepts the newly-addressed
|
|
18
|
+
// instance (the domain_required 409 clears once a domain is recorded)
|
|
19
|
+
//
|
|
20
|
+
// DB-free: data-access fns are stubbed on the cached module object (the
|
|
21
|
+
// provisioning_onboard_mode.mjs pattern). No network.
|
|
22
|
+
//
|
|
23
|
+
// Run: node tests/provisioning_domain_attach.mjs
|
|
24
|
+
|
|
25
|
+
import { strict as assert } from 'node:assert';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
31
|
+
|
|
32
|
+
let passed = 0, failed = 0;
|
|
33
|
+
function t(name, fn) {
|
|
34
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
35
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
36
|
+
}
|
|
37
|
+
async function ta(name, fn) {
|
|
38
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
39
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('service slice — updateInstanceDomain / getInstanceByDomain:');
|
|
43
|
+
|
|
44
|
+
await ta('updateInstanceDomain: parameterized UPDATE of only the domain (+updated_at), returns the row', async () => {
|
|
45
|
+
const calls = [];
|
|
46
|
+
const db = { query: async (sql, params) => {
|
|
47
|
+
calls.push({ sql, params });
|
|
48
|
+
return { rows: [{ id: 18, domain: params[1] }] };
|
|
49
|
+
} };
|
|
50
|
+
const row = await provisioning.updateInstanceDomain(db, 18, 'spike.cloudbongos.com');
|
|
51
|
+
assert.equal(row.domain, 'spike.cloudbongos.com');
|
|
52
|
+
assert.match(calls[0].sql, /UPDATE provisioning_instances/);
|
|
53
|
+
assert.match(calls[0].sql, /SET domain = \$2, updated_at = now\(\)/, 'touches ONLY the domain column');
|
|
54
|
+
assert.deepEqual(calls[0].params, [18, 'spike.cloudbongos.com']);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await ta('getInstanceByDomain: case-insensitive exact match, parameterized', async () => {
|
|
58
|
+
const calls = [];
|
|
59
|
+
const db = { query: async (sql, params) => {
|
|
60
|
+
calls.push({ sql, params });
|
|
61
|
+
return { rows: params[0] === 'spike.cloudbongos.com' ? [{ id: 18 }] : [] };
|
|
62
|
+
} };
|
|
63
|
+
const hit = await provisioning.getInstanceByDomain(db, 'SPIKE.cloudbongos.com');
|
|
64
|
+
assert.equal(hit.id, 18, 'matches regardless of stored/asked case');
|
|
65
|
+
assert.match(calls[0].sql, /lower\(domain\) = \$1/);
|
|
66
|
+
assert.equal(await provisioning.getInstanceByDomain(db, 'other.example.com'), null);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
t("INTENT_ACTIONS includes 'domain-attach' (lockstep with the provisioning_005 CHECK)", () => {
|
|
70
|
+
assert.ok(provisioning.INTENT_ACTIONS.includes('domain-attach'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── the routes, over a real mounted router (stubbed data access) ──────────────────
|
|
74
|
+
let instance = null; // what getInstanceById returns
|
|
75
|
+
let domainHolder = null; // what getInstanceByDomain returns
|
|
76
|
+
let updateCalls = [];
|
|
77
|
+
let enqueueCalls = [];
|
|
78
|
+
let enqueueResult = null; // what enqueueIntent reports
|
|
79
|
+
let createResult = null; // what createInstance reports (re-request tests)
|
|
80
|
+
let manifestRows = [];
|
|
81
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
82
|
+
provisioning.getInstanceByDomain = async (_db, d) => domainHolder;
|
|
83
|
+
provisioning.updateInstanceDomain = async (_db, id, domain) => {
|
|
84
|
+
updateCalls.push({ id, domain });
|
|
85
|
+
instance = { ...instance, domain };
|
|
86
|
+
return { ...instance, id };
|
|
87
|
+
};
|
|
88
|
+
provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
|
|
89
|
+
enqueueCalls.push({ id, action, requestedBy });
|
|
90
|
+
return enqueueResult || { intent: { id: 9, action, state: 'pending' }, created: true };
|
|
91
|
+
};
|
|
92
|
+
provisioning.getOpenIntent = async () => null;
|
|
93
|
+
provisioning.createInstance = async () => createResult;
|
|
94
|
+
provisioning.recordEvent = async () => {};
|
|
95
|
+
provisioning.createOAuthManifest = async (_db, row) => { manifestRows.push(row); return { id: 1, ...row }; };
|
|
96
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
97
|
+
|
|
98
|
+
const api = require('../src/module-api.js');
|
|
99
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
100
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
101
|
+
api.branding = () => ({
|
|
102
|
+
domains: { publicOrigin: 'https://cb.test' },
|
|
103
|
+
identity: { productName: 'Cloud Bongos' },
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const express = require('express');
|
|
107
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
108
|
+
const app = express();
|
|
109
|
+
app.use(express.json());
|
|
110
|
+
app.use((_req, res, next) => {
|
|
111
|
+
res.fail = (code, opts, details) => {
|
|
112
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
113
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
114
|
+
};
|
|
115
|
+
next();
|
|
116
|
+
});
|
|
117
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
118
|
+
|
|
119
|
+
const server = app.listen(0);
|
|
120
|
+
await new Promise((r) => server.once('listening', r));
|
|
121
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
122
|
+
|
|
123
|
+
function post(path, body = {}) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const payload = JSON.stringify(body);
|
|
126
|
+
const req = http.request(`${base}${path}`, {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
129
|
+
}, (res) => {
|
|
130
|
+
let d = '';
|
|
131
|
+
res.on('data', (c) => { d += c; });
|
|
132
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
133
|
+
});
|
|
134
|
+
req.on('error', reject);
|
|
135
|
+
req.end(payload);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function reset({ inst } = {}) {
|
|
140
|
+
instance = inst || null;
|
|
141
|
+
domainHolder = null;
|
|
142
|
+
updateCalls = [];
|
|
143
|
+
enqueueCalls = [];
|
|
144
|
+
enqueueResult = null;
|
|
145
|
+
createResult = null;
|
|
146
|
+
manifestRows = [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
console.log('\nPOST /provisioning/instances/:id/domain — give an existing instance its address:');
|
|
150
|
+
|
|
151
|
+
await ta('ACTIVE domainless instance: domain is validated, recorded, and a domain-attach intent queues', async () => {
|
|
152
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null } });
|
|
153
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'Spike.CloudBongos.com.' });
|
|
154
|
+
assert.equal(res.status, 200, res.raw);
|
|
155
|
+
assert.equal(res.body.ok, true);
|
|
156
|
+
assert.equal(res.body.domain, 'spike.cloudbongos.com', 'normalized before storing');
|
|
157
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'spike.cloudbongos.com' }]);
|
|
158
|
+
assert.deepEqual(enqueueCalls.map((c) => c.action), ['domain-attach']);
|
|
159
|
+
assert.equal(res.body.queued, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await ta('a malformed domain is refused 400 before anything is written', async () => {
|
|
163
|
+
for (const bad of ['not a host', 'com', '', '-x.example.com']) {
|
|
164
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
|
|
165
|
+
const res = await post('/provisioning/instances/18/domain', { domain: bad });
|
|
166
|
+
assert.equal(res.status, 400, `${JSON.stringify(bad)}: ${res.raw}`);
|
|
167
|
+
assert.equal(res.body.error.code, 'bad_domain');
|
|
168
|
+
assert.equal(updateCalls.length, 0, 'nothing recorded');
|
|
169
|
+
assert.equal(enqueueCalls.length, 0, 'nothing queued');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await ta("another instance already holding the domain is a truthful 409 domain_taken (never a silent shadow)", async () => {
|
|
174
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
|
|
175
|
+
domainHolder = { id: 99, slug: 'other' };
|
|
176
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
177
|
+
assert.equal(res.status, 409, res.raw);
|
|
178
|
+
assert.equal(res.body.error.code, 'domain_taken');
|
|
179
|
+
assert.equal(updateCalls.length, 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await ta('same domain again is idempotent — 200, changed:false, nothing re-queued', async () => {
|
|
183
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' } });
|
|
184
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'SPIKE.cloudbongos.com' });
|
|
185
|
+
assert.equal(res.status, 200, res.raw);
|
|
186
|
+
assert.equal(res.body.changed, false);
|
|
187
|
+
assert.equal(updateCalls.length, 0);
|
|
188
|
+
assert.equal(enqueueCalls.length, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await ta('a DIFFERENT domain already set is a truthful 409 domain_already_set (re-pointing is not this route)', async () => {
|
|
192
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'old.cloudbongos.com' } });
|
|
193
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'new.cloudbongos.com' });
|
|
194
|
+
assert.equal(res.status, 409, res.raw);
|
|
195
|
+
assert.equal(res.body.error.code, 'domain_already_set');
|
|
196
|
+
assert.equal(res.body.error.details.current, 'old.cloudbongos.com', 'names the current address');
|
|
197
|
+
assert.equal(updateCalls.length, 0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await ta('REQUESTED instance: domain records with NO intent — the provision run wires it', async () => {
|
|
201
|
+
reset({ inst: { owner_builder_id: 7, slug: 'fresh', status: 'requested', domain: null } });
|
|
202
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'fresh.cloudbongos.com' });
|
|
203
|
+
assert.equal(res.status, 200, res.raw);
|
|
204
|
+
assert.equal(res.body.queued, false);
|
|
205
|
+
assert.deepEqual(updateCalls, [{ id: 18, domain: 'fresh.cloudbongos.com' }]);
|
|
206
|
+
assert.equal(enqueueCalls.length, 0, 'provision will wire it — no separate intent');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
await ta('an instance mid-flight (provisioning / tearing_down / torn_down) is a truthful 409', async () => {
|
|
210
|
+
for (const status of ['provisioning', 'tearing_down', 'torn_down']) {
|
|
211
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status, domain: null } });
|
|
212
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
213
|
+
assert.equal(res.status, 409, `${status}: ${res.raw}`);
|
|
214
|
+
assert.equal(res.body.error.code, 'domain_not_attachable');
|
|
215
|
+
assert.equal(res.body.error.details.status, status, 'names the real state');
|
|
216
|
+
assert.equal(updateCalls.length, 0, `${status}: nothing recorded`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await ta('an open non-domain intent (e.g. a teardown) is a truthful 409 intent_conflict, not a false success', async () => {
|
|
221
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
|
|
222
|
+
enqueueResult = { intent: { id: 5, action: 'teardown', state: 'pending' }, created: false };
|
|
223
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
224
|
+
assert.equal(res.status, 409, res.raw);
|
|
225
|
+
assert.equal(res.body.error.code, 'intent_conflict');
|
|
226
|
+
assert.equal(res.body.error.details.action, 'teardown', 'names the real blocker');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
await ta('an already-open domain-attach intent is idempotent — 200 queued:false "already being wired"', async () => {
|
|
230
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
|
|
231
|
+
enqueueResult = { intent: { id: 5, action: 'domain-attach', state: 'pending' }, created: false };
|
|
232
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'spike.cloudbongos.com' });
|
|
233
|
+
assert.equal(res.status, 200, res.raw);
|
|
234
|
+
assert.equal(res.body.queued, false);
|
|
235
|
+
assert.match(res.body.message, /already/i);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await ta('not the owner → 404 (own-scoped; existence not leaked)', async () => {
|
|
239
|
+
reset({ inst: { owner_builder_id: 42, slug: 'theirs', status: 'active', domain: null } });
|
|
240
|
+
const res = await post('/provisioning/instances/18/domain', { domain: 'x.cloudbongos.com' });
|
|
241
|
+
assert.equal(res.status, 404, res.raw);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
console.log('\nPOST /provisioning/instances — slug re-requests are truthful about the domain:');
|
|
245
|
+
|
|
246
|
+
await ta('re-request with a domain on an existing instance SAYS the domain was ignored (and where to send it)', async () => {
|
|
247
|
+
reset();
|
|
248
|
+
createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: null }, created: false };
|
|
249
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
|
|
250
|
+
assert.equal(res.status, 200, res.raw);
|
|
251
|
+
assert.ok(res.body.domain_ignored, 'the drop is surfaced, not silent');
|
|
252
|
+
assert.equal(res.body.domain_ignored.requested, 'spike.cloudbongos.com');
|
|
253
|
+
assert.match(res.body.domain_ignored.hint, /\/domain/, 'points at the domain-attach route');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await ta('re-request with the SAME domain the instance already has → no ignored flag (nothing was dropped)', async () => {
|
|
257
|
+
reset();
|
|
258
|
+
createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' }, created: false };
|
|
259
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
|
|
260
|
+
assert.equal(res.status, 200, res.raw);
|
|
261
|
+
assert.equal(res.body.domain_ignored, undefined);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await ta('a fresh create with a domain is untouched (the domain is stored at creation — nothing ignored)', async () => {
|
|
265
|
+
reset();
|
|
266
|
+
createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'new', status: 'requested', domain: 'new.cloudbongos.com' }, created: true };
|
|
267
|
+
const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'standalone', domain: 'new.cloudbongos.com' });
|
|
268
|
+
assert.equal(res.status, 201, res.raw);
|
|
269
|
+
assert.equal(res.body.domain_ignored, undefined);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
console.log('\nPOST /provisioning/instances/:id/github-app — accepts the newly-addressed instance:');
|
|
273
|
+
|
|
274
|
+
await ta('domainless instance still 409s domain_required; once addressed, the manifest mints', async () => {
|
|
275
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: null } });
|
|
276
|
+
const before = await post('/provisioning/instances/18/github-app', {});
|
|
277
|
+
assert.equal(before.status, 409, before.raw);
|
|
278
|
+
assert.equal(before.body.error.code, 'domain_required');
|
|
279
|
+
|
|
280
|
+
instance = { ...instance, domain: 'spike.cloudbongos.com' };
|
|
281
|
+
const after = await post('/provisioning/instances/18/github-app', {});
|
|
282
|
+
assert.equal(after.status, 200, after.raw);
|
|
283
|
+
assert.equal(manifestRows.length, 1, 'a manifest attempt row was recorded');
|
|
284
|
+
assert.match(after.body.manifest.url, /spike\.cloudbongos\.com/, 'the manifest points at the new address');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
server.close();
|
|
288
|
+
console.log(`\nprovisioning_domain_attach: ${passed} passed, ${failed} failed`);
|
|
289
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -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);
|