@bongos/core 1.19.789 → 1.19.790
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 +110 -40
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +129 -1
- package/docs/api-reference.md +5 -2
- package/docs/copy-inventory.md +31 -14
- package/docs/copy-registry.json +205 -31
- package/docs/module-api-changelog.md +2 -0
- package/docs/recipes/core-release-pipeline.md +12 -0
- package/migrations/core_241_core_upgrades_actor.sql +65 -0
- package/migrations/core_242_core_version_snapshot.sql +54 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/government/catalog.js +8 -0
- package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
- package/modules/hall-ui/public/deploy.html +64 -0
- package/modules/hall-ui/public/deploy.js +188 -0
- package/modules/hall-ui/public/shell.js +4 -0
- package/modules/provisioning/control-plane-guard.js +102 -0
- package/modules/provisioning/core-upgrade.js +289 -0
- package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +8 -8
- package/modules/provisioning/routes/core-upgrade.js +95 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/enroll-control-plane.js +101 -0
- package/scripts/gds/provision-core-upgrade.js +311 -0
- package/scripts/gds/provision.js +4 -2
- package/scripts/gds/upgrade.js +53 -14
- package/src/bongos/auth.js +19 -0
- package/src/bongos/serve-internal.js +21 -0
- package/src/module-api.js +1 -1
- package/tests/auth_resolve_failure.mjs +4 -2
- package/tests/core_upgrade_door.mjs +408 -0
- package/tests/core_upgrade_runner.mjs +377 -0
- package/tests/hall_nav.mjs +4 -1
- package/tests/hall_page_gate_map.mjs +7 -0
- package/tests/provisioning_disconnect.mjs +30 -5
- package/tests/provisioning_paid_shape_gate.mjs +14 -4
- package/tests/provisioning_restart.mjs +25 -9
- package/tests/provisioning_settings_apply.mjs +11 -2
- package/tests/provisioning_teardown_intent.mjs +7 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
// tests/core_upgrade_door.mjs — the owner's deploy door (task 1003159, ADR 0293).
|
|
2
|
+
//
|
|
3
|
+
// WHAT IS ACTUALLY AT RISK HERE. Enrolling the control plane as a row in
|
|
4
|
+
// provisioning_instances is what gives the deploy door something to point at —
|
|
5
|
+
// `provisioning_intents.instance_id` is NOT NULL, so an intent must name a row.
|
|
6
|
+
// The same row makes 'teardown' a button that would destroy the platform from
|
|
7
|
+
// inside the platform's own UI. `intentAllowedForShape` is the single place that
|
|
8
|
+
// decides, and these cases are what keep it honest.
|
|
9
|
+
//
|
|
10
|
+
// The guard is an ALLOW-list on purpose (ADR 0293 D2). A deny-list of today's
|
|
11
|
+
// destructive actions would silently admit tomorrow's, and the failure direction
|
|
12
|
+
// of "admitted by omission" is the platform deleting itself.
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/core_upgrade_door.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const P = require('../modules/provisioning/provisioning.js');
|
|
24
|
+
// The guard is its own module, and callers import it DIRECTLY rather than through
|
|
25
|
+
// provisioning.js: that file sits on the oversized-file ratchet at its ceiling, so a
|
|
26
|
+
// re-export there costs a line the build will not give back. Importing the thing
|
|
27
|
+
// itself is also the honest shape - the guard is not provisioning's to own.
|
|
28
|
+
const G = require('../modules/provisioning/control-plane-guard.js');
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const repoRoot = join(__dirname, '..');
|
|
32
|
+
const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
|
|
33
|
+
|
|
34
|
+
let passed = 0;
|
|
35
|
+
let failed = 0;
|
|
36
|
+
const t = async (name, fn) => {
|
|
37
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
38
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
console.log('\nthe vocabulary:');
|
|
42
|
+
|
|
43
|
+
await t('core-upgrade is a queueable intent action', () => {
|
|
44
|
+
assert.ok(P.INTENT_ACTIONS.includes('core-upgrade'));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await t('control-plane is a hosting shape', () => {
|
|
48
|
+
assert.ok(P.HOSTING_SHAPES.includes('control-plane'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await t('the shape and the action both reach the DB constraints', () => {
|
|
52
|
+
// The JS list and the CHECK are two spellings of one vocabulary; a widening that
|
|
53
|
+
// lands in only one of them fails at insert time, in production, on the first
|
|
54
|
+
// real use. (The bug class task 1003913 shipped a fix for, in another file.)
|
|
55
|
+
const mig = read('modules', 'provisioning', 'migrations', 'provisioning_018_core_upgrade_intent.sql')
|
|
56
|
+
.replace(/^\s*--.*$/gm, ' ').replace(/\s+/g, ' ');
|
|
57
|
+
assert.match(mig, /CHECK \(action IN \([^)]*'core-upgrade'\)\)/, 'the intents CHECK admits the new action');
|
|
58
|
+
assert.match(mig, /CHECK \(hosting_shape IN \([^)]*'control-plane'\)\)/, 'the instances CHECK admits the new shape');
|
|
59
|
+
for (const a of P.INTENT_ACTIONS) {
|
|
60
|
+
assert.ok(mig.includes(`'${a}'`), `the widened CHECK must still carry '${a}' — a rewrite drops what it forgets`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
console.log('\nthe guard — what may be aimed at the platform itself:');
|
|
65
|
+
|
|
66
|
+
await t('TEARDOWN IS REFUSED on the control-plane row', () => {
|
|
67
|
+
// The case this whole file exists for.
|
|
68
|
+
assert.equal(G.intentAllowedForShape('teardown', 'control-plane'), false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await t('every action not explicitly allowed is refused on the self row', () => {
|
|
72
|
+
const allowed = new Set(G.CONTROL_PLANE_ACTIONS);
|
|
73
|
+
for (const a of P.INTENT_ACTIONS) {
|
|
74
|
+
assert.equal(
|
|
75
|
+
G.intentAllowedForShape(a, 'control-plane'),
|
|
76
|
+
allowed.has(a),
|
|
77
|
+
`'${a}' on the control-plane row must be ${allowed.has(a) ? 'allowed' : 'refused'}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await t('the allow-list is exactly the two acts that cannot destroy the box', () => {
|
|
83
|
+
// Pinned by NAME so widening it is a deliberate edit someone has to argue for,
|
|
84
|
+
// not a side effect of adding an action to INTENT_ACTIONS.
|
|
85
|
+
assert.deepEqual([...G.CONTROL_PLANE_ACTIONS].sort(), ['core-upgrade', 'restart']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('an ordinary instance is untouched by the guard', () => {
|
|
89
|
+
for (const shape of ['co-tenant', 'dedicated', 'standalone']) {
|
|
90
|
+
for (const a of P.INTENT_ACTIONS) {
|
|
91
|
+
assert.equal(G.intentAllowedForShape(a, shape), true, `${a}/${shape} must be unaffected`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await t('an UNKNOWN shape is treated as ordinary, not as the control plane', () => {
|
|
97
|
+
// Deliberate: the guard protects one specific row. Failing closed on every
|
|
98
|
+
// unrecognised shape would break provisioning for a shape added later without
|
|
99
|
+
// touching this list, and that is a bigger blast radius than the thing guarded.
|
|
100
|
+
assert.equal(G.intentAllowedForShape('teardown', 'something-new'), true);
|
|
101
|
+
assert.equal(G.intentAllowedForShape('teardown', undefined), true);
|
|
102
|
+
assert.equal(G.intentAllowedForShape('teardown', null), true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
console.log('\nat most one self row:');
|
|
106
|
+
|
|
107
|
+
await t('the migration makes a second control-plane row impossible', () => {
|
|
108
|
+
// Two self rows would make "which one is me?" a question with no answer, and the
|
|
109
|
+
// door would point at whichever the query returned first.
|
|
110
|
+
const mig = read('modules', 'provisioning', 'migrations', 'provisioning_018_core_upgrade_intent.sql')
|
|
111
|
+
.replace(/\s+/g, ' ');
|
|
112
|
+
assert.match(mig, /CREATE UNIQUE INDEX IF NOT EXISTS provisioning_instances_one_control_plane_idx/);
|
|
113
|
+
assert.match(mig, /WHERE hosting_shape = 'control-plane'/, 'partial, so it constrains only the self row');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
console.log('\nthe ledger records WHO (ADR 0293 D5):');
|
|
117
|
+
|
|
118
|
+
await t('core_241 adds an actor and a source, and binds the door to naming one', () => {
|
|
119
|
+
const mig = read('migrations', 'core_241_core_upgrades_actor.sql')
|
|
120
|
+
.replace(/^\s*--.*$/gm, ' ').replace(/\s+/g, ' ');
|
|
121
|
+
assert.match(mig, /ADD COLUMN IF NOT EXISTS requested_by_builder_id bigint REFERENCES builders\(id\) ON DELETE SET NULL/);
|
|
122
|
+
assert.match(mig, /source IS NULL OR source IN \('shell', 'owner-control'\)/);
|
|
123
|
+
assert.match(
|
|
124
|
+
mig,
|
|
125
|
+
/source IS DISTINCT FROM 'owner-control' OR requested_by_builder_id IS NOT NULL/,
|
|
126
|
+
'an upgrade through the DOOR must name its actor',
|
|
127
|
+
);
|
|
128
|
+
assert.match(mig, /NOT VALID/, 'without scanning history that predates the concept');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await t('the actor column is NULLABLE — the shell path must keep working', () => {
|
|
132
|
+
const mig = read('migrations', 'core_241_core_upgrades_actor.sql');
|
|
133
|
+
assert.doesNotMatch(
|
|
134
|
+
mig.replace(/^\s*--.*$/gm, ''),
|
|
135
|
+
/requested_by_builder_id bigint[^;]*NOT NULL/,
|
|
136
|
+
'a human at a shell has no builder to attribute; this column is not a gate on the CLI path',
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const CU = require('../modules/provisioning/core-upgrade.js');
|
|
141
|
+
|
|
142
|
+
const ACTIVE = Object.freeze({ id: 7, slug: 'cloudbongos', status: 'active', hosting_shape: 'control-plane', owner_builder_id: 3, core_version: '1.19.754' });
|
|
143
|
+
|
|
144
|
+
console.log('\nwhat the door refuses, before anything is queued:');
|
|
145
|
+
|
|
146
|
+
await t('a version that is not three numbers is refused', () => {
|
|
147
|
+
for (const bad of ['latest', '1.19', 'v1.19.757', '1.19.757-rc1', '', ' ', 'DROP TABLE']) {
|
|
148
|
+
const f = CU.coreUpgradeFault(ACTIVE, bad);
|
|
149
|
+
assert.ok(f, `'${bad}' must be refused`);
|
|
150
|
+
assert.equal(f.code, 'bad_target_version', `'${bad}'`);
|
|
151
|
+
assert.equal(f.status, 400);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
await t('a well-formed version is admitted', () => {
|
|
156
|
+
assert.equal(CU.coreUpgradeFault(ACTIVE, '1.19.757'), null);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await t('the version ALREADY running is refused rather than restarting to change nothing', () => {
|
|
160
|
+
const f = CU.coreUpgradeFault(ACTIVE, '1.19.754', '1.19.754');
|
|
161
|
+
assert.ok(f);
|
|
162
|
+
assert.equal(f.code, 'already_on_version');
|
|
163
|
+
assert.match(f.message, /already runs core 1\.19\.754/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await t('an instance that is not ACTIVE has no settled pin to move', () => {
|
|
167
|
+
for (const status of ['provisioning', 'tearing_down', 'torn_down', 'error']) {
|
|
168
|
+
const f = CU.coreUpgradeFault({ ...ACTIVE, status }, '1.19.757');
|
|
169
|
+
assert.ok(f, status);
|
|
170
|
+
assert.equal(f.code, 'not_upgradable', status);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await t('a shape the platform does not manage is refused, and that check runs FIRST', () => {
|
|
175
|
+
// Ordering matters: the shape check protects the platform, the others protect
|
|
176
|
+
// the caller from a mistake. A dedicated droplet has no runner exec path, so
|
|
177
|
+
// queueing would be the F16 lie — a request that never lands.
|
|
178
|
+
//
|
|
179
|
+
// NB the shape refusal here is UPGRADE_SHAPES alone. This test used to be named
|
|
180
|
+
// for the self-row guard running first, which it never exercised — see the
|
|
181
|
+
// vacuity case below for why that call is not made at all.
|
|
182
|
+
const f = CU.coreUpgradeFault({ ...ACTIVE, hosting_shape: 'dedicated', status: 'torn_down' }, 'nonsense');
|
|
183
|
+
assert.ok(f);
|
|
184
|
+
assert.equal(f.code, 'core_upgrade_unavailable',
|
|
185
|
+
'the shape refusal wins over the state and format ones');
|
|
186
|
+
// And a dedicated droplet is refused even when everything else is perfect —
|
|
187
|
+
// the runner has no exec path to it, so queueing would be accepted and never land.
|
|
188
|
+
const clean = CU.coreUpgradeFault({ ...ACTIVE, hosting_shape: 'dedicated' }, '1.19.757');
|
|
189
|
+
assert.ok(clean, 'a dedicated droplet is refused on shape alone');
|
|
190
|
+
assert.equal(clean.code, 'core_upgrade_unavailable');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await t('no refusal message promises a duration (ADR 0190 §7)', () => {
|
|
194
|
+
const messages = [
|
|
195
|
+
CU.coreUpgradeFault({ ...ACTIVE, hosting_shape: 'dedicated' }, '1.19.757'),
|
|
196
|
+
CU.coreUpgradeFault({ ...ACTIVE, hosting_shape: 'control-plane', status: 'provisioning' }, '1.19.757'),
|
|
197
|
+
CU.coreUpgradeFault({ ...ACTIVE, status: 'torn_down' }, '1.19.757'),
|
|
198
|
+
CU.coreUpgradeFault(ACTIVE, 'latest'),
|
|
199
|
+
CU.coreUpgradeFault(ACTIVE, '1.19.754', '1.19.754'),
|
|
200
|
+
].map((f) => f.message);
|
|
201
|
+
for (const m of messages) {
|
|
202
|
+
assert.doesNotMatch(m, /\b(minute|second|hour|shortly|soon)\b/i, m);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
console.log('\nthe gate — the DONE-WHEN clause:');
|
|
207
|
+
|
|
208
|
+
await t('A NON-ARCHON IS REFUSED: the route gates on core.pin.move, an archon-floor system atom', () => {
|
|
209
|
+
// The task's explicit DONE-WHEN. Asserted at the DECLARATION rather than by
|
|
210
|
+
// driving express, because the gate IS the middleware list: a handler test would
|
|
211
|
+
// pass just as happily with the gate deleted.
|
|
212
|
+
// Comments stripped FIRST: the route's own header names requireRank to explain
|
|
213
|
+
// why it is not used, and a raw-text scan reads that prose as the forbidden call.
|
|
214
|
+
// Exactly the defect filed as task 1003832 against government_require_permission.
|
|
215
|
+
const route = read('modules', 'provisioning', 'routes', 'core-upgrade.js')
|
|
216
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
217
|
+
.replace(/^\s*\/\/.*$/gm, ' ')
|
|
218
|
+
.replace(/\s+/g, ' ');
|
|
219
|
+
assert.match(route, /api\.requirePermission\('core\.pin\.move'\)/, 'the atom gates the route');
|
|
220
|
+
assert.match(route, /api\.requireBuilder/, 'and an anonymous caller never reaches it');
|
|
221
|
+
assert.doesNotMatch(route, /requireRank/, 'BV1.R105: a modules/*/routes gate is an atom, never a rank');
|
|
222
|
+
|
|
223
|
+
const catalog = read('modules', 'government', 'catalog.js');
|
|
224
|
+
const entry = catalog.split('\n').find((l) => l.includes("key: 'core.pin.move'"));
|
|
225
|
+
assert.ok(entry, 'the atom is in the catalog');
|
|
226
|
+
assert.match(entry, /floor: 'archon'/, 'archon floor — deploy is a hard gate the owner holds');
|
|
227
|
+
assert.match(entry, /system: true/,
|
|
228
|
+
'system:true is what makes it UN-DELEGATABLE: the catalog invariant forbids granting a system key to metic');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
await t('the grant migration gives it to archon and to NOBODY else', () => {
|
|
232
|
+
const mig = read('modules', 'government', 'migrations', 'government_014_core_pin_move.sql')
|
|
233
|
+
.replace(/^\s*--.*$/gm, ' ');
|
|
234
|
+
assert.match(mig, /SELECT 'archon', unnest\(ARRAY\[\s*'core\.pin\.move'/);
|
|
235
|
+
assert.doesNotMatch(mig, /SELECT 'metic'/, 'no metic grant — the absence is the point');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
console.log('\nthe web tier only ASKS (ADR 0111 §2 / ADR 0293 D1):');
|
|
239
|
+
|
|
240
|
+
await t('nothing in the door shells out, escalates, or reaches the control plane', () => {
|
|
241
|
+
// The rule that makes a stolen builder token unable to deploy. Asserted over both
|
|
242
|
+
// web-tier files, comments stripped so prose describing the runner is not read as
|
|
243
|
+
// the web tier doing it.
|
|
244
|
+
for (const f of [['modules', 'provisioning', 'core-upgrade.js'], ['modules', 'provisioning', 'routes', 'core-upgrade.js']]) {
|
|
245
|
+
const code = read(...f)
|
|
246
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
247
|
+
.replace(/^\s*\/\/.*$/gm, ' ');
|
|
248
|
+
for (const forbidden of ['child_process', 'spawnSync', 'execSync', 'sudo ', 'ssh ']) {
|
|
249
|
+
assert.ok(!code.includes(forbidden), `${f.join('/')} must not reference ${forbidden}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await t('the handler queues an intent carrying the TARGET, and records the actor', async () => {
|
|
255
|
+
const calls = [];
|
|
256
|
+
const provisioning = {
|
|
257
|
+
getInstanceById: async () => ACTIVE,
|
|
258
|
+
enqueueIntent: async (_db, id, action, requestedBy, targetVersion) => {
|
|
259
|
+
calls.push({ id, action, requestedBy, targetVersion });
|
|
260
|
+
return { intent: { id: 1, action, state: 'pending', target_version: targetVersion }, created: true };
|
|
261
|
+
},
|
|
262
|
+
recordEvent: async (_db, e) => { calls.push({ event: e.event, actor: e.actor }); },
|
|
263
|
+
};
|
|
264
|
+
const handler = CU.makeCoreUpgradeHandler({
|
|
265
|
+
provisioning, pool: {}, parseId: () => 7,
|
|
266
|
+
validateOrRespond: () => false, log: { error() {} },
|
|
267
|
+
});
|
|
268
|
+
let body = null;
|
|
269
|
+
const res = { json: (b) => { body = b; return res; }, fail: (code, o) => { body = { code, ...o }; return res; } };
|
|
270
|
+
await handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.757' } }, res);
|
|
271
|
+
|
|
272
|
+
const enqueued = calls.find((c) => c.action === 'core-upgrade');
|
|
273
|
+
assert.ok(enqueued, 'an intent was queued');
|
|
274
|
+
assert.equal(enqueued.targetVersion, '1.19.757', 'carrying the version the owner confirmed');
|
|
275
|
+
assert.equal(enqueued.requestedBy, 'api:archon:42', 'attributed to the caller');
|
|
276
|
+
assert.ok(calls.some((c) => c.event === 'core-upgrade' && c.actor === 'api:archon:42'), 'and audited');
|
|
277
|
+
assert.equal(body.queued, true);
|
|
278
|
+
assert.equal(body.target_version, '1.19.757');
|
|
279
|
+
assert.doesNotMatch(body.message, /\b(minute|second|hour)\b/i, 'no duration is promised');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await t('a SECOND request conflicts rather than reporting the first as done', async () => {
|
|
283
|
+
// Unlike a duplicate restart, which IS the thing asked for. This one names a
|
|
284
|
+
// target, and the open intent may name a different one — reporting that as
|
|
285
|
+
// success would tell the owner their version is coming when another is.
|
|
286
|
+
const provisioning = {
|
|
287
|
+
getInstanceById: async () => ACTIVE,
|
|
288
|
+
enqueueIntent: async () => ({ intent: { action: 'core-upgrade', state: 'running', target_version: '1.19.756' }, created: false }),
|
|
289
|
+
recordEvent: async () => {},
|
|
290
|
+
};
|
|
291
|
+
const handler = CU.makeCoreUpgradeHandler({
|
|
292
|
+
provisioning, pool: {}, parseId: () => 7,
|
|
293
|
+
validateOrRespond: () => false, log: { error() {} },
|
|
294
|
+
});
|
|
295
|
+
let body = null;
|
|
296
|
+
const res = { json: (b) => { body = b; return res; }, fail: (code, o) => { body = { code, ...o }; return res; } };
|
|
297
|
+
await handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.757' } }, res);
|
|
298
|
+
assert.equal(body.code, 'intent_conflict');
|
|
299
|
+
assert.equal(body.status, 409);
|
|
300
|
+
assert.equal(body.details.target_version, '1.19.756', 'and it names the target already in flight');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await t('the handler does NOT read a core_version column, because there is none', () => {
|
|
304
|
+
// The first cut passed `inst.core_version || null`, which is always undefined —
|
|
305
|
+
// so the already-on-version refusal was unreachable from the route while reading
|
|
306
|
+
// as a live guard. Pinned from both ends: the schema has no such column, and the
|
|
307
|
+
// handler does not name it.
|
|
308
|
+
const migs = ['provisioning_001_tables.sql', 'provisioning_018_core_upgrade_intent.sql']
|
|
309
|
+
.map((f) => read('modules', 'provisioning', 'migrations', f)).join('\n');
|
|
310
|
+
assert.doesNotMatch(migs, /core_version/, 'no migration adds the column');
|
|
311
|
+
const code = read('modules', 'provisioning', 'core-upgrade.js')
|
|
312
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
313
|
+
.replace(/^\s*\/\/.*$/gm, ' ');
|
|
314
|
+
assert.doesNotMatch(code, /inst\.core_version/, 'and the handler does not pretend to read it');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
await t('THE SELF-ROW GUARD IS VACUOUS FOR THIS ACTION, so neither door half may call it as a gate', () => {
|
|
318
|
+
// The failure this pins is a guard that reads as present and can never fire —
|
|
319
|
+
// the third instance of that shape on this one task. `intentAllowedForShape`
|
|
320
|
+
// answers TRUE for 'core-upgrade' against every shape there is: for a tenant
|
|
321
|
+
// shape unconditionally, and for the control plane because the action is
|
|
322
|
+
// permanently on CONTROL_PLANE_ACTIONS. A reader scanning either file would
|
|
323
|
+
// reasonably believe two gates stood there; only one ever did.
|
|
324
|
+
for (const shape of [...P.HOSTING_SHAPES, 'something-new', undefined, null]) {
|
|
325
|
+
assert.equal(G.intentAllowedForShape('core-upgrade', shape), true,
|
|
326
|
+
`the guard cannot refuse core-upgrade for shape '${shape}' — so calling it as a gate is dead code`);
|
|
327
|
+
}
|
|
328
|
+
// Neither half of the door may name it. If a later edit puts it back, this fails
|
|
329
|
+
// and points at the sentence above rather than at a mystery.
|
|
330
|
+
for (const f of [['modules', 'provisioning', 'core-upgrade.js'], ['scripts', 'gds', 'provision-core-upgrade.js']]) {
|
|
331
|
+
const src = read(...f).replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/^\s*\/\/.*$/gm, ' ');
|
|
332
|
+
assert.ok(!/intentAllowedForShape\s*\(/.test(src),
|
|
333
|
+
`${f.join('/')} must not call intentAllowedForShape — it can only ever return true here`);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
console.log('\nthe preview — step one of the two-step (ADR 0293 D4):');
|
|
338
|
+
|
|
339
|
+
const previewInst = { id: 7, slug: 'cloudbongos', hosting_shape: 'control-plane' };
|
|
340
|
+
|
|
341
|
+
await t('a box behind a published version reads as behind, and names what it would move to', () => {
|
|
342
|
+
const p = CU.previewFromSnapshot(previewInst,
|
|
343
|
+
{ served_version: '1.19.758', available: ['1.19.758', '1.19.762'], recommended: '1.19.762', observed_at: '2026-09-16T10:00:00Z' }, null);
|
|
344
|
+
assert.equal(p.served_version, '1.19.758');
|
|
345
|
+
assert.equal(p.recommended_version, '1.19.762');
|
|
346
|
+
assert.equal(p.behind, true);
|
|
347
|
+
assert.equal(p.reading, 'ok');
|
|
348
|
+
assert.equal(p.observed_at, '2026-09-16T10:00:00Z', 'the age of the reading is part of the answer, never hidden');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await t('a box already on the newest is NOT behind, and offers nothing', () => {
|
|
352
|
+
const p = CU.previewFromSnapshot(previewInst,
|
|
353
|
+
{ served_version: '1.19.762', available: ['1.19.762'], recommended: '1.19.762', observed_at: 'x' }, null);
|
|
354
|
+
assert.equal(p.behind, false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await t('A BOX THAT DID NOT ANSWER IS "no_answer", NEVER a confident "up to date"', () => {
|
|
358
|
+
// The failure this pins: collapsing an unread box into `behind:false` renders as
|
|
359
|
+
// "nothing to deploy" over a box whose version nobody actually knows.
|
|
360
|
+
const p = CU.previewFromSnapshot(previewInst,
|
|
361
|
+
{ served_version: null, available: ['1.19.762'], recommended: null, observed_at: 'x' }, null);
|
|
362
|
+
assert.equal(p.reading, 'no_answer');
|
|
363
|
+
assert.equal(p.served_version, null);
|
|
364
|
+
assert.equal(p.behind, false);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await t('no snapshot at all reports observed_at:null rather than a confident-looking empty reading', () => {
|
|
368
|
+
const p = CU.previewFromSnapshot(previewInst, undefined, null);
|
|
369
|
+
assert.equal(p.observed_at, null);
|
|
370
|
+
assert.equal(p.reading, 'no_answer');
|
|
371
|
+
assert.equal(p.available_count, 0);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
await t('an open intent is surfaced with its target, so the page can say what is already running', () => {
|
|
375
|
+
const p = CU.previewFromSnapshot(previewInst, { served_version: '1.19.758', available: [], recommended: null },
|
|
376
|
+
{ action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
|
|
377
|
+
assert.deepEqual(p.open_intent, { action: 'core-upgrade', state: 'running', target_version: '1.19.762' });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
console.log('\nthe deploy page is gated on the same atom as its routes:');
|
|
381
|
+
|
|
382
|
+
await t('the page gate, the preview and the move all check core.pin.move — the shell can never drift wider than the button', () => {
|
|
383
|
+
const routes = read('modules', 'provisioning', 'routes', 'core-upgrade.js');
|
|
384
|
+
const gates = routes.match(/requirePermission\('([^']+)'\)/g) || [];
|
|
385
|
+
assert.ok(gates.length >= 3, 'all three routes carry a gate');
|
|
386
|
+
for (const g of gates) assert.match(g, /core\.pin\.move/, `every gate on this router is the same atom: ${g}`);
|
|
387
|
+
const auth = read('src', 'bongos', 'auth.js');
|
|
388
|
+
assert.match(auth, /requireCorePinPage: Object\.freeze\(\{ permission: 'core\.pin\.move'/, 'and the page gate is the same key');
|
|
389
|
+
const serve = read('src', 'bongos', 'serve-internal.js');
|
|
390
|
+
assert.match(serve, /const DEPLOY_PAGE_RE = \/\^\\\/builders\\\/deploy/, 'the page is matched');
|
|
391
|
+
assert.match(serve, /DEPLOY_PAGE_RE\.test\(norm\)\) \{\s*\n\s*return gdsAuth\.requireCorePinPage/, 'and dispatched to that gate');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await t('THE DEPLOY PAGE NEVER COMPUTES A VERSION COMPARISON — every number on it is a reading the server handed it', () => {
|
|
395
|
+
// The failure this pins is subtle and expensive: a page that decides for itself
|
|
396
|
+
// whether the box is behind will, one release later, disagree with the runner that
|
|
397
|
+
// actually performs the move — on the screen the owner trusts most.
|
|
398
|
+
const js = read('modules', 'hall-ui', 'public', 'deploy.js')
|
|
399
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
400
|
+
.replace(/^\s*\/\/.*$/gm, ' ');
|
|
401
|
+
assert.ok(!/localeCompare|semver|\.split\('\.'\)/.test(js), 'no version arithmetic on the client');
|
|
402
|
+
assert.ok(!/\bconst\s+\w*VERSION\w*\s*=\s*'/.test(js), 'no version literal baked into the page');
|
|
403
|
+
assert.match(js, /p\.recommended_version/, 'it sends the version the SERVER named');
|
|
404
|
+
assert.match(js, /p\.behind/, 'and asks the server whether there is anything to do');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
408
|
+
if (failed) process.exit(1);
|