@bongos/core 1.19.998 → 1.19.1000
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 +105 -90
- 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 +154 -4
- package/docs/api-reference.md +5 -3
- 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/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 +18 -14
- 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_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}`);
|
|
@@ -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`);
|
|
@@ -3,13 +3,18 @@
|
|
|
3
3
|
// The paid-shape gate on POST /provisioning/instances (task 1003682).
|
|
4
4
|
//
|
|
5
5
|
// Before this, the create route's only gate was requireBuilder, and `hosting_shape` was
|
|
6
|
-
// caller-supplied and validated only against the allowed set. `dedicated`
|
|
7
|
-
// whose runner leg
|
|
8
|
-
// monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos
|
|
9
|
-
// could queue recurring infrastructure spend the owner never approved. The route
|
|
10
|
-
// SPENDS was less gated than force-teardown, which stops spending and sits behind
|
|
6
|
+
// caller-supplied and validated only against the allowed set. `dedicated` was then the ONE
|
|
7
|
+
// shape whose runner leg created a real DigitalOcean droplet (scripts/gds/provision.js —
|
|
8
|
+
// billed monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos
|
|
9
|
+
// included — could queue recurring infrastructure spend the owner never approved. The route
|
|
10
|
+
// that SPENDS was less gated than force-teardown, which stops spending and sits behind
|
|
11
11
|
// provisioning.fleet.manage.
|
|
12
12
|
//
|
|
13
|
+
// THAT SHAPE IS GONE (ADR 0327 decision 4, task 1004182, migration provisioning_027): on the
|
|
14
|
+
// owner's own platform account a bigger plan is a setting they choose, not a shape we model.
|
|
15
|
+
// Its rows folded into 'cloud-host'. The gate's reasoning did not move with it — see the
|
|
16
|
+
// 'dedicated is no longer requestable' case below, which is what holds the removal.
|
|
17
|
+
//
|
|
13
18
|
// The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
|
|
14
19
|
// so the front door — `byo-host` since ADR 0323, `co-tenant` before it — stays open to any
|
|
15
20
|
// authenticated builder. Everything else
|
|
@@ -21,10 +26,10 @@
|
|
|
21
26
|
// subject. The original gate named the one shape it refused and admitted the rest, and two
|
|
22
27
|
// shapes stayed open underneath that reasoning:
|
|
23
28
|
//
|
|
24
|
-
//
|
|
25
|
-
// trust. The unit's WorkingDirectory is the
|
|
26
|
-
//
|
|
27
|
-
// a repo the caller named.
|
|
29
|
+
// cloud-host — then stored as 'standalone'. Judged harmless because it costs $0. True
|
|
30
|
+
// of the invoice; false of the trust. The unit's WorkingDirectory is the
|
|
31
|
+
// caller's own checkout and the loader discovers modules from
|
|
32
|
+
// process.cwd(), so the platform require()s a repo the caller named.
|
|
28
33
|
// control-plane — did not exist when the gate was written, and was permitted by omission
|
|
29
34
|
// the moment task 1003159 added it. A probe returned 201 and a second
|
|
30
35
|
// control-plane row, while this file's own comment asserted it could not.
|
|
@@ -173,41 +178,41 @@ await ta('the refusal speaks WIRE vocabulary — no owner-facing label is transl
|
|
|
173
178
|
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
|
|
174
179
|
assert.equal(res.status, 403, `expected 403 for ${shape}, got ${res.status} ${res.raw}`);
|
|
175
180
|
const message = res.body.error.message || '';
|
|
176
|
-
for (const label of ['bring your own host', '
|
|
181
|
+
for (const label of ['bring your own host', 'your own host', 'shared server']) {
|
|
177
182
|
assert.ok(!message.toLowerCase().includes(label),
|
|
178
183
|
`the ${shape} refusal translates a stored value into the owner-facing label "${label}" — that map lives only in projects.html's SHAPE_LABEL (ADR 0323): ${message}`);
|
|
179
184
|
}
|
|
180
185
|
}
|
|
181
186
|
// And the caller is still told what they CAN ask for, in the vocabulary they sent.
|
|
182
187
|
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
|
|
183
|
-
assert.deepEqual([...res.body.error.details.requestable].sort(), ['byo-host', '
|
|
188
|
+
assert.deepEqual([...res.body.error.details.requestable].sort(), ['byo-host', 'cloud-host'],
|
|
184
189
|
'a refusal that names no alternative is a dead end — the stored values are the alternative');
|
|
185
190
|
});
|
|
186
191
|
|
|
187
|
-
// ──
|
|
192
|
+
// ── cloud-host runs the caller's OWN code on the platform's box (task 1003370) ────
|
|
188
193
|
|
|
189
|
-
await ta('
|
|
194
|
+
await ta('cloud-host: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
190
195
|
// Task 1003682 left this shape open, reasoning that a $0 shape hands the caller
|
|
191
196
|
// nothing. That is true of the invoice and false of the trust: the systemd unit's
|
|
192
197
|
// WorkingDirectory is the caller's own checkout and the module loader discovers
|
|
193
|
-
// `modules/` from process.cwd(), so a
|
|
198
|
+
// `modules/` from process.cwd(), so a cloud-host request is "please require() this
|
|
194
199
|
// repo on your control plane". The uid split (task 1003369) bounds the blast radius;
|
|
195
200
|
// it does not make the default acceptable.
|
|
196
201
|
held = [];
|
|
197
202
|
lastCreateArgs = null;
|
|
198
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: '
|
|
203
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
199
204
|
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
200
205
|
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
201
206
|
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
202
207
|
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
203
208
|
});
|
|
204
209
|
|
|
205
|
-
await ta('
|
|
210
|
+
await ta('cloud-host: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
|
|
206
211
|
held = ['provisioning.fleet.manage'];
|
|
207
212
|
lastCreateArgs = null;
|
|
208
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: '
|
|
213
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
209
214
|
assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
|
|
210
|
-
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, '
|
|
215
|
+
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
|
|
211
216
|
});
|
|
212
217
|
|
|
213
218
|
// ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
|
|
@@ -239,45 +244,46 @@ await ta('control-plane: still refused for a builder holding EVERY permission',
|
|
|
239
244
|
assert.equal(lastCreateArgs, null, 'no row is created');
|
|
240
245
|
});
|
|
241
246
|
|
|
242
|
-
// ──
|
|
243
|
-
|
|
244
|
-
await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
|
|
245
|
-
held = [];
|
|
246
|
-
lastCreateArgs = null;
|
|
247
|
-
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
248
|
-
assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
|
|
249
|
-
assert.equal(res.body.error.code, 'permission_forbidden');
|
|
250
|
-
// requirePermission reports the gate's literal list under `details` (the same envelope
|
|
251
|
-
// requireRank uses), so the denial names the atom a caller has to be granted.
|
|
252
|
-
assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
|
|
253
|
-
assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
|
|
254
|
-
});
|
|
247
|
+
// ── the removed shape stays removed ───────────────────────────────────────────────
|
|
255
248
|
|
|
256
|
-
await ta('dedicated
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
249
|
+
await ta('dedicated is no longer requestable AT ALL — not by permission, not by anyone', async () => {
|
|
250
|
+
// This is the assertion that holds ADR 0327 decision 4. 'dedicated' used to be the metered
|
|
251
|
+
// shape: refused without provisioning.fleet.manage, ADMITTED with it. Task 1004182 removed
|
|
252
|
+
// the value from HOSTING_SHAPES and migration provisioning_027 folded its rows into
|
|
253
|
+
// 'cloud-host', so it is now an unknown shape — the gate DEFERS it and the validator
|
|
254
|
+
// refuses it, whatever the caller holds.
|
|
255
|
+
//
|
|
256
|
+
// Asserted for BOTH permission states deliberately. Testing only the unprivileged case
|
|
257
|
+
// would still pass if the shape had merely gone back to being gated, which is exactly the
|
|
258
|
+
// state this removal is distinguishable from.
|
|
259
|
+
for (const permissions of [[], ['provisioning.fleet.manage']]) {
|
|
260
|
+
held = permissions;
|
|
261
|
+
lastCreateArgs = null;
|
|
262
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
|
|
263
|
+
assert.ok(res.status === 400 || res.status === 403,
|
|
264
|
+
`holding ${JSON.stringify(permissions)}: expected a refusal, got ${res.status} ${res.raw}`);
|
|
265
|
+
assert.equal(lastCreateArgs, null,
|
|
266
|
+
`holding ${JSON.stringify(permissions)}: a removed shape must never reach createInstance`);
|
|
267
|
+
}
|
|
262
268
|
});
|
|
263
269
|
|
|
264
|
-
await ta('the gate refuses BEFORE the row exists — a denied
|
|
270
|
+
await ta('the gate refuses BEFORE the row exists — a denied create leaves nothing behind', async () => {
|
|
265
271
|
// The denial must land ahead of createInstance, not after it: a refused-but-inserted row
|
|
266
272
|
// would still be drained by the runner, which reads the column, not the HTTP status.
|
|
267
273
|
held = [];
|
|
268
274
|
lastCreateArgs = null;
|
|
269
|
-
await post('/provisioning/instances', { slug: 'spendy', hosting_shape: '
|
|
275
|
+
await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
|
|
270
276
|
assert.equal(lastCreateArgs, null);
|
|
271
277
|
});
|
|
272
278
|
|
|
273
279
|
// ── the gate cannot be walked around ──────────────────────────────────────────────
|
|
274
280
|
|
|
275
|
-
await ta('a case-variant shape does not slip past the gate INTO a
|
|
281
|
+
await ta('a case-variant shape does not slip past the gate INTO a privileged row', async () => {
|
|
276
282
|
// The gate matches the exact wire value, and so does the shape validator — so a variant
|
|
277
|
-
// is refused as a bad shape rather than admitted as a cheap one and stored as
|
|
278
|
-
// The property that matters is not WHICH error comes back, it is that no
|
|
283
|
+
// is refused as a bad shape rather than admitted as a cheap one and stored as a gated
|
|
284
|
+
// shape. The property that matters is not WHICH error comes back, it is that no gated row
|
|
279
285
|
// is ever created without the permission.
|
|
280
|
-
for (const shape of ['
|
|
286
|
+
for (const shape of ['CLOUD-HOST', 'Cloud-Host', ' cloud-host', 'cloud-host ']) {
|
|
281
287
|
held = [];
|
|
282
288
|
lastCreateArgs = null;
|
|
283
289
|
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
|
|
@@ -307,8 +313,15 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
|
|
|
307
313
|
// no caller may request: the value stays storable so one torn-down row remains
|
|
308
314
|
// readable, and the door is shut in UNREQUESTABLE_SHAPES instead. Dropping it from
|
|
309
315
|
// HOSTING_SHAPES would make that historical row violate its own CHECK constraint.
|
|
316
|
+
//
|
|
317
|
+
// It fired a THIRD time at task 1004182, which is the first time it fired for a shape
|
|
318
|
+
// going AWAY rather than arriving. 'dedicated' left the set entirely (ADR 0327 decision 4)
|
|
319
|
+
// and 'standalone' was renamed to 'cloud-host' so the stored value matches the
|
|
320
|
+
// owner-facing word; migration provisioning_027 moved both. A shape LEAVING needs the same
|
|
321
|
+
// written decision as one arriving, and more care: the rows carrying the old value have to
|
|
322
|
+
// land somewhere legal before the CHECK constraint narrows under them.
|
|
310
323
|
assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
|
|
311
|
-
['byo-host', 'co-tenant', 'control-plane'
|
|
324
|
+
['byo-host', 'cloud-host', 'co-tenant', 'control-plane']);
|
|
312
325
|
});
|
|
313
326
|
|
|
314
327
|
await ta('an unknown shape is answered by the validator, not by the gate', () => {
|
|
@@ -83,7 +83,7 @@ function post(path, body) {
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: '
|
|
86
|
+
const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
|
|
87
87
|
const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
88
88
|
function reset(patch = {}) {
|
|
89
89
|
instance = { ...LIVE, ...patch };
|