@bongos/core 1.19.923 → 1.19.925

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.
@@ -91,6 +91,31 @@ const CORE_PKG_DIR = 'node_modules/@bongos/core';
91
91
  // one constant so the backup dir owner + the backup unit's User= can't drift from it.
92
92
  const APP_USER = 'lars';
93
93
  function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
94
+ // WHERE THE INSTANCE THIS CORE SERVES ACTUALLY LIVES (task 1004123).
95
+ //
96
+ // REPO_ROOT is `path.resolve(__dirname, '..', '..')` — it points at whatever copy of
97
+ // the core is EXECUTING, which is not the same place as the instance being upgraded.
98
+ // Two shapes, and the code had only ever met the first:
99
+ //
100
+ // • a core CHECKOUT run in place (`/home/lars/cloudbongos`) — the instance root IS
101
+ // the checkout, so REPO_ROOT was right by coincidence;
102
+ // • a VENDORED core (`<instance>/node_modules/@bongos/core`) — REPO_ROOT is the
103
+ // package, and the instance is three directories up.
104
+ //
105
+ // The live control plane became the second shape when its runner unit was re-homed
106
+ // (`WorkingDirectory=/home/lars/cloudbongos-instance`, ExecStart into the vendored
107
+ // core), and nothing noticed: REPO_ROOT still resolved, just to the wrong tree. So
108
+ // `readInstalledCoreVersion` answered null instead of 1.19.917, every guard that
109
+ // reads `installed` silently stopped guarding, and an upgrade would have pinned and
110
+ // npm-installed INSIDE the core package while the instance's own pin stayed put.
111
+ //
112
+ // This is a string question, not a filesystem one: the vendored layout is the only
113
+ // way a core ends up under `node_modules/`, so the suffix IS the answer. Keeping it
114
+ // pure means the control-plane path is testable without a box.
115
+ function selfInstanceRoot(repoRoot = REPO_ROOT) {
116
+ const suffix = path.sep + CORE_PKG_DIR.split('/').join(path.sep);
117
+ return repoRoot.endsWith(suffix) ? repoRoot.slice(0, -suffix.length) : repoRoot;
118
+ }
94
119
  // Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
95
120
  // step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
96
121
  // precondition, like the runbook's `git pull`). Run with cwd = standaloneRoot.
@@ -1107,6 +1132,7 @@ module.exports = {
1107
1132
  safeVersionLabel,
1108
1133
  scaffoldStandaloneRepo,
1109
1134
  seedFirstVersionCmd,
1135
+ selfInstanceRoot,
1110
1136
  standaloneInstallCmd,
1111
1137
  standaloneMigrateCmd,
1112
1138
  standalonePullCmd,
@@ -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.923'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.925'; // 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
- const mig = read('modules', 'provisioning', 'migrations', 'provisioning_018_core_upgrade_intent.sql')
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
- 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');
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);
@@ -36,6 +36,7 @@ const R = require('../scripts/gds/provision-core-upgrade.js');
36
36
  const U = require('../scripts/gds/upgrade.js');
37
37
  const P = require('../modules/provisioning/provisioning.js');
38
38
  const G = require('../modules/provisioning/control-plane-guard.js');
39
+ const RP = require('../scripts/gds/provision-repo.js');
39
40
 
40
41
  let passed = 0;
41
42
  let failed = 0;
@@ -471,5 +472,180 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
471
472
  assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
472
473
  });
473
474
 
