@bongos/core 1.19.922 → 1.19.924
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 +48 -28
- package/docs/adr/0322-a-remnant-needs-evidence-and-merged-wins.md +110 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +22 -15
- package/docs/copy-registry.json +81 -18
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/reward.js +11 -0
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/deploy.js +103 -3
- package/modules/hall-ui/public/project-settings.css +39 -0
- package/modules/ideas/idea-state.js +254 -0
- package/modules/ideas/routes/inbox.js +8 -0
- package/modules/ideas/trail.js +13 -1
- package/modules/provisioning/core-upgrade.js +24 -5
- package/modules/provisioning/migrations/provisioning_020_core_upgrade_preflight.sql +53 -0
- package/modules/provisioning/provisioning.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-core-upgrade.js +123 -66
- package/scripts/gds/provision.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_door.mjs +56 -4
- package/tests/core_upgrade_runner.mjs +82 -0
- package/tests/deploy_page_projects.mjs +104 -5
- package/tests/idea_state.mjs +399 -0
|
@@ -184,73 +184,24 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
184
184
|
// the two attribution flags (ADR 0293 D5). --commit-pin is NOT optional here: without
|
|
185
185
|
// it the pin lives only in the working tree, and the next pull-deploy resets it away —
|
|
186
186
|
// the silent thirteen-release revert upgrade.js's own header records.
|
|
187
|
-
const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
|
|
188
187
|
const versionUrl = `http://127.0.0.1:${inst.port}/version`;
|
|
189
|
-
// WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
|
|
190
|
-
//
|
|
191
|
-
// A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
|
|
192
|
-
// CURRENT OS USER rather than a flag:
|
|
193
|
-
//
|
|
194
|
-
// • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
|
|
195
|
-
// is almost never a Postgres role — pre-flight refuses with "Postgres has no
|
|
196
|
-
// role root" before the pin moves (correctly; the pin must not move for a
|
|
197
|
-
// migrate that cannot run).
|
|
198
|
-
// • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
|
|
199
|
-
// node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
|
|
200
|
-
// repo that installs its workspaces rather than its root has no core there, so
|
|
201
|
-
// the name cannot be resolved at all.
|
|
202
|
-
//
|
|
203
|
-
// Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
|
|
204
|
-
// pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
|
|
205
|
-
// provision-repo.js — this leg simply never used them.
|
|
206
|
-
//
|
|
207
|
-
// The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
|
|
208
|
-
// Postgres login role (instanceDbRole), so peer auth lands on a role that owns
|
|
209
|
-
// exactly that instance's database. The control plane is the exception: it is not a
|
|
210
|
-
// provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
|
|
211
|
-
// the recipe's gate-3 invocation names.
|
|
212
|
-
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
213
|
-
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
214
|
-
// WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
|
|
215
|
-
//
|
|
216
|
-
// `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
|
|
217
|
-
// no scripts/ directory at all — its core sits under node_modules/@bongos/core — so
|
|
218
|
-
// running the CLI with cwd = the instance root resolved nothing, and EVERY standalone
|
|
219
|
-
// upgrade died before it began:
|
|
220
|
-
//
|
|
221
|
-
// Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
|
|
222
|
-
//
|
|
223
|
-
// It worked only where instanceDir HAPPENED to be this checkout — the control-plane self
|
|
224
|
-
// row and a co-tenant — which is why it survived to production: the shapes that are
|
|
225
|
-
// upgraded by hand are the shapes the relative path fits. Every ACTIVE tenant is
|
|
226
|
-
// standalone, so the deploy door could not move a single one of them.
|
|
227
|
-
//
|
|
228
|
-
// upgrade.js's own header states the contract this restores: "Runs from the consumer
|
|
229
|
-
// instance root (default) or `--instance <dir>`. This script ships IN the core … against
|
|
230
|
-
// `--instance <consumer>`." So: run it where it exists, and NAME the target.
|
|
231
|
-
const runDir = REPO_ROOT;
|
|
232
|
-
const targetArg = instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`;
|
|
233
|
-
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
234
188
|
const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
|
|
235
189
|
? intent.requested_by.replace(/^api:archon:/, '')
|
|
236
190
|
: '';
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`--to ${to}`,
|
|
241
|
-
targetArg,
|
|
242
|
-
`--service ${inst.slug}`,
|
|
243
|
-
`--health-url ${healthUrl}`,
|
|
244
|
-
`--version-url ${versionUrl}`,
|
|
245
|
-
'--commit-pin',
|
|
246
|
-
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
247
|
-
requestedBy ? '--upgrade-source owner-control' : '',
|
|
248
|
-
].filter(Boolean).join(' ');
|
|
191
|
+
// Built by the SAME function the preview uses, so "what you were shown" and "what runs"
|
|
192
|
+
// cannot drift apart into two hand-maintained flag lists (task 1004121).
|
|
193
|
+
const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, requestedBy });
|
|
249
194
|
|
|
250
195
|
if (!apply) {
|
|
251
|
-
|
|
196
|
+
// A --dry-run runner tick used to only PRINT this command. It now runs the CLI's own
|
|
197
|
+
// dry run, so `provision.js --dry-run` reports what the upgrade would actually refuse
|
|
198
|
+
// rather than a command string the reader has to evaluate in their head.
|
|
199
|
+
log(` ✓ would run (in ${runDir}): ${cmd}`);
|
|
200
|
+
const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
|
|
201
|
+
for (const line of verdict.lines || []) log(` ✓ pre-flight: ${line}`);
|
|
202
|
+
if (!verdict.ok) log(` ✖ ${verdict.error}`);
|
|
252
203
|
log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
|
|
253
|
-
return { ok: true, served: null };
|
|
204
|
+
return { ok: true, served: null, preflight: verdict };
|
|
254
205
|
}
|
|
255
206
|
try {
|
|
256
207
|
log(` [upgrade] (in ${runDir}) ${cmd}`);
|
|
@@ -290,6 +241,100 @@ async function coreUpgradeInstance(inst, deps, intent) {
|
|
|
290
241
|
return { ok: true, served, confirmed: served === to };
|
|
291
242
|
}
|
|
292
243
|
|
|
244
|
+
// The pre-flight lines a dry run emits, and the refusal when it has one. upgrade.js writes
|
|
245
|
+
// its pre-flight to stdout as ` ✓ pre-flight: …` and returns a `✖`-prefixed multi-line
|
|
246
|
+
// error on a refusal, so BOTH streams matter: a pass is only legible from stdout, and a
|
|
247
|
+
// refusal only from the failure text. PURE given its input.
|
|
248
|
+
function parsePreflight(out) {
|
|
249
|
+
const text = String(out || '');
|
|
250
|
+
const lines = text.split('\n').map((l) => l.trim());
|
|
251
|
+
const checks = lines.filter((l) => /^[✓✖]\s*pre-flight:/.test(l)).map((l) => l.replace(/^[✓✖]\s*/, ''));
|
|
252
|
+
// A refusal is whatever upgrade.js said after the ✖ — reported verbatim rather than
|
|
253
|
+
// re-worded, because its wording already names the fix ("Set PGDATABASE, or …").
|
|
254
|
+
const failAt = text.indexOf('✖');
|
|
255
|
+
const error = failAt >= 0 ? text.slice(failAt + 1).trim() : null;
|
|
256
|
+
return { checks, error };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ONE BUILDER FOR BOTH LEGS (task 1004121).
|
|
260
|
+
//
|
|
261
|
+
// The preview and the move must be the same command or the preview is worthless — and
|
|
262
|
+
// two hand-copied builders are exactly how they stop being the same. The mode decides
|
|
263
|
+
// only the TAIL: a dry run stops after pre-flight, so the flags that describe what to do
|
|
264
|
+
// AFTER the pin moves (restart, health, version, pin-commit, attribution) would be
|
|
265
|
+
// inert on it. Everything that decides whether the upgrade is ALLOWED — the account, the
|
|
266
|
+
// database, the target, the instance — is shared, which is the half a preview is about.
|
|
267
|
+
//
|
|
268
|
+
// The slug rides into a shell command through instanceUser()/dbName(), so it is checked
|
|
269
|
+
// HERE rather than left to instanceUser()'s own throw: that throw is correct today, but
|
|
270
|
+
// a call site whose safety depends on a helper's internals is one refactor away from a
|
|
271
|
+
// shell-injection path (and the apply leg's own guard sits at the route, not here).
|
|
272
|
+
function upgradeInvocation(inst, to, { privileged = false, dryRun = false, requestedBy = '' } = {}) {
|
|
273
|
+
if (!isValidSlug(inst && inst.slug)) {
|
|
274
|
+
throw new Error(`upgradeInvocation: refusing to build a shell command from an invalid slug ${JSON.stringify(inst && inst.slug)}`);
|
|
275
|
+
}
|
|
276
|
+
const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
|
|
277
|
+
// The control plane is not a provisioned tenant and has no `bongos-` account; every
|
|
278
|
+
// other shape runs as its own, which is also its Postgres login role (task 1004119).
|
|
279
|
+
const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
|
|
280
|
+
const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
|
|
281
|
+
const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
|
|
282
|
+
const shared = [`${sudoP}node ${cli}`, '--registry', `--to ${to}`,
|
|
283
|
+
instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`];
|
|
284
|
+
const tail = dryRun
|
|
285
|
+
? ['--dry-run']
|
|
286
|
+
: [`--service ${inst.slug}`,
|
|
287
|
+
`--health-url http://127.0.0.1:${inst.port}/healthz`,
|
|
288
|
+
`--version-url http://127.0.0.1:${inst.port}/version`,
|
|
289
|
+
'--commit-pin',
|
|
290
|
+
requestedBy ? `--requested-by ${requestedBy}` : '',
|
|
291
|
+
requestedBy ? '--upgrade-source owner-control' : ''];
|
|
292
|
+
return { cmd: [...shared, ...tail].filter(Boolean).join(' '), runDir: REPO_ROOT, instanceDir, runAs };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Ask the upgrade CLI what it WOULD do, changing nothing. `allowFail` because a refusal
|
|
296
|
+
// is the interesting answer, not an exception: pre-flight refusing is what this is for.
|
|
297
|
+
function preflightUpgrade(inst, to, deps) {
|
|
298
|
+
const { exec, privileged } = deps;
|
|
299
|
+
const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
|
|
300
|
+
const { cmd, runDir } = upgradeInvocation(inst, to, { privileged, dryRun: true });
|
|
301
|
+
const r = boxExec(cmd, { cwd: runDir, allowFail: true });
|
|
302
|
+
const out = `${(r && r.stdout) || ''}\n${(r && r.stderr) || ''}`;
|
|
303
|
+
const { checks, error } = parsePreflight(out);
|
|
304
|
+
// ok is the RUN's verdict, not the parser's: a command that exited non-zero without a
|
|
305
|
+
// recognisable ✖ is still a refusal, and reporting it as a pass is the failure mode
|
|
306
|
+
// this whole task exists to remove.
|
|
307
|
+
const ok = !!(r && !r.softFailed && r.ok !== false) && !error;
|
|
308
|
+
return { target: to, ok, lines: checks, error: error || (ok ? null : 'the dry run did not complete') };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
|
|
312
|
+
// a `core-upgrade-preflight` intent the owner's arming queues (task 1004121). The tick
|
|
313
|
+
// does not do this: a verdict that is only ever produced on a background schedule is
|
|
314
|
+
// missing at exactly the moment it is wanted (a fresh release, a new instance), and
|
|
315
|
+
// re-running it every tick for a box that has been behind for hours spends a subprocess
|
|
316
|
+
// to re-learn the same answer. Asked for, then cached, is both cheaper and timelier.
|
|
317
|
+
async function preflightCoreUpgrade(inst, deps, intent) {
|
|
318
|
+
const { db, log } = deps;
|
|
319
|
+
const to = intent && intent.target_version;
|
|
320
|
+
if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') {
|
|
321
|
+
return { ok: false, error: `cannot preview a ${inst.hosting_shape} instance` };
|
|
322
|
+
}
|
|
323
|
+
if (!to) return { ok: false, error: 'no target version to preview' };
|
|
324
|
+
const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
|
|
325
|
+
await db.query(
|
|
326
|
+
`INSERT INTO provisioning_core_version_snapshot (instance_id, preflight, preflight_at, observed_at)
|
|
327
|
+
VALUES ($1, $2::jsonb, now(), now())
|
|
328
|
+
ON CONFLICT (instance_id) DO UPDATE
|
|
329
|
+
SET preflight = EXCLUDED.preflight, preflight_at = now()`,
|
|
330
|
+
[inst.id, JSON.stringify(verdict)]);
|
|
331
|
+
log(` ${verdict.ok ? '✓' : '✖'} ${inst.slug}: pre-flight for ${to} — ${verdict.ok ? 'clear' : verdict.error}`);
|
|
332
|
+
// ok:true means THE PREVIEW RAN. A refusal is a successful preview of a refusal — the
|
|
333
|
+
// intent must not go to 'error', or the owner's next arming is blocked by a stale open
|
|
334
|
+
// intent describing a check that worked perfectly.
|
|
335
|
+
return { ok: true, preflight: verdict };
|
|
336
|
+
}
|
|
337
|
+
|
|
293
338
|
/**
|
|
294
339
|
* Observe one instance and cache what the deploy control needs, without acting.
|
|
295
340
|
*
|
|
@@ -323,14 +368,26 @@ async function snapshotCoreVersions(inst, deps) {
|
|
|
323
368
|
const recommended = served
|
|
324
369
|
? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
|
|
325
370
|
: null;
|
|
371
|
+
// THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
|
|
372
|
+
// because a check re-run every tick for a box that has been behind for hours spends a
|
|
373
|
+
// subprocess to re-learn the same answer, and is still missing in the one window that
|
|
374
|
+
// matters — the minutes after a release, which is when someone presses the button.
|
|
375
|
+
// The version probe below is cheap and genuinely per-tick; a dry run is not.
|
|
376
|
+
const preflight = null;
|
|
326
377
|
await db.query(
|
|
327
|
-
`INSERT INTO provisioning_core_version_snapshot
|
|
328
|
-
|
|
378
|
+
`INSERT INTO provisioning_core_version_snapshot
|
|
379
|
+
(instance_id, served_version, available, recommended, observed_at, preflight, preflight_at)
|
|
380
|
+
VALUES ($1, $2, $3::jsonb, $4, now(), $5::jsonb, $6)
|
|
329
381
|
ON CONFLICT (instance_id) DO UPDATE
|
|
330
382
|
SET served_version = EXCLUDED.served_version, available = EXCLUDED.available,
|
|
331
|
-
recommended = EXCLUDED.recommended, observed_at = now()
|
|
332
|
-
|
|
333
|
-
|
|
383
|
+
recommended = EXCLUDED.recommended, observed_at = now(),
|
|
384
|
+
-- A tick with nothing to preview must not ERASE the last verdict; it simply
|
|
385
|
+
-- has nothing newer to say. Only a real dry run overwrites one.
|
|
386
|
+
preflight = COALESCE(EXCLUDED.preflight, provisioning_core_version_snapshot.preflight),
|
|
387
|
+
preflight_at = COALESCE(EXCLUDED.preflight_at, provisioning_core_version_snapshot.preflight_at)`,
|
|
388
|
+
[inst.id, served, JSON.stringify(available), recommended || null,
|
|
389
|
+
preflight ? JSON.stringify(preflight) : null, preflight ? new Date() : null]);
|
|
390
|
+
return { ok: true, served, available: available.length, preflight };
|
|
334
391
|
} catch (e) {
|
|
335
392
|
log(` ~ core-version snapshot for ${inst.slug} skipped (${(e && e.message) || e})`);
|
|
336
393
|
return { ok: true, skipped: true };
|
|
@@ -352,4 +409,4 @@ async function snapshotAllCoreVersions(deps) {
|
|
|
352
409
|
return { seen };
|
|
353
410
|
}
|
|
354
411
|
|
|
355
|
-
module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
|
412
|
+
module.exports = { UPGRADE_SHAPES, versionCmd, parseServedVersion, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -58,7 +58,7 @@ const { capacityPreflight, statusCapacityLine } = require('./provision-capacity.
|
|
|
58
58
|
const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
|
|
59
59
|
const { teardownInstance } = require('./provision-teardown.js');
|
|
60
60
|
const { disconnectInstance } = require('./provision-disconnect.js');
|
|
61
|
-
const { coreUpgradeInstance, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
|
|
61
|
+
const { coreUpgradeInstance, preflightCoreUpgrade, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
|
|
62
62
|
|
|
63
63
|
// WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
|
|
64
64
|
// seam exists so a plane without systemd is not a dead end. Injectable for tests.
|
|
@@ -1288,6 +1288,7 @@ async function cmdRunIntents(deps) {
|
|
|
1288
1288
|
else if (intent.action === 'restart') result = await restartInstance(inst, deps);
|
|
1289
1289
|
else if (intent.action === 'disconnect') result = await disconnectInstance(inst, deps);
|
|
1290
1290
|
else if (intent.action === 'core-upgrade') result = await coreUpgradeInstance(inst, deps, intent); // also reads the INTENT: the version the owner previewed (ADR 0293 D4)
|
|
1291
|
+
else if (intent.action === 'core-upgrade-preflight') result = await preflightCoreUpgrade(inst, deps, intent); // changes nothing; caches what the move WOULD say (task 1004121)
|
|
1291
1292
|
else result = { ok: false, error: `unknown_action '${intent.action}' — runner core ${coreVersionSafe() || '?'} does not know it (runner too old?); refusing to guess` };
|
|
1292
1293
|
if (result && result.ok === false) {
|
|
1293
1294
|
if (intent.attempts >= MAX_INTENT_ATTEMPTS) {
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.924'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { strict as assert } from 'node:assert';
|
|
17
17
|
import { createRequire } from 'node:module';
|
|
18
|
-
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
|
|
@@ -52,10 +52,24 @@ await t('the shape and the action both reach the DB constraints', () => {
|
|
|
52
52
|
// The JS list and the CHECK are two spellings of one vocabulary; a widening that
|
|
53
53
|
// lands in only one of them fails at insert time, in production, on the first
|
|
54
54
|
// real use. (The bug class task 1003913 shipped a fix for, in another file.)
|
|
55
|
-
|
|
55
|
+
// Read the NEWEST migration that widens the action CHECK, not a fixed filename: the
|
|
56
|
+
// vocabulary grows, and a test pinned to one file passes forever while the constraint
|
|
57
|
+
// it is meant to guard falls behind (task 1004121 added an action 018 does not carry).
|
|
58
|
+
const migDir = join(__dirname, '..', 'modules', 'provisioning', 'migrations');
|
|
59
|
+
const widenings = readdirSync(migDir).filter((f) => f.endsWith('.sql'))
|
|
60
|
+
.filter((f) => /ADD CONSTRAINT provisioning_intents_action_check/.test(readFileSync(join(migDir, f), 'utf8')))
|
|
61
|
+
.sort();
|
|
62
|
+
assert.ok(widenings.length, 'some migration defines the action CHECK');
|
|
63
|
+
const mig = readFileSync(join(migDir, widenings[widenings.length - 1]), 'utf8')
|
|
64
|
+
.replace(/^\s*--.*$/gm, ' ').replace(/\s+/g, ' ');
|
|
65
|
+
const shapes = read('modules', 'provisioning', 'migrations', 'provisioning_018_core_upgrade_intent.sql')
|
|
56
66
|
.replace(/^\s*--.*$/gm, ' ').replace(/\s+/g, ' ');
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
// Membership, not POSITION. The old spelling required 'core-upgrade' to be the last
|
|
68
|
+
// item before the closing paren, so the next action appended after it failed a test
|
|
69
|
+
// that had nothing to say about the next action.
|
|
70
|
+
assert.match(mig, /CHECK \(action IN \(/, 'the intents CHECK is an action allow-list');
|
|
71
|
+
assert.ok(mig.includes("'core-upgrade'"), 'and it still admits the move itself');
|
|
72
|
+
assert.match(shapes, /CHECK \(hosting_shape IN \([^)]*'control-plane'\)\)/, 'the instances CHECK admits the new shape');
|
|
59
73
|
for (const a of P.INTENT_ACTIONS) {
|
|
60
74
|
assert.ok(mig.includes(`'${a}'`), `the widened CHECK must still carry '${a}' — a rewrite drops what it forgets`);
|
|
61
75
|
}
|
|
@@ -404,5 +418,43 @@ await t('THE DEPLOY PAGE NEVER COMPUTES A VERSION COMPARISON — every number on
|
|
|
404
418
|
assert.match(js, /p\.behind/, 'and asks the server whether there is anything to do');
|
|
405
419
|
});
|
|
406
420
|
|
|
421
|
+
console.log('\nthe pre-flight verdict, and when it may be shown (task 1004121):');
|
|
422
|
+
|
|
423
|
+
const snapOf = (over = {}) => ({
|
|
424
|
+
served_version: '1.19.917', available: ['1.19.917', '1.19.921'], recommended: '1.19.921',
|
|
425
|
+
observed_at: new Date().toISOString(), preflight_at: new Date().toISOString(), ...over,
|
|
426
|
+
});
|
|
427
|
+
const box = { id: '1', slug: 'cloudbongos', hosting_shape: 'control-plane' };
|
|
428
|
+
|
|
429
|
+
await t('a verdict taken against the version ON OFFER is shown', () => {
|
|
430
|
+
const p = CU.previewFromSnapshot(box, snapOf({
|
|
431
|
+
preflight: { target: '1.19.921', ok: true, lines: ['database reachable'], error: null } }), null);
|
|
432
|
+
assert.ok(p.preflight, 'it reaches the control');
|
|
433
|
+
assert.equal(p.preflight.target, '1.19.921');
|
|
434
|
+
assert.ok(p.preflight_at, 'with its own age, not the probe’s');
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
await t('a verdict taken against a SUPERSEDED version is withheld, not shown as a pass', () => {
|
|
438
|
+
// The whole point of storing `target` with the verdict. A pass checked against
|
|
439
|
+
// 1.19.920 says nothing true about a button labelled 1.19.921 — and a stale pass is
|
|
440
|
+
// worse than no answer, because it reads as a guarantee.
|
|
441
|
+
const p = CU.previewFromSnapshot(box, snapOf({
|
|
442
|
+
preflight: { target: '1.19.920', ok: true, lines: ['database reachable'], error: null } }), null);
|
|
443
|
+
assert.equal(p.preflight, null, 'withheld');
|
|
444
|
+
assert.equal(p.preflight_at, null, 'and so is its timestamp — no age for an answer not given');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
await t('a stale REFUSAL is withheld too — it must not veto a move it never checked', () => {
|
|
448
|
+
const p = CU.previewFromSnapshot(box, snapOf({
|
|
449
|
+
preflight: { target: '1.19.900', ok: false, lines: [], error: 'working tree not clean' } }), null);
|
|
450
|
+
assert.equal(p.preflight, null, 'the guard is symmetric: it withholds fails as well as passes');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
await t('no verdict at all is null — never an implied pass', () => {
|
|
454
|
+
const p = CU.previewFromSnapshot(box, snapOf({ preflight: null, preflight_at: null }), null);
|
|
455
|
+
assert.equal(p.preflight, null);
|
|
456
|
+
assert.equal(p.behind, true, 'and the rest of the reading is unaffected');
|
|
457
|
+
});
|
|
458
|
+
|
|
407
459
|
console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
|
|
408
460
|
if (failed) process.exit(1);
|
|
@@ -471,5 +471,87 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
|
|
|
471
471
|
assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
console.log('\nthe dry run behind "see what would change" (task 1004121):');
|
|
475
|
+
|
|
476
|
+
await t('a passing dry run is parsed into its pre-flight lines, with no error', () => {
|
|
477
|
+
const { checks, error } = R.parsePreflight([
|
|
478
|
+
'bongos upgrade — @bongos/core: 1.19.917 → 1.19.921',
|
|
479
|
+
' ✓ pre-flight: no modules/ — nothing to pre-check',
|
|
480
|
+
' ✓ pre-flight: database "cloudbongos" reachable as the current user',
|
|
481
|
+
].join('\n'));
|
|
482
|
+
assert.equal(error, null);
|
|
483
|
+
assert.equal(checks.length, 2);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
await t('a REFUSAL is carried verbatim — its wording is the instruction', () => {
|
|
487
|
+
const { error } = R.parsePreflight(
|
|
488
|
+
' ✖ cannot determine which database to migrate.\n Set PGDATABASE, or give this instance a db.database in config/branding.json.');
|
|
489
|
+
assert.match(error, /Set PGDATABASE/, 'the remedy rides along, not just the complaint');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
await t('ONE builder serves both legs — the preview and the move cannot drift apart', () => {
|
|
493
|
+
// Two hand-copied builders are how "what you were shown" stops matching "what runs".
|
|
494
|
+
// Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
|
|
495
|
+
// that describes what happens AFTER the pin moves may differ, because a dry run stops
|
|
496
|
+
// before it.
|
|
497
|
+
const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone', db_name: 'test4' };
|
|
498
|
+
const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
|
|
499
|
+
const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
|
|
500
|
+
for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
|
|
501
|
+
assert.ok(move.cmd.includes(shared), `move carries ${shared}`);
|
|
502
|
+
assert.ok(peek.cmd.includes(shared), `preview carries the same ${shared}`);
|
|
503
|
+
}
|
|
504
|
+
assert.equal(move.instanceDir, peek.instanceDir, 'same target directory');
|
|
505
|
+
assert.equal(move.runAs, peek.runAs, 'same account');
|
|
506
|
+
// And the tail genuinely differs — asserted rather than assumed, so this test cannot
|
|
507
|
+
// quietly pass a preview that would restart the box.
|
|
508
|
+
assert.match(peek.cmd, /--dry-run/);
|
|
509
|
+
for (const post of ['--service', '--health-url', '--version-url', '--commit-pin', '--requested-by']) {
|
|
510
|
+
assert.ok(move.cmd.includes(post), `the move carries ${post}`);
|
|
511
|
+
assert.ok(!peek.cmd.includes(post), `the preview must NOT carry ${post} — it never gets that far`);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
|
|
516
|
+
// The slug reaches a shell through instanceUser()/dbName(). Depending on another
|
|
517
|
+
// function's internals for that is one refactor away from an injection path.
|
|
518
|
+
assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'standalone' }, '1.19.921', {}),
|
|
519
|
+
/invalid slug/);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
|
|
523
|
+
const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
|
|
524
|
+
const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone' }, '1.19.921', deps);
|
|
525
|
+
assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
|
|
526
|
+
assert.ok(r.error);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
await t('the TICK never dry-runs — the verdict is asked for, not produced on a schedule', async () => {
|
|
530
|
+
// Re-running a check every tick for a box behind for hours spends a subprocess to
|
|
531
|
+
// re-learn the same answer, and is still missing in the window that matters.
|
|
532
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
533
|
+
let spawned = 0;
|
|
534
|
+
d.preflightUpgrade = () => { spawned++; return { ok: true }; };
|
|
535
|
+
await R.snapshotCoreVersions(inst(), d);
|
|
536
|
+
assert.equal(spawned, 0, 'the version probe is per-tick; the dry run is not');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await t('the on-demand leg caches the verdict, and a REFUSAL is still a successful preview', async () => {
|
|
540
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
541
|
+
d.preflightUpgrade = () => ({ target: '1.19.921', ok: false, lines: [], error: 'working tree not clean' });
|
|
542
|
+
const r = await R.preflightCoreUpgrade(inst(), d, intent({ target_version: '1.19.921' }));
|
|
543
|
+
assert.equal(r.ok, true, 'the CHECK ran — a refusal is what it was for, not a failure of it');
|
|
544
|
+
const write = d.queries.find((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql));
|
|
545
|
+
assert.ok(write, 'the verdict is cached');
|
|
546
|
+
assert.match(String(write.params[1]), /working tree not clean/);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
await t('a preview of nothing is refused rather than cached as a pass', async () => {
|
|
550
|
+
const d = makeDeps({ served: '1.19.762' });
|
|
551
|
+
const r = await R.preflightCoreUpgrade(inst(), d, intent({ target_version: null }));
|
|
552
|
+
assert.equal(r.ok, false);
|
|
553
|
+
assert.equal(d.queries.filter((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql)).length, 0);
|
|
554
|
+
});
|
|
555
|
+
|
|
474
556
|
console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
|
|
475
557
|
if (failed) process.exit(1);
|
|
@@ -75,12 +75,18 @@ function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus =
|
|
|
75
75
|
OTB: { escapeHtml: (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])), toast() {} },
|
|
76
76
|
location: { assign() {} },
|
|
77
77
|
};
|
|
78
|
+
// The page's poll interval, driven rather than slept through. Injecting the REAL
|
|
79
|
+
// setTimeout made every case whose preview never converges sit out the page's full
|
|
80
|
+
// ~30s wait in wall-clock, still repainting after the test had finished — slow, and
|
|
81
|
+
// flaky against a torn-down sandbox.
|
|
82
|
+
windowObj.__deployPollMs = 0;
|
|
83
|
+
windowObj.__deployPollTries = 3;
|
|
78
84
|
const sandbox = { window: windowObj, document: documentObj, console, setTimeout, clearTimeout, queueMicrotask, Date, Set, Map, Promise };
|
|
79
85
|
sandbox.globalThis = sandbox;
|
|
80
86
|
vm.createContext(sandbox);
|
|
81
87
|
vm.runInContext(SRC, sandbox, { filename: 'deploy.js' });
|
|
82
88
|
documentObj._ready();
|
|
83
|
-
const settle = async () => { for (let i = 0; i <
|
|
89
|
+
const settle = async () => { for (let i = 0; i < 40; i++) await new Promise((r) => setTimeout(r, 0)); };
|
|
84
90
|
return { els, posts, gets, settle, panel: () => els.get('dp-panel').innerHTML, sub: () => els.get('dp-sub').textContent };
|
|
85
91
|
}
|
|
86
92
|
|
|
@@ -126,13 +132,20 @@ test('confirming sends THAT project’s id and the version it was shown', async
|
|
|
126
132
|
b.els.get('dp-preview-32')._on.click();
|
|
127
133
|
b.els.get('dp-confirm-32')._on.click();
|
|
128
134
|
await b.settle();
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
// Arming also asks for a dry run now (task 1004121), so the MOVE is the post that is
|
|
136
|
+
// not one — separated by its payload rather than by its position, because "the second
|
|
137
|
+
// post" would silently start asserting about the wrong request the next time the order
|
|
138
|
+
// changes.
|
|
139
|
+
const moves = b.posts.filter((x) => !(x.body && x.body.dry_run));
|
|
140
|
+
assert.equal(moves.length, 1, 'exactly one move is sent');
|
|
141
|
+
assert.equal(moves[0].path, '/provisioning/instances/32/core-upgrade');
|
|
131
142
|
// Field-wise, not deepEqual: the body is built inside the vm realm, so its
|
|
132
143
|
// prototype is not this realm's Object and deepStrictEqual rejects it on identity
|
|
133
144
|
// alone — a harness artifact that says nothing about the payload.
|
|
134
|
-
assert.equal(
|
|
135
|
-
assert.deepEqual(Object.keys(
|
|
145
|
+
assert.equal(moves[0].body.to, '1.19.871');
|
|
146
|
+
assert.deepEqual(Object.keys(moves[0].body), ['to'], 'and nothing else is sent');
|
|
147
|
+
// And the move must never carry the flag that would turn it into a no-op.
|
|
148
|
+
assert.equal(moves[0].body.dry_run, undefined, 'a move is not a dry run');
|
|
136
149
|
});
|
|
137
150
|
|
|
138
151
|
test('a box with nothing newer offers no button', async () => {
|
|
@@ -191,3 +204,89 @@ test('a move on the platform row re-reads the control plane, not a tenant previe
|
|
|
191
204
|
assert.ok(b.gets.includes('/provisioning/control-plane'), 'the self row is read from its own endpoint');
|
|
192
205
|
assert.ok(!b.gets.some((g) => /instances\/3\d\/core-upgrade/.test(g)), 'and no tenant is re-probed');
|
|
193
206
|
});
|
|
207
|
+
|
|
208
|
+
// ---- the pre-flight verdict on step two (task 1004121) ---------------------------
|
|
209
|
+
// The defect: step one was called "see what would change" and changed nothing but a
|
|
210
|
+
// local flag. These read what step two actually PAINTS, because the bug was invisible
|
|
211
|
+
// to every assertion about what the page fetched — it fetched nothing.
|
|
212
|
+
|
|
213
|
+
test('step two shows what the dry run found, not just a restatement of the action', async () => {
|
|
214
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', {
|
|
215
|
+
preflight: { target: '1.19.871', ok: true, lines: ['database "cloudbongos" reachable as the current user'], error: null },
|
|
216
|
+
preflight_at: new Date().toISOString(),
|
|
217
|
+
}) });
|
|
218
|
+
await b.settle();
|
|
219
|
+
b.els.get('dp-preview-1')._on.click();
|
|
220
|
+
const html = b.panel();
|
|
221
|
+
assert.match(html, /nothing blocks this move/i, 'it states a verdict');
|
|
222
|
+
assert.match(html, /database "cloudbongos" reachable|database "cloudbongos" reachable/, 'and shows what was checked');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('a BLOCKED move says so, in the upgrade’s own words, and still offers the button', async () => {
|
|
226
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', {
|
|
227
|
+
preflight: { target: '1.19.871', ok: false, lines: [], error: 'working tree not clean — commit/stash first, or pass --force.' },
|
|
228
|
+
preflight_at: new Date().toISOString(),
|
|
229
|
+
}) });
|
|
230
|
+
await b.settle();
|
|
231
|
+
b.els.get('dp-preview-1')._on.click();
|
|
232
|
+
const html = b.panel();
|
|
233
|
+
assert.match(html, /This would fail/, 'the owner is told before pressing');
|
|
234
|
+
assert.match(html, /working tree not clean/, 'verbatim — the wording names the fix');
|
|
235
|
+
// Deliberate: the runner refuses on its own, and this reading can be minutes old.
|
|
236
|
+
assert.match(html, /id="dp-confirm-1"/, 'the button is still there — a stale ✖ must not lock the owner out');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('with no verdict yet, arming ASKS for one and says so — never an implied pass', async () => {
|
|
240
|
+
// The whole failure mode in one test: silence where a check should be must not look
|
|
241
|
+
// like a clean bill of health, and the page must actually go and get the answer rather
|
|
242
|
+
// than wait for a background tick that may be minutes away.
|
|
243
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { preflight: null, preflight_at: null }) });
|
|
244
|
+
await b.settle();
|
|
245
|
+
b.els.get('dp-preview-1')._on.click();
|
|
246
|
+
// While it is in flight the row says so — asserted BEFORE settling, because the whole
|
|
247
|
+
// point is what the owner sees during the wait.
|
|
248
|
+
assert.match(b.panel(), /Checking what this would do/, 'it states that a check is under way');
|
|
249
|
+
await b.settle();
|
|
250
|
+
const asks = b.posts.filter((x) => x.body && x.body.dry_run === true);
|
|
251
|
+
assert.equal(asks.length, 1, 'exactly one check is requested');
|
|
252
|
+
assert.equal(asks[0].body.to, '1.19.871', 'against the version on offer');
|
|
253
|
+
// And when the answer never arrives (this harness never converges), it falls back to
|
|
254
|
+
// the honest state rather than to a pass — which is the failure mode being guarded.
|
|
255
|
+
const settled = b.panel();
|
|
256
|
+
assert.match(settled, /Not checked yet/, 'an answer that never came is stated as such');
|
|
257
|
+
assert.doesNotMatch(settled, /nothing blocks this move/i, 'and never dressed up as a pass');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('the poll does not close the panel the owner is reading', async () => {
|
|
261
|
+
// refreshOne() returns a row to step one — right after a MOVE, wrong during the wait.
|
|
262
|
+
// Polling with it un-armed the row under the owner every interval, so the confirm
|
|
263
|
+
// button vanished mid-check.
|
|
264
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { preflight: null, preflight_at: null }) });
|
|
265
|
+
await b.settle();
|
|
266
|
+
b.els.get('dp-preview-1')._on.click();
|
|
267
|
+
await b.settle();
|
|
268
|
+
assert.match(b.panel(), /id="dp-confirm-1"/, 'still armed after the poll has run and given up');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('a poll whose row is closed stops instead of repainting a step nobody is on', async () => {
|
|
272
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', { preflight: null, preflight_at: null }) });
|
|
273
|
+
await b.settle();
|
|
274
|
+
b.els.get('dp-preview-1')._on.click();
|
|
275
|
+
b.els.get('dp-cancel-1')._on.click();
|
|
276
|
+
await b.settle();
|
|
277
|
+
assert.match(b.panel(), /id="dp-preview-1"/, 'back at step one');
|
|
278
|
+
assert.doesNotMatch(b.panel(), /Checking what this would do/, 'and not still claiming to be checking');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('a verdict already in hand is NOT re-requested', async () => {
|
|
282
|
+
// Re-asking would spend a subprocess on the box to reprint an answer already shown.
|
|
283
|
+
const b = boot({ controlPlane: preview('1', 'cloudbongos', {
|
|
284
|
+
preflight: { target: '1.19.871', ok: true, lines: ['database reachable'], error: null },
|
|
285
|
+
preflight_at: new Date().toISOString(),
|
|
286
|
+
}) });
|
|
287
|
+
await b.settle();
|
|
288
|
+
b.els.get('dp-preview-1')._on.click();
|
|
289
|
+
await b.settle();
|
|
290
|
+
assert.equal(b.posts.filter((x) => x.body && x.body.dry_run).length, 0, 'no check requested');
|
|
291
|
+
assert.match(b.panel(), /nothing blocks this move/i, 'the held verdict is shown');
|
|
292
|
+
});
|