@bongos/core 1.19.999 → 1.19.1001
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 +116 -91
- package/.claude/skills/collab-review/SKILL.md +24 -13
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +155 -5
- package/docs/api-reference.md +6 -4
- package/docs/copy-inventory.md +18 -13
- package/docs/copy-registry.json +70 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +79 -0
- package/modules/hall-ui/public/collab.css +60 -0
- package/modules/hall-ui/public/collab.js +134 -5
- package/modules/lifecycle/help-requests.js +118 -8
- package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
- package/modules/lifecycle/routes/help-requests.js +93 -13
- package/modules/provisioning/capacity.js +3 -3
- package/modules/provisioning/control-plane-guard.js +1 -1
- package/modules/provisioning/core-upgrade.js +7 -7
- package/modules/provisioning/cost-ledger.js +74 -0
- package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
- package/modules/provisioning/onboard-plan.js +1 -1
- package/modules/provisioning/paid-shape-gate.js +28 -15
- package/modules/provisioning/provisioning.js +20 -39
- package/modules/provisioning/routes/provisioning.js +4 -3
- package/modules/provisioning/routes/repo-private.js +1 -1
- package/modules/provisioning/tests/liveness.mjs +16 -8
- package/modules/provisioning/tests/provisioning.mjs +1 -1
- package/modules/public-landing/public/projects.html +26 -27
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/init.js +12 -4
- package/scripts/gds/onboard.js +1 -1
- package/scripts/gds/provision-capacity.js +6 -1
- package/scripts/gds/provision-config.js +6 -3
- package/scripts/gds/provision-core-upgrade.js +8 -8
- package/scripts/gds/provision-disconnect.js +2 -2
- package/scripts/gds/provision-process.js +2 -2
- package/scripts/gds/provision-repo-private.js +1 -1
- package/scripts/gds/provision-repo.js +13 -3
- package/scripts/gds/provision-teardown.js +2 -2
- package/scripts/gds/provision-units.js +2 -2
- package/scripts/gds/provision.js +6 -6
- package/src/module-api.js +1 -1
- package/tests/collab_page.mjs +140 -7
- package/tests/core_upgrade_door.mjs +1 -1
- package/tests/core_upgrade_runner.mjs +20 -20
- package/tests/deploy_page_projects.mjs +5 -5
- package/tests/hall_collab_world.mjs +6 -2
- package/tests/help_requests.mjs +201 -6
- package/tests/hub_disconnect_ui.mjs +4 -1
- package/tests/init.mjs +6 -6
- package/tests/onboard.mjs +5 -4
- package/tests/project_catalog_backfill.mjs +1 -1
- package/tests/project_catalog_bridge.mjs +2 -2
- package/tests/project_catalog_routes.mjs +4 -4
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +22 -5
- package/tests/projects_hub_repo_private.mjs +1 -1
- package/tests/provision.mjs +32 -32
- package/tests/provision_byo_host.mjs +1 -1
- package/tests/provision_caddy_wiring.mjs +1 -1
- package/tests/provision_disconnect.mjs +3 -3
- package/tests/provision_repo_private.mjs +1 -1
- package/tests/provision_restart.mjs +5 -5
- package/tests/provision_settings_apply.mjs +4 -4
- package/tests/provision_teardown_address.mjs +2 -2
- package/tests/provisioning_capacity.mjs +40 -34
- package/tests/provisioning_capacity_gate.mjs +5 -12
- package/tests/provisioning_cost_ledger.mjs +98 -0
- package/tests/provisioning_default_domain.mjs +1 -1
- package/tests/provisioning_domain_attach.mjs +4 -4
- package/tests/provisioning_domain_unique.mjs +2 -2
- package/tests/provisioning_error_note_scope.mjs +1 -1
- package/tests/provisioning_module_selection.mjs +5 -5
- package/tests/provisioning_onboard_mode.mjs +2 -2
- package/tests/provisioning_paid_shape_gate.mjs +57 -44
- package/tests/provisioning_repo_private_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +11 -4
- package/tests/provisioning_settings_apply.mjs +12 -5
- package/tests/provisioning_settings_env.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +3 -3
- package/tests/public_landing_projects.mjs +13 -6
- package/tests/upgrade.mjs +2 -2
|
@@ -78,17 +78,23 @@ t('ceilingFor: an explicit override wins; an unmeasured box falls to the ADR sma
|
|
|
78
78
|
assert.equal(DEFAULT_CEILING, 15);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
t('placementBox:
|
|
82
|
-
assert.deepEqual(placementBox('
|
|
83
|
-
assert.deepEqual(placementBox('
|
|
84
|
-
assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['
|
|
81
|
+
t('placementBox: cloud-host → control plane always; co-tenant → the remote box only under remote-exec; byo-host → no box of ours', () => {
|
|
82
|
+
assert.deepEqual(placementBox('cloud-host', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
|
|
83
|
+
assert.deepEqual(placementBox('cloud-host', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['cloud-host'], fixedMb: 750 });
|
|
84
|
+
assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
|
|
85
85
|
assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
86
|
+
// ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and no
|
|
87
|
+
// shared ceiling. This used to be one of TWO null answers — 'dedicated' was the other,
|
|
88
|
+
// null for a weaker reason (our droplet, just not a shared one) — until ADR 0327
|
|
89
|
+
// decision 4 removed that shape. remoteHost must not change the answer.
|
|
90
90
|
assert.equal(placementBox('byo-host', {}), null);
|
|
91
91
|
assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
|
|
92
|
+
// Everything else still falls through to the control-plane box — including the
|
|
93
|
+
// 'control-plane' row itself, which is unchanged by this rename. So 'byo-host' is now the
|
|
94
|
+
// ONLY null answer, where there used to be two; the skip reason it produces is named for
|
|
95
|
+
// the question ('no-shared-box') rather than for the shape, so removing the other one did
|
|
96
|
+
// not leave a label naming a shape that no longer exists.
|
|
97
|
+
assert.deepEqual(placementBox('control-plane', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
|
|
92
98
|
});
|
|
93
99
|
|
|
94
100
|
t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
|
|
@@ -119,14 +125,14 @@ t('capacitySummary: resize_due at 80% (12 of 15), remaining never negative, verd
|
|
|
119
125
|
|
|
120
126
|
await ta('countPlaced counts ONLY provisioning + active rows of the box\'s shapes, and can exclude the row being re-run', async () => {
|
|
121
127
|
const db = fakeDb(10);
|
|
122
|
-
assert.equal(await countPlaced(db, ['
|
|
123
|
-
assert.deepEqual(db.queries[0].params, [['
|
|
128
|
+
assert.equal(await countPlaced(db, ['cloud-host']), 10);
|
|
129
|
+
assert.deepEqual(db.queries[0].params, [['cloud-host'], PLACED_STATES]);
|
|
124
130
|
assert.deepEqual(PLACED_STATES, ['provisioning', 'active'], 'error rows hold no running unit (live fleet, 2026-09-16) and must not take a slot');
|
|
125
131
|
assert.doesNotMatch(db.queries[0].sql, /id <>/, 'no exclusion unless asked');
|
|
126
|
-
await countPlaced(db, ['
|
|
132
|
+
await countPlaced(db, ['cloud-host', 'co-tenant'], { excludeId: 42 });
|
|
127
133
|
assert.match(db.queries[1].sql, /id <> \$3/);
|
|
128
|
-
assert.deepEqual(db.queries[1].params, [['
|
|
129
|
-
assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['
|
|
134
|
+
assert.deepEqual(db.queries[1].params, [['cloud-host', 'co-tenant'], PLACED_STATES, 42]);
|
|
135
|
+
assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['cloud-host']), 0, 'a recording fake with no rows reads 0, not NaN');
|
|
130
136
|
});
|
|
131
137
|
|
|
132
138
|
await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts the control plane\'s shapes', async () => {
|
|
@@ -135,7 +141,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
|
|
|
135
141
|
const c = await fleetCapacity(db, { meminfo: MEMINFO_2GB, remoteHost: null });
|
|
136
142
|
assert.equal(c.box, 'control-plane'); assert.equal(c.placed, 10); assert.equal(c.ceiling, 15); assert.equal(c.source, 'measured');
|
|
137
143
|
assert.equal(c.mem_available_mb, 1108); assert.equal(c.resize_due, false); assert.equal(c.verdict.ok, true);
|
|
138
|
-
assert.deepEqual(db.queries[0].params[0], ['
|
|
144
|
+
assert.deepEqual(db.queries[0].params[0], ['cloud-host', 'co-tenant']);
|
|
139
145
|
const remote = await fleetCapacity(fakeDb(3), { meminfo: MEMINFO_4GB, remoteHost: 'cohost' });
|
|
140
146
|
assert.equal(remote.ceiling, 39, 'the control plane\'s own 4 GB ceiling, not the overflow box\'s');
|
|
141
147
|
// The env override reaches the summary, and is read per call.
|
|
@@ -147,7 +153,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
|
|
|
147
153
|
|
|
148
154
|
console.log('\nthe runner preflight (scripts/gds/provision-capacity.js):');
|
|
149
155
|
|
|
150
|
-
const
|
|
156
|
+
const cloudHost = { id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
|
|
151
157
|
|
|
152
158
|
// Recording deps: which executor was asked, what was logged, which events landed.
|
|
153
159
|
function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB, boxMeminfo = MEMINFO_4GB } = {}) {
|
|
@@ -164,11 +170,11 @@ function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB
|
|
|
164
170
|
};
|
|
165
171
|
}
|
|
166
172
|
|
|
167
|
-
await ta('a
|
|
173
|
+
await ta('a cloud-host with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
|
|
168
174
|
const h = preflightDeps({ placed: 10 });
|
|
169
|
-
const r = await capacityPreflight(
|
|
175
|
+
const r = await capacityPreflight(cloudHost, h.deps);
|
|
170
176
|
assert.equal(r.ok, true);
|
|
171
|
-
assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a
|
|
177
|
+
assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a cloud-host runs control-plane-local, so its box is measured there');
|
|
172
178
|
assert.equal(r.ceiling, 15); assert.equal(r.source, 'measured'); assert.equal(r.placed, 10);
|
|
173
179
|
assert.deepEqual(h.deps.db.queries[0].params[2], 7, 'the row being provisioned never counts itself');
|
|
174
180
|
assert.equal(h.events.length, 0, 'nothing to record when it fits');
|
|
@@ -177,7 +183,7 @@ await ta('a standalone with room: ok, measured on the CONTROL executor, the coun
|
|
|
177
183
|
|
|
178
184
|
await ta('a full box REFUSES with the owner-readable reason, records placement-refused, and returns (never throws)', async () => {
|
|
179
185
|
const h = preflightDeps({ placed: 15 });
|
|
180
|
-
const r = await capacityPreflight(
|
|
186
|
+
const r = await capacityPreflight(cloudHost, h.deps);
|
|
181
187
|
assert.equal(r.ok, false);
|
|
182
188
|
assert.match(r.error, /15 of 15 project slots are taken/);
|
|
183
189
|
assert.equal(h.events.length, 1);
|
|
@@ -189,7 +195,7 @@ await ta('a full box REFUSES with the owner-readable reason, records placement-r
|
|
|
189
195
|
|
|
190
196
|
await ta('the live memory floor refuses even with slots to spare (a stray unit the ledger cannot see)', async () => {
|
|
191
197
|
const h = preflightDeps({ placed: 10, controlMeminfo: MEMINFO_TIGHT });
|
|
192
|
-
const r = await capacityPreflight(
|
|
198
|
+
const r = await capacityPreflight(cloudHost, h.deps);
|
|
193
199
|
assert.equal(r.ok, false);
|
|
194
200
|
assert.equal(r.verdict.wall, 'memory');
|
|
195
201
|
assert.match(r.error, /117 MB of memory free/);
|
|
@@ -198,7 +204,7 @@ await ta('the live memory floor refuses even with slots to spare (a stray unit t
|
|
|
198
204
|
|
|
199
205
|
await ta('at 80% it still places, but the log names the resize trigger', async () => {
|
|
200
206
|
const h = preflightDeps({ placed: 12 });
|
|
201
|
-
const r = await capacityPreflight(
|
|
207
|
+
const r = await capacityPreflight(cloudHost, h.deps);
|
|
202
208
|
assert.equal(r.ok, true); assert.equal(r.resize_due, true);
|
|
203
209
|
assert.ok(h.logs.some((l) => /at or past 80%: schedule the resize \(task 1003923\)/.test(l)), h.logs.join(' | '));
|
|
204
210
|
});
|
|
@@ -209,35 +215,35 @@ await ta('a co-tenant under remote-exec is measured on the BOX executor against
|
|
|
209
215
|
CONFIG.remoteHost = 'cohost';
|
|
210
216
|
try {
|
|
211
217
|
const h = preflightDeps({ placed: 40 });
|
|
212
|
-
const r = await capacityPreflight({ ...
|
|
218
|
+
const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'co-tenant' }, h.deps);
|
|
213
219
|
assert.deepEqual(h.asked, [['box', MEMINFO_CMD]]);
|
|
214
220
|
assert.equal(r.box, 'overflow'); assert.equal(r.ceiling, 41); assert.equal(r.ok, true);
|
|
215
221
|
assert.deepEqual(h.deps.db.queries[0].params[0], ['co-tenant'], 'only the shapes that share that box count');
|
|
216
222
|
} finally { CONFIG.remoteHost = prior; }
|
|
217
223
|
});
|
|
218
224
|
|
|
219
|
-
await ta('a
|
|
225
|
+
await ta('a shape with no box of ours: no measurement, no count, no refusal', async () => {
|
|
220
226
|
const h = preflightDeps({ placed: 99 });
|
|
221
|
-
const r = await capacityPreflight({ ...
|
|
222
|
-
assert.deepEqual(r, { ok: true, skipped: '
|
|
227
|
+
const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'byo-host' }, h.deps);
|
|
228
|
+
assert.deepEqual(r, { ok: true, skipped: 'no-shared-box' });
|
|
223
229
|
assert.equal(h.asked.length, 0); assert.equal(h.deps.db.queries.length, 0);
|
|
224
230
|
});
|
|
225
231
|
|
|
226
232
|
await ta('dry-run measures nothing and refuses nothing, even on a full box; an unmeasured --apply falls to the default and warns', async () => {
|
|
227
233
|
const dry = preflightDeps({ apply: false, placed: 15 });
|
|
228
|
-
const r = await capacityPreflight(
|
|
234
|
+
const r = await capacityPreflight(cloudHost, dry.deps);
|
|
229
235
|
assert.equal(r.ok, true); assert.equal(r.dryRun, true);
|
|
230
236
|
assert.equal(dry.events.length, 0);
|
|
231
237
|
assert.ok(dry.logs.some((l) => /would refuse if full/.test(l)));
|
|
232
238
|
// --apply with an executor that answers no stdout (a fake, or a box without procfs)
|
|
233
239
|
const blind = preflightDeps({ placed: 3, controlMeminfo: '' });
|
|
234
|
-
const b = await capacityPreflight(
|
|
240
|
+
const b = await capacityPreflight(cloudHost, blind.deps);
|
|
235
241
|
assert.equal(b.ok, true); assert.equal(b.source, 'default'); assert.equal(b.ceiling, DEFAULT_CEILING);
|
|
236
242
|
assert.ok(blind.logs.some((l) => /could not measure the box.*PROVISION_BOX_CEILING/.test(l)), blind.logs.join(' | '));
|
|
237
243
|
// …and an executor that throws is the same as one that answers nothing.
|
|
238
244
|
const throwing = preflightDeps({ placed: 3 });
|
|
239
245
|
throwing.deps.controlExec = () => { throw new Error('ssh down'); };
|
|
240
|
-
assert.equal((await capacityPreflight(
|
|
246
|
+
assert.equal((await capacityPreflight(cloudHost, throwing.deps)).source, 'default');
|
|
241
247
|
});
|
|
242
248
|
|
|
243
249
|
await ta('statusCapacityLine prints "box at N of M" for the CLI', async () => {
|
|
@@ -272,7 +278,7 @@ function recorder({ apply, placed, meminfo = MEMINFO_2GB }) {
|
|
|
272
278
|
|
|
273
279
|
await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any status write or standup command', async () => {
|
|
274
280
|
const h = recorder({ apply: true, placed: 15 });
|
|
275
|
-
const r = await P.provisionInstance(
|
|
281
|
+
const r = await P.provisionInstance(cloudHost, h.deps);
|
|
276
282
|
assert.equal(r.ok, false);
|
|
277
283
|
assert.match(r.error, /15 of 15 project slots/);
|
|
278
284
|
assert.deepEqual(h.statuses, [], 'never flipped to provisioning — a project that was not placed is not "provisioning"');
|
|
@@ -283,7 +289,7 @@ await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any s
|
|
|
283
289
|
|
|
284
290
|
await ta('provisionInstance(--apply) with room proceeds into the runbook exactly as before', async () => {
|
|
285
291
|
const h = recorder({ apply: true, placed: 10 });
|
|
286
|
-
const r = await P.provisionInstance({ ...
|
|
292
|
+
const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
|
|
287
293
|
assert.equal(r.ok, true);
|
|
288
294
|
assert.equal(h.statuses[0], 'provisioning');
|
|
289
295
|
assert.equal(h.cmds[0], MEMINFO_CMD, 'measured first');
|
|
@@ -292,7 +298,7 @@ await ta('provisionInstance(--apply) with room proceeds into the runbook exactly
|
|
|
292
298
|
|
|
293
299
|
await ta('provisionInstance(dry-run) on a full box still prints the whole plan — dry-run places nothing, so it refuses nothing', async () => {
|
|
294
300
|
const h = recorder({ apply: false, placed: 15 });
|
|
295
|
-
const r = await P.provisionInstance({ ...
|
|
301
|
+
const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
|
|
296
302
|
assert.equal(r.ok, true);
|
|
297
303
|
assert.ok(h.cmds.some((c) => /createdb lantern/.test(c)));
|
|
298
304
|
});
|
|
@@ -306,7 +312,7 @@ await ta('cmdRunIntents: a refused placement retires onto the card as error_note
|
|
|
306
312
|
db: fakeDb(15),
|
|
307
313
|
provisioning: {
|
|
308
314
|
claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: P.MAX_INTENT_ATTEMPTS } : null),
|
|
309
|
-
getInstanceById: async () => ({ ...
|
|
315
|
+
getInstanceById: async () => ({ ...cloudHost }),
|
|
310
316
|
resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
|
|
311
317
|
setInstanceStatus: async (_db, id, status, patch) => { statusWrites.push({ id, status, patch }); },
|
|
312
318
|
recordEvent: async (_db, e) => { events.push(e); },
|
|
@@ -335,7 +341,7 @@ await ta('cmdRunIntents: below the attempt cap a refusal goes back to pending wi
|
|
|
335
341
|
apply: true, log: () => {}, db,
|
|
336
342
|
provisioning: {
|
|
337
343
|
claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: 1 } : null),
|
|
338
|
-
getInstanceById: async () => ({ ...
|
|
344
|
+
getInstanceById: async () => ({ ...cloudHost }),
|
|
339
345
|
resolveIntent: async () => { throw new Error('must not resolve on a retryable refusal'); },
|
|
340
346
|
setInstanceStatus: async () => { throw new Error('must not touch the instance on a retryable refusal'); },
|
|
341
347
|
recordEvent: async () => {},
|
|
@@ -357,7 +363,7 @@ console.log('\nthe fleet API carries the same summary:');
|
|
|
357
363
|
// Stub the route's data access at the same seams tests/provisioning_paid_shape_gate.mjs
|
|
358
364
|
// uses; the capacity module object is shared with the route through the require cache.
|
|
359
365
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
360
|
-
provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: '
|
|
366
|
+
provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'cloud-host', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
|
|
361
367
|
const api = require('../src/module-api.js');
|
|
362
368
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
363
369
|
api.requirePermission = () => (_req, _res, next) => next();
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// naming N of M, before any row or intent exists.
|
|
9
9
|
//
|
|
10
10
|
// What these tests pin: the refusal lands before createInstance; a box the web tier cannot
|
|
11
|
-
// count (
|
|
11
|
+
// count (a remote co-host, or byo-host) is never refused here; a re-request of a row that
|
|
12
12
|
// already holds its slot stays idempotent, while a revive (which takes a slot) is counted;
|
|
13
13
|
// the live-memory floor stays the runner's; and a capacity read that fails ADMITS, because
|
|
14
14
|
// the runner remains the wall.
|
|
@@ -100,7 +100,7 @@ function reset({ count = 0, row = null, mem = MEMINFO_2GB, fails = false } = {})
|
|
|
100
100
|
delete process.env.PROVISION_REMOTE_HOST;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
const WIZARD = { slug: 'lantern', hosting_shape: '
|
|
103
|
+
const WIZARD = { slug: 'lantern', hosting_shape: 'cloud-host', no_address: true };
|
|
104
104
|
|
|
105
105
|
// ── the wall ──────────────────────────────────────────────────────────────────────
|
|
106
106
|
|
|
@@ -141,13 +141,6 @@ await ta('an absent shape takes the byo-host default, so a full box does NOT ref
|
|
|
141
141
|
|
|
142
142
|
// ── boxes the web tier cannot count are the runner's ──────────────────────────────
|
|
143
143
|
|
|
144
|
-
await ta('dedicated is its own box: never refused here, and the count is never read', async () => {
|
|
145
|
-
reset({ count: 15 });
|
|
146
|
-
const res = await post({ slug: 'lantern', hosting_shape: 'dedicated', no_address: true });
|
|
147
|
-
assert.equal(res.status, 201, res.raw);
|
|
148
|
-
assert.equal(capacityReads, 0);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
144
|
await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
|
|
152
145
|
// ADR 0323's new shape, and the strongest form of "not our box": there is no machine
|
|
153
146
|
// of ours to place it on at all, so a full control plane must not refuse it and the
|
|
@@ -176,14 +169,14 @@ await ta('the live-memory floor is left to the runner: slots free + memory tight
|
|
|
176
169
|
// ── re-requests ───────────────────────────────────────────────────────────────────
|
|
177
170
|
|
|
178
171
|
await ta('a re-request of the owner\'s ACTIVE project on a full box stays idempotent (no box_full)', async () => {
|
|
179
|
-
reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: '
|
|
172
|
+
reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'active' } });
|
|
180
173
|
const res = await post(WIZARD);
|
|
181
174
|
assert.notEqual(res.status, 409, res.raw);
|
|
182
175
|
assert.ok(res.status < 300, res.raw);
|
|
183
176
|
});
|
|
184
177
|
|
|
185
178
|
await ta('a revive of a torn-down project takes a slot, so a full box refuses it', async () => {
|
|
186
|
-
reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: '
|
|
179
|
+
reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'torn_down' } });
|
|
187
180
|
const res = await post(WIZARD);
|
|
188
181
|
assert.equal(res.status, 409, res.raw);
|
|
189
182
|
assert.equal(res.body.error.code, 'box_full');
|
|
@@ -199,7 +192,7 @@ await ta('a capacity read that fails ADMITS — the runner remains the wall', as
|
|
|
199
192
|
});
|
|
200
193
|
|
|
201
194
|
await ta('a malformed slug or shape gets the handler\'s 400, not box_full', async () => {
|
|
202
|
-
for (const body of [{ slug: 'Bad Slug!', hosting_shape: '
|
|
195
|
+
for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'cloud-host' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
|
|
203
196
|
reset({ count: 15 });
|
|
204
197
|
const res = await post(body);
|
|
205
198
|
assert.equal(res.status, 400, `${JSON.stringify(body)} → ${res.raw}`);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// tests/provisioning_cost_ledger.mjs — the fleet cost ledger asks two questions, one
|
|
2
|
+
// column each (task 1004148, ADR 0327): what a row costs US, and an estimate of what it
|
|
3
|
+
// costs its OWNER. A row neither can price reads null (unpriced), never 0 — the old
|
|
4
|
+
// single column reported a confident $0 for every project, with no way to tell
|
|
5
|
+
// "costs nothing" from "we don't know".
|
|
6
|
+
//
|
|
7
|
+
// DB-free: CI has no Postgres, so costLedger runs against a recording fake.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/provisioning_cost_ledger.mjs
|
|
10
|
+
import { strict as assert } from 'node:assert';
|
|
11
|
+
import { makeRunner } from './helpers.mjs';
|
|
12
|
+
import {
|
|
13
|
+
platformMonthlyUsd, ownerMonthlyUsd, costLedger, RENDER_PLAN_MONTHLY_USD,
|
|
14
|
+
} from '../modules/provisioning/cost-ledger.js';
|
|
15
|
+
|
|
16
|
+
const { test, summary } = makeRunner();
|
|
17
|
+
|
|
18
|
+
const PLAN = Object.keys(RENDER_PLAN_MONTHLY_USD)[0];
|
|
19
|
+
|
|
20
|
+
await test('a cloud-host with no plan named is unpriced for its owner, not $0', () => {
|
|
21
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), null);
|
|
22
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host' }), null);
|
|
23
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: '' }), null);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
await test('a cloud-host on a named plan is priced from the plan table', () => {
|
|
27
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), RENDER_PLAN_MONTHLY_USD[PLAN]);
|
|
28
|
+
assert.ok(RENDER_PLAN_MONTHLY_USD[PLAN] > 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
await test('an unrecognised plan is unpriced, including an Object.prototype key', () => {
|
|
32
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'pro-max+pro-64gb' }), null);
|
|
33
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'cloud-host', tier: 'constructor' }), null);
|
|
34
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 'toString' }), null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
await test('a cloud-host costs US a correct $0: it runs on its owner\'s own account', () => {
|
|
38
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: PLAN }), 0);
|
|
39
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'cloud-host', tier: null }), 0);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await test('byo-host: $0 to us, and unpriced for the owner (their own machine)', () => {
|
|
43
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'byo-host' }), 0);
|
|
44
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'byo-host' }), null);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await test('co-tenant shares our box: no owner is billed, and the box is priced once', () => {
|
|
48
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
|
|
49
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'co-tenant' }), 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await test('the control plane is OUR cost, priced by its droplet size; no size is unpriced', () => {
|
|
53
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 12);
|
|
54
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'control-plane', tier: null }), null);
|
|
55
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'control-plane', tier: 's-1vcpu-2gb' }), 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await test('an unknown shape is unpriced in both columns', () => {
|
|
59
|
+
assert.equal(platformMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
|
|
60
|
+
assert.equal(ownerMonthlyUsd({ hostingShape: 'dedicated', tier: 's-2vcpu-4gb' }), null);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function recordingDb(rows) {
|
|
64
|
+
const calls = [];
|
|
65
|
+
return { calls, query: async (sql, params) => { calls.push({ sql, params }); return { rows }; } };
|
|
66
|
+
}
|
|
67
|
+
const row = (id, hosting_shape, tier = null) =>
|
|
68
|
+
({ id, slug: `p${id}`, hosting_shape, tier, status: 'active', github_login: 'owner' });
|
|
69
|
+
|
|
70
|
+
await test('the live fleet today: every total says how many rows it could not price', async () => {
|
|
71
|
+
// Measured 2026-09-24: the control plane (no size recorded) + five cloud-hosts (no plan).
|
|
72
|
+
const db = recordingDb([row(1, 'control-plane'), ...[2, 3, 4, 5, 6].map((id) => row(id, 'cloud-host'))]);
|
|
73
|
+
const out = await costLedger(db);
|
|
74
|
+
assert.deepEqual(out.totals.platform, { monthly_usd: 0, unpriced: 1 });
|
|
75
|
+
assert.deepEqual(out.totals.owner, { monthly_usd: 0, unpriced: 5 });
|
|
76
|
+
assert.equal(out.instances[0].platform_monthly_usd, null);
|
|
77
|
+
assert.equal(out.instances[1].owner_monthly_usd, null);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('totals sum only priced rows, per column', async () => {
|
|
81
|
+
const db = recordingDb([
|
|
82
|
+
row(1, 'control-plane', 's-1vcpu-2gb'), row(2, 'cloud-host', PLAN), row(3, 'cloud-host', PLAN),
|
|
83
|
+
row(4, 'cloud-host'), row(5, 'byo-host'),
|
|
84
|
+
]);
|
|
85
|
+
const out = await costLedger(db);
|
|
86
|
+
assert.deepEqual(out.totals.platform, { monthly_usd: 12, unpriced: 0 });
|
|
87
|
+
assert.deepEqual(out.totals.owner, { monthly_usd: 2 * RENDER_PLAN_MONTHLY_USD[PLAN], unpriced: 2 });
|
|
88
|
+
assert.equal('cost_estimate_usd' in out.instances[0], false, 'the one-column-two-questions field is gone');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await test('the query skips torn-down rows and clamps the limit', async () => {
|
|
92
|
+
const db = recordingDb([]);
|
|
93
|
+
await costLedger(db, { limit: 99999 });
|
|
94
|
+
assert.match(db.calls[0].sql, /status <> 'torn_down'/);
|
|
95
|
+
assert.deepEqual(db.calls[0].params, [2000]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
summary();
|
|
@@ -133,7 +133,7 @@ function post(path, body = {}) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
const freshRow = (over = {}) => ({
|
|
136
|
-
id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: '
|
|
136
|
+
id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'cloud-host',
|
|
137
137
|
domain: 'spike.cloudbongos.com', onboard_mode: 'greenfield', type: null, created_at: '', updated_at: '', ...over,
|
|
138
138
|
});
|
|
139
139
|
|
|
@@ -192,7 +192,7 @@ function reset({ inst, holder } = {}) {
|
|
|
192
192
|
console.log('\nPOST /provisioning/instances/:id/domain — give an existing instance its address:');
|
|
193
193
|
|
|
194
194
|
await ta('ACTIVE domainless instance: domain is validated, recorded, and a domain-attach intent queues', async () => {
|
|
195
|
-
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: '
|
|
195
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null } });
|
|
196
196
|
const res = await post('/provisioning/instances/18/domain', { domain: 'Spike.CloudBongos.com.' });
|
|
197
197
|
assert.equal(res.status, 200, res.raw);
|
|
198
198
|
assert.equal(res.body.ok, true);
|
|
@@ -419,7 +419,7 @@ console.log('\nPOST /provisioning/instances — slug re-requests are truthful ab
|
|
|
419
419
|
await ta('re-request with a domain on an existing instance SAYS the domain was ignored (and where to send it)', async () => {
|
|
420
420
|
reset();
|
|
421
421
|
createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: null }, created: false };
|
|
422
|
-
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: '
|
|
422
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
|
|
423
423
|
assert.equal(res.status, 200, res.raw);
|
|
424
424
|
assert.ok(res.body.domain_ignored, 'the drop is surfaced, not silent');
|
|
425
425
|
assert.equal(res.body.domain_ignored.requested, 'spike.cloudbongos.com');
|
|
@@ -429,7 +429,7 @@ await ta('re-request with a domain on an existing instance SAYS the domain was i
|
|
|
429
429
|
await ta('re-request with the SAME domain the instance already has → no ignored flag (nothing was dropped)', async () => {
|
|
430
430
|
reset();
|
|
431
431
|
createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' }, created: false };
|
|
432
|
-
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: '
|
|
432
|
+
const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
|
|
433
433
|
assert.equal(res.status, 200, res.raw);
|
|
434
434
|
assert.equal(res.body.domain_ignored, undefined);
|
|
435
435
|
});
|
|
@@ -437,7 +437,7 @@ await ta('re-request with the SAME domain the instance already has → no ignore
|
|
|
437
437
|
await ta('a fresh create with a domain is untouched (the domain is stored at creation — nothing ignored)', async () => {
|
|
438
438
|
reset();
|
|
439
439
|
createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'new', status: 'requested', domain: 'new.cloudbongos.com' }, created: true };
|
|
440
|
-
const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: '
|
|
440
|
+
const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'cloud-host', domain: 'new.cloudbongos.com' });
|
|
441
441
|
assert.equal(res.status, 201, res.raw);
|
|
442
442
|
assert.equal(res.body.domain_ignored, undefined);
|
|
443
443
|
});
|
|
@@ -249,7 +249,7 @@ console.log('\nPOST /provisioning/instances/:id/domain — the index refusing a
|
|
|
249
249
|
|
|
250
250
|
await ta('the index catching the race answers domain_taken 409, and nothing is queued', async () => {
|
|
251
251
|
reset();
|
|
252
|
-
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: '
|
|
252
|
+
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
|
|
253
253
|
updateThrows = DOMAIN_VIOLATION();
|
|
254
254
|
const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
255
255
|
assert.equal(res.status, 409, res.raw);
|
|
@@ -260,7 +260,7 @@ await ta('the index catching the race answers domain_taken 409, and nothing is q
|
|
|
260
260
|
|
|
261
261
|
await ta('any OTHER failure on attach is still the honest 500', async () => {
|
|
262
262
|
reset();
|
|
263
|
-
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: '
|
|
263
|
+
instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
|
|
264
264
|
updateThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
|
|
265
265
|
const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
|
|
266
266
|
assert.equal(res.status, 500, res.raw);
|
|
@@ -30,7 +30,7 @@ const provisioning = require('../modules/provisioning/provisioning.js');
|
|
|
30
30
|
const { test, summary } = makeRunner();
|
|
31
31
|
|
|
32
32
|
const activeStandalone = {
|
|
33
|
-
id: 18, slug: 'spike', hosting_shape: '
|
|
33
|
+
id: 18, slug: 'spike', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 7,
|
|
34
34
|
port: 3010, db_name: 'spike', domain: null, target_ref: 'jaxri/spike-harbor',
|
|
35
35
|
};
|
|
36
36
|
|
|
@@ -74,7 +74,7 @@ await ta('createInstance: a chosen set is stored as a jsonb array, in catalog or
|
|
|
74
74
|
|
|
75
75
|
// Act
|
|
76
76
|
await provisioning.createInstance(db, {
|
|
77
|
-
ownerBuilderId: 7, slug: 'demo', hostingShape: '
|
|
77
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game',
|
|
78
78
|
modules: ['discord', 'agents'],
|
|
79
79
|
});
|
|
80
80
|
|
|
@@ -90,7 +90,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
|
|
|
90
90
|
for (const modules of [undefined, null, 'discord', 42]) {
|
|
91
91
|
const db = recordingDb();
|
|
92
92
|
await provisioning.createInstance(db, {
|
|
93
|
-
ownerBuilderId: 7, slug: 'demo', hostingShape: '
|
|
93
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules,
|
|
94
94
|
});
|
|
95
95
|
const params = insertParams(db);
|
|
96
96
|
assert.equal(params[13], null, `modules=${JSON.stringify(modules)} stores NULL, not an empty list`);
|
|
@@ -100,7 +100,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
|
|
|
100
100
|
await ta('createInstance: an explicit empty answer is stored, and is NOT the same as absent', async () => {
|
|
101
101
|
const db = recordingDb();
|
|
102
102
|
await provisioning.createInstance(db, {
|
|
103
|
-
ownerBuilderId: 7, slug: 'demo', hostingShape: '
|
|
103
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules: [],
|
|
104
104
|
});
|
|
105
105
|
assert.equal(insertParams(db)[13], '[]', 'the owner said none of them, and the row says so');
|
|
106
106
|
});
|
|
@@ -110,7 +110,7 @@ await ta('createInstance: the always-on core is never written into the selection
|
|
|
110
110
|
// the column disagree with ALWAYS_ON_CORE the day a manifest flips its default.
|
|
111
111
|
const db = recordingDb();
|
|
112
112
|
await provisioning.createInstance(db, {
|
|
113
|
-
ownerBuilderId: 7, slug: 'demo', hostingShape: '
|
|
113
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', modules: ['memory', 'discord', 'provisioning'],
|
|
114
114
|
});
|
|
115
115
|
assert.equal(insertParams(db)[13], JSON.stringify(['discord']));
|
|
116
116
|
});
|
|
@@ -164,7 +164,7 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
164
164
|
lastCreateArgs = args;
|
|
165
165
|
return existingRow
|
|
166
166
|
? { instance: existingRow, created: false }
|
|
167
|
-
: { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: '
|
|
167
|
+
: { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'cloud-host', type: args.type || null, detail: {} }, created: true };
|
|
168
168
|
};
|
|
169
169
|
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
170
170
|
provisioning.getOpenIntent = async () => null;
|
|
@@ -53,7 +53,7 @@ await ta('createInstance: onboardMode "adopt" is persisted', async () => {
|
|
|
53
53
|
const db = recordingDb();
|
|
54
54
|
await provisioning.createInstance(db, {
|
|
55
55
|
ownerBuilderId: 7, slug: 'demo', targetRef: 'example-owner/demo',
|
|
56
|
-
hostingShape: '
|
|
56
|
+
hostingShape: 'cloud-host', onboardMode: 'adopt',
|
|
57
57
|
});
|
|
58
58
|
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
59
59
|
assert.ok(insert, 'issued an INSERT');
|
|
@@ -68,7 +68,7 @@ await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE
|
|
|
68
68
|
for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
|
|
69
69
|
const db = recordingDb();
|
|
70
70
|
await provisioning.createInstance(db, {
|
|
71
|
-
ownerBuilderId: 7, slug: 'demo', hostingShape: '
|
|
71
|
+
ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', onboardMode: mode,
|
|
72
72
|
});
|
|
73
73
|
const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
|
|
74
74
|
assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
|