475
+ console.log('\nthe dry run behind "see what would change" (task 1004121):');
476
+
477
+ await t('a passing dry run is parsed into its pre-flight lines, with no error', () => {
478
+ const { checks, error } = R.parsePreflight([
479
+ 'bongos upgrade — @bongos/core: 1.19.917 → 1.19.921',
480
+ ' ✓ pre-flight: no modules/ — nothing to pre-check',
481
+ ' ✓ pre-flight: database "cloudbongos" reachable as the current user',
482
+ ].join('\n'));
483
+ assert.equal(error, null);
484
+ assert.equal(checks.length, 2);
485
+ });
486
+
487
+ await t('a REFUSAL is carried verbatim — its wording is the instruction', () => {
488
+ const { error } = R.parsePreflight(
489
+ ' ✖ cannot determine which database to migrate.\n Set PGDATABASE, or give this instance a db.database in config/branding.json.');
490
+ assert.match(error, /Set PGDATABASE/, 'the remedy rides along, not just the complaint');
491
+ });
492
+
493
+ await t('ONE builder serves both legs — the preview and the move cannot drift apart', () => {
494
+ // Two hand-copied builders are how "what you were shown" stops matching "what runs".
495
+ // Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
496
+ // that describes what happens AFTER the pin moves may differ, because a dry run stops
497
+ // before it.
498
+ const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone', db_name: 'test4' };
499
+ const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
500
+ const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
501
+ for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
502
+ assert.ok(move.cmd.includes(shared), `move carries ${shared}`);
503
+ assert.ok(peek.cmd.includes(shared), `preview carries the same ${shared}`);
504
+ }
505
+ assert.equal(move.instanceDir, peek.instanceDir, 'same target directory');
506
+ assert.equal(move.runAs, peek.runAs, 'same account');
507
+ // And the tail genuinely differs — asserted rather than assumed, so this test cannot
508
+ // quietly pass a preview that would restart the box.
509
+ assert.match(peek.cmd, /--dry-run/);
510
+ for (const post of ['--service', '--health-url', '--version-url', '--commit-pin', '--requested-by']) {
511
+ assert.ok(move.cmd.includes(post), `the move carries ${post}`);
512
+ assert.ok(!peek.cmd.includes(post), `the preview must NOT carry ${post} — it never gets that far`);
513
+ }
514
+ });
515
+
516
+ await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
517
+ // The slug reaches a shell through instanceUser()/dbName(). Depending on another
518
+ // function's internals for that is one refactor away from an injection path.
519
+ assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'standalone' }, '1.19.921', {}),
520
+ /invalid slug/);
521
+ });
522
+
523
+ await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
524
+ const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
525
+ const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone' }, '1.19.921', deps);
526
+ assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
527
+ assert.ok(r.error);
528
+ });
529
+
530
+ await t('the TICK never dry-runs — the verdict is asked for, not produced on a schedule', async () => {
531
+ // Re-running a check every tick for a box behind for hours spends a subprocess to
532
+ // re-learn the same answer, and is still missing in the window that matters.
533
+ const d = makeDeps({ served: '1.19.762' });
534
+ let spawned = 0;
535
+ d.preflightUpgrade = () => { spawned++; return { ok: true }; };
536
+ await R.snapshotCoreVersions(inst(), d);
537
+ assert.equal(spawned, 0, 'the version probe is per-tick; the dry run is not');
538
+ });
539
+
540
+ await t('the on-demand leg caches the verdict, and a REFUSAL is still a successful preview', async () => {
541
+ const d = makeDeps({ served: '1.19.762' });
542
+ d.preflightUpgrade = () => ({ target: '1.19.921', ok: false, lines: [], error: 'working tree not clean' });
543
+ const r = await R.preflightCoreUpgrade(inst(), d, intent({ target_version: '1.19.921' }));
544
+ assert.equal(r.ok, true, 'the CHECK ran — a refusal is what it was for, not a failure of it');
545
+ const write = d.queries.find((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql));
546
+ assert.ok(write, 'the verdict is cached');
547
+ assert.match(String(write.params[1]), /working tree not clean/);
548
+ });
549
+
550
+ await t('a preview of nothing is refused rather than cached as a pass', async () => {
551
+ const d = makeDeps({ served: '1.19.762' });
552
+ const r = await R.preflightCoreUpgrade(inst(), d, intent({ target_version: null }));
553
+ assert.equal(r.ok, false);
554
+ assert.equal(d.queries.filter((q) => /INSERT INTO provisioning_core_version_snapshot/.test(q.sql)).length, 0);
555
+ });
556
+
557
+ console.log('\nwhere the instance actually is (task 1004123):');
558
+
559
+ // The vendored layout the live control plane runs in: the core is a PACKAGE inside the
560
+ // instance, so the directory the core executes from is three levels below the directory
561
+ // that must be upgraded.
562
+ const VENDORED = '/home/lars/cloudbongos-instance/node_modules/@bongos/core';
563
+ const INSTANCE = '/home/lars/cloudbongos-instance';
564
+ const LEGACY = '/home/lars/cloudbongos';
565
+
566
+ await t('selfInstanceRoot walks out of a vendored core, and leaves a plain checkout alone', () => {
567
+ assert.equal(RP.selfInstanceRoot(VENDORED), INSTANCE, 'the package is not the instance');
568
+ assert.equal(RP.selfInstanceRoot(LEGACY), LEGACY, 'a checkout run in place IS the instance');
569
+ });
570
+
571
+ await t('a path that merely CONTAINS the package name is not mistaken for one', () => {
572
+ // Guarding the string test itself: only a trailing segment means "I am the vendored
573
+ // core", and an instance that happens to live under a similarly-named directory must
574
+ // not be truncated into nonsense.
575
+ const decoy = '/srv/node_modules/@bongos/core-tools';
576
+ assert.equal(RP.selfInstanceRoot(decoy), decoy);
577
+ assert.equal(RP.selfInstanceRoot('/srv/node_modules/@bongos/core/sub'), '/srv/node_modules/@bongos/core/sub');
578
+ });
579
+
580
+ await t('the runner reads the installed version from the INSTANCE, not from the core package', async () => {
581
+ // The defect this replaces: readInstalledCoreVersion was handed REPO_ROOT, which in
582
+ // the vendored shape holds no node_modules/@bongos/core at all — so it answered null
583
+ // on the live control plane for as long as the shape has existed.
584
+ const asked = [];
585
+ const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => INSTANCE };
586
+ d.readInstalledCoreVersion = (dir) => { asked.push(dir); return '1.19.760'; };
587
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
588
+ assert.deepEqual(asked, [INSTANCE], 'asked the instance root exactly once');
589
+ });
590
+
591
+ await t('the CLI runs where it lives and NAMES the instance it is aimed at', async () => {
592
+ const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => INSTANCE };
593
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
594
+ const cmd = upgradeCall(d);
595
+ assert.ok(cmd.includes(`--instance ${INSTANCE} `),
596
+ `without this the upgrade pins and installs inside the core package and the real pin never moves — got: ${cmd}`);
597
+ });
598
+
599
+ await t('the legacy shape still omits --instance, because there is nothing to disambiguate', async () => {
600
+ const d = { ...makeDeps({ served: '1.19.762' }), selfInstanceRoot: () => require('../scripts/gds/provision-config.js').REPO_ROOT };
601
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
602
+ assert.doesNotMatch(upgradeCall(d), /--instance /, 'a checkout run in place is its own instance');
603
+ });
604
+
605
+ await t('the version snapshot asks the registry from the instance, not from inside the package', async () => {
606
+ // Weakest of the three sites — `recommended` is derived from the box's own /version,
607
+ // so aiming this one wrongly changes no answer today. It is guarded anyway: npm reads
608
+ // its configuration from the directory it runs in, and an unguarded line is a line
609
+ // that regresses quietly. Found by mutating each of the three sites in turn; this was
610
+ // the one no test noticed.
611
+ const cwds = [];
612
+ const d = makeDeps({ served: '1.19.760' });
613
+ d.selfInstanceRoot = () => INSTANCE;
614
+ d.updateChannel = { ...d.updateChannel, listAvailableVersions: ({ cwd }) => { cwds.push(cwd); return { ok: true, versions: ['1.19.760', '1.19.762'] }; } };
615
+ await R.snapshotCoreVersions(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d);
616
+ assert.deepEqual(cwds, [INSTANCE]);
617
+ });
618
+
619
+ await t('the PREVIEW aims at the instance too — a dry run of the wrong directory is the lie', async () => {
620
+ // This is the shape that made the defect dangerous rather than merely broken: dry-run
621
+ // against the core package and upgrade.js reports `(none) → <target>` with green
622
+ // pre-flight ticks, so the owner is shown a clean bill of health for a run that will
623
+ // restart production and roll back. Both legs share one builder; both must aim.
624
+ const box = { id: 41, slug: 'cloudbongos', port: 3002, hosting_shape: 'control-plane', db_name: 'cloudbongos' };
625
+ const selfRoot = () => INSTANCE;
626
+ const move = R.upgradeInvocation(box, '1.19.922', { privileged: true, selfRoot });
627
+ const peek = R.upgradeInvocation(box, '1.19.922', { privileged: true, dryRun: true, selfRoot });
628
+ assert.ok(peek.cmd.includes(`--instance ${INSTANCE} `), `the preview names its target — got: ${peek.cmd}`);
629
+ assert.equal(move.instanceDir, peek.instanceDir, 'and it is the same target the move would take');
630
+ assert.equal(peek.instanceDir, INSTANCE);
631
+ });
632
+
633
+ await t('an unreadable installed version is a REFUSAL, not a skipped channel check', () => {
634
+ // Fail-closed. This is the guard that would have caught the directory bug on the
635
+ // first press instead of after four live restarts.
636
+ const f = R.targetFault('1.19.760', ['1.19.758', '1.19.760'], null, 'patch', () => true);
637
+ assert.match(f, /could not read the core version installed/);
638
+ assert.match(f, /cannot be bounded by the 'patch' channel/);
639
+ });
640
+
641
+ await t('the runner refuses, and runs NOTHING, when it cannot see where the instance is', async () => {
642
+ const d = makeDeps({ served: '1.19.762' });
643
+ d.readInstalledCoreVersion = () => null;
644
+ const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos', port: 3002 }), d, intent());
645
+ assert.equal(r.ok, false);
646
+ assert.match(r.error, /could not read the core version installed/);
647
+ assert.equal(d.ranWith.filter((c) => /upgrade\.js/.test(c.cmd)).length, 0, 'the box was not touched');
648
+ });
649
+
474
650
  console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
475
651
  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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 < 12; i++) await new Promise((r) => setTimeout(r, 0)); };
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
- assert.equal(b.posts.length, 1);
130
- assert.equal(b.posts[0].path, '/provisioning/instances/32/core-upgrade');
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(b.posts[0].body.to, '1.19.871');
135
- assert.deepEqual(Object.keys(b.posts[0].body), ['to'], 'and nothing else is sent');
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 &quot;cloudbongos&quot; 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
+ });