@bongos/core 1.19.674 → 1.19.676

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/tests/go_live.mjs CHANGED
@@ -144,9 +144,11 @@ test('a typo’d field is reported, never silently ignored', () => {
144
144
 
145
145
  test('missing required fields are named individually', () => {
146
146
  const errs = G.validateTarget('t', { ssh: 'box' });
147
- for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer']) {
147
+ for (const f of ['instanceDir', 'service', 'healthUrl', 'versionUrl']) {
148
148
  assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
149
149
  }
150
+ // deployTimer is co-tenant-optional (task 1003521) and must NOT be demanded.
151
+ assert.ok(!/missing required field "deployTimer"/.test(errs.join(' ')));
150
152
  });
151
153
 
152
154
  test('a valid target passes clean', () => {
@@ -393,7 +395,7 @@ test('ssh is OPTIONAL — a local target validates clean', () => {
393
395
 
394
396
  test('the remaining required fields are still enforced for a local target', () => {
395
397
  const errs = G.validateTarget('local', { instanceDir: '/i' });
396
- for (const f of ['service', 'healthUrl', 'versionUrl', 'deployTimer']) {
398
+ for (const f of ['service', 'healthUrl', 'versionUrl']) {
397
399
  assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
398
400
  }
399
401
  // ssh must NOT be demanded any more.
@@ -411,3 +413,173 @@ test('gitRefExists reports rather than throwing', () => {
411
413
  assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
412
414
  assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
413
415
  });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Co-tenant mode (task 1003521)
419
+ //
420
+ // go-live drove the PLATFORM instance and could not drive a provisioned co-tenant,
421
+ // for two structural reasons, so mercury had to be upgraded by hand from a box-side
422
+ // script — the exact one-command path this file exists to provide:
423
+ //
424
+ // 1. deployTimer was REQUIRED. A co-tenant has no pull-deploy timer at all (only
425
+ // its <slug>-db-backup.timer), so the config could not even be AUTHORED without
426
+ // inventing a unit name that the script would then try to `systemctl stop`.
427
+ // 2. The pin was always PUSHED. On a co-tenant the repo of record is the customer's
428
+ // own GitHub repo, so that push is both wrong and, in practice, uncredentialed.
429
+ //
430
+ // The assertions below are on the scripts applyGoLive COMPOSES and the verdicts it
431
+ // reaches — `run` is injected, so nothing here touches a box.
432
+ // ---------------------------------------------------------------------------
433
+
434
+ const coTenant = {
435
+ ssh: 'my-box',
436
+ instanceDir: '/srv/my-host/their-slug',
437
+ service: 'their-slug.service',
438
+ healthUrl: 'http://127.0.0.1:3101/healthz',
439
+ versionUrl: 'http://127.0.0.1:3101/version',
440
+ pinMode: 'local-commit',
441
+ };
442
+
443
+ // Canned phase-C output. The sections are what applyGoLive parses its verdicts from.
444
+ const phaseC = ({ timer = true, pin = '', version = '1.19.306', installed = '1.19.306' } = {}) => [
445
+ timer ? '##TIMERSTART##\nactive' : '',
446
+ `##PIN##\n${pin}`,
447
+ '##HEAD##\ndeadbee pin @bongos/core 1.19.306',
448
+ `##VERSION##\n{"coreVersion":"${version}"}`,
449
+ `##INSTALLED##\n${installed}`,
450
+ '##END##',
451
+ ].filter(Boolean).join('\n');
452
+
453
+ // Records every script fed to a shell. `upgradeFails` drives the rollback path.
454
+ function fakeShell({ upgradeFails = false, phaseCOut = phaseC() } = {}) {
455
+ const scripts = [];
456
+ const fn = (cmd, args, opts = {}) => {
457
+ const input = opts.input || '';
458
+ scripts.push(input);
459
+ if (input.includes('bongos upgrade')) return { status: upgradeFails ? 1 : 0, stdout: '', stderr: upgradeFails ? 'boom' : '' };
460
+ if (input.includes('##PIN##')) return { status: 0, stdout: phaseCOut, stderr: '' };
461
+ return { status: 0, stdout: '##TIMER##\n##BACKUP##\n##END##', stderr: '' };
462
+ };
463
+ fn.scripts = scripts;
464
+ fn.all = () => scripts.join('\n');
465
+ return fn;
466
+ }
467
+
468
+ const stepNames = (r) => (r.steps || []).map((s) => s.name);
469
+
470
+ test('pinMode defaults to push, so an existing config keeps its behaviour', () => {
471
+ assert.equal(G.pinModeOf(okTarget), 'push');
472
+ assert.equal(G.pinModeOf({}), 'push');
473
+ assert.equal(G.pinModeOf(undefined), 'push');
474
+ assert.equal(G.pinModeOf({ pinMode: 'leave-dirty' }), 'leave-dirty');
475
+ });
476
+
477
+ test('a bogus pinMode is REFUSED, and each documented one validates', () => {
478
+ assert.match(G.validateTarget('t', { ...okTarget, pinMode: 'shove-it' }).join(' '), /pinMode must be one of/);
479
+ for (const m of G.PIN_MODES) assert.deepEqual(G.validateTarget('t', { ...okTarget, pinMode: m }), []);
480
+ });
481
+
482
+ test('push mode pushes the pin; the two co-tenant modes never do', () => {
483
+ const cmd = (t) => G.upgradeCommand(t, '1.19.306');
484
+
485
+ // The platform instance: --commit-pin ALONE, which pushes (task 1002712 — thirteen
486
+ // releases were lost to an unpushed pin, so this is the regression that matters).
487
+ const push = cmd({ ...okTarget, pinMode: 'push' });
488
+ assert.ok(push.includes('--commit-pin'), 'push mode must still commit the pin');
489
+ assert.ok(!push.includes('--pin-no-push'), 'push mode must NOT suppress the push');
490
+
491
+ // local-commit: commit it, leave the customer's remote alone.
492
+ const local = cmd({ ...okTarget, pinMode: 'local-commit' });
493
+ assert.ok(local.includes('--commit-pin') && local.includes('--pin-no-push'));
494
+
495
+ // leave-dirty: no commit at all, and --pin-no-push so upgrade.js does not raise the
496
+ // pull-deploy alarm at an instance that has no pull-deploy.
497
+ const dirty = cmd({ ...okTarget, pinMode: 'leave-dirty' });
498
+ assert.ok(!dirty.includes('--commit-pin'), 'leave-dirty must not commit');
499
+ assert.ok(dirty.includes('--pin-no-push'));
500
+
501
+ // Unchanged for every mode: auto-rollback stays ON.
502
+ for (const c of [push, local, dirty]) assert.ok(!c.includes('--no-rollback-on-failure'));
503
+ });
504
+
505
+ test('a co-tenant target validates with NO deployTimer — the field that blocked the config', () => {
506
+ assert.deepEqual(G.validateTarget('a-co-tenant', coTenant), []);
507
+ });
508
+
509
+ test('with no deployTimer, nothing is stopped, started, or reported as restarted', () => {
510
+ const run = fakeShell({ phaseCOut: phaseC({ timer: false }) });
511
+ const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
512
+
513
+ assert.equal(r.ok, true, `expected a clean run, got ${JSON.stringify(stepNames(r))}`);
514
+ // The bug shape: an invented unit name reaching systemctl, or a literal `undefined`.
515
+ assert.ok(!/systemctl (stop|start)/.test(run.all()), 'must not touch a timer it has not got');
516
+ assert.ok(!run.all().includes('undefined'), 'no field may interpolate as undefined');
517
+ assert.ok(!stepNames(r).some((n) => /restart/.test(n)), 'no restart step for a timer that does not exist');
518
+ assert.match(stepNames(r)[0], /^backup \(no pull-deploy timer\)$/);
519
+ });
520
+
521
+ test('the platform instance still stops the timer and puts it back', () => {
522
+ const run = fakeShell();
523
+ const r = G.applyGoLive({ ...okTarget, deployTimer: 'inst-deploy.timer' }, 'web', '1.19.306', '1.19.300', { run });
524
+
525
+ assert.equal(r.ok, true);
526
+ assert.match(run.all(), /systemctl stop inst-deploy\.timer/);
527
+ assert.match(run.all(), /systemctl start inst-deploy\.timer/);
528
+ assert.ok(stepNames(r).includes('restart inst-deploy.timer'));
529
+ });
530
+
531
+ test('a failed upgrade restarts the timer it stopped — and asks for none when it stopped none', () => {
532
+ const withTimer = fakeShell({ upgradeFails: true });
533
+ const a = G.applyGoLive(okTarget, 'web', '1.19.306', '1.19.300', { run: withTimer });
534
+ assert.equal(a.ok, false);
535
+ assert.equal(a.failedAt, 'UPGRADE');
536
+ assert.match(withTimer.all(), /systemctl start inst-deploy\.timer/);
537
+
538
+ const coT = fakeShell({ upgradeFails: true });
539
+ const b = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run: coT });
540
+ assert.equal(b.ok, false);
541
+ assert.ok(!/systemctl start/.test(coT.all()), 'nothing was stopped, so nothing is restarted');
542
+ });
543
+
544
+ test('a dirty pin is the durability failure it is under push and local-commit', () => {
545
+ for (const pinMode of ['push', 'local-commit']) {
546
+ const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
547
+ const r = G.applyGoLive({ ...okTarget, pinMode }, 'web', '1.19.306', '1.19.300', { run });
548
+ assert.equal(r.ok, false, `${pinMode} must fail on an uncommitted pin`);
549
+ assert.equal(r.pinCommitted, false);
550
+ }
551
+ });
552
+
553
+ test('under leave-dirty the SAME dirty pin is the configured outcome, not a failure', () => {
554
+ const run = fakeShell({ phaseCOut: phaseC({ pin: ' M package.json' }) });
555
+ const r = G.applyGoLive({ ...okTarget, pinMode: 'leave-dirty' }, 'web', '1.19.306', '1.19.300', { run });
556
+ assert.equal(r.ok, true, `a correct leave-dirty run must not read as broken: ${JSON.stringify(stepNames(r))}`);
557
+ assert.match(stepNames(r).join(' '), /leave-dirty/);
558
+ });
559
+
560
+ test('the health and version gates are untouched by pin mode', () => {
561
+ // A co-tenant serving the WRONG version must still fail, or co-tenant mode would be
562
+ // a way to get a green report out of a bump that did not take.
563
+ const run = fakeShell({ phaseCOut: phaseC({ timer: false, version: '1.19.300' }) });
564
+ const r = G.applyGoLive(coTenant, 'a-co-tenant', '1.19.306', '1.19.300', { run });
565
+ assert.equal(r.ok, false);
566
+ assert.match(stepNames(r).join(' '), /live reads back/);
567
+ });
568
+
569
+ test('the report does not promise a push it did not make', () => {
570
+ const base = { targetName: 'x', from: '1.19.300', to: '1.19.306', steps: [], liveVersion: '1.19.306', ok: true };
571
+ assert.match(G.renderReport({ ...base, pinMode: 'push', pinCommitted: true }), /pin committed {5}yes$/m);
572
+ assert.match(G.renderReport({ ...base, pinMode: 'local-commit', pinCommitted: true }), /local commit/);
573
+ assert.match(G.renderReport({ ...base, pinMode: 'leave-dirty', pinCommitted: false }), /left in the working tree/);
574
+ // An absent pinMode (a failure return from an older path) still reads as push.
575
+ assert.match(G.renderReport({ ...base, pinCommitted: false }), /next pull-deploy reset will revert/);
576
+ });
577
+
578
+ test('the shipped co-tenant template is itself valid config', () => {
579
+ const r = G.loadConfig({ configPath: 'x.json', exists: () => true, readFile: () => G.CONFIG_TEMPLATE });
580
+ assert.ok(r.ok, `template did not parse: ${r.detail || ''}`);
581
+ const sel = G.selectTarget(r.config, 'a-co-tenant');
582
+ assert.ok(sel.ok, `co-tenant template target invalid: ${sel.detail || ''}`);
583
+ assert.equal(sel.target.pinMode, 'local-commit');
584
+ assert.equal(sel.target.deployTimer, undefined);
585
+ });
@@ -146,6 +146,67 @@ test('the copy-pasteable fix line is shell-quoted', () => {
146
146
  assert.equal((cmd.match(/'/g) || []).length % 2, 0, 'every quote is closed');
147
147
  });
148
148
 
149
+ // ── push: false — co-tenant mode (task 1003521) ─────────────────────────────
150
+ //
151
+ // The reasoning above assumes the repo of record is OURS. On a PROVISIONED instance it
152
+ // is the customer's own GitHub repo, so the push is both wrong and, in practice,
153
+ // uncredentialed — and with no pull-deploy timer nothing will reset the pin away, so
154
+ // the commit alone is durable. go-live.js passes --pin-no-push for such a target.
155
+
156
+ test('push: false commits the pin and stops there', () => {
157
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
158
+ const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
159
+
160
+ assert.equal(r.committed, true);
161
+ assert.equal(r.pushed, false);
162
+ assert.equal(r.pushSkipped, true);
163
+ assert.ok(!r.error, 'a skipped push is not a failure');
164
+ assert.ok(run.calls.some((c) => c === `git add ${PIN_FILES.join(' ')}`), 'still stages the pin files');
165
+ assert.ok(run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core')), 'still commits');
166
+ assert.ok(!run.calls.some((c) => c.startsWith('git push')), 'must NOT touch a remote that is not ours');
167
+ });
168
+
169
+ test('push defaults to true, so every existing caller still pushes', () => {
170
+ // The absent field must not silently become co-tenant mode: that is how thirteen
171
+ // releases were lost the first time.
172
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
173
+ const r = persistPin({ ...base, commitPin: true }, run, quiet, quiet);
174
+ assert.equal(r.pushed, true);
175
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'));
176
+ });
177
+
178
+ test('a co-tenant with no --commit-pin gets a NOTE, not the pull-deploy alarm', () => {
179
+ // The loud warning is correct on a pull-deploy instance and false here. An alarm an
180
+ // operator learns to ignore is worse than no alarm.
181
+ const warnings = [];
182
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
183
+ const r = persistPin({ ...base, commitPin: false, push: false }, run, quiet, (m) => warnings.push(m));
184
+
185
+ assert.equal(r.warned, true);
186
+ assert.equal(r.committed, false);
187
+ const text = warnings.join('\n');
188
+ assert.ok(!/PIN NOT COMMITTED/.test(text), 'no shouting about a revert that cannot happen');
189
+ assert.ok(!/reset --hard/.test(text), 'and no claim that a pull-deploy will revert it');
190
+ assert.match(text, /not ours/, 'says why the push is off');
191
+ assert.match(text, /--commit-pin/, 'still names the fix for the dirty tree');
192
+ });
193
+
194
+ test('--dry-run under push: false does not promise a push', () => {
195
+ const logs = [];
196
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN });
197
+ const r = persistPin({ ...base, commitPin: true, push: false, dryRun: true }, run, (m) => logs.push(m), quiet);
198
+ assert.equal(r.dryRun, true);
199
+ assert.ok(!/push/.test(logs.join('\n').replace(/no push/g, '')), `dry-run must not claim a push: ${logs.join('\n')}`);
200
+ assert.ok(!run.calls.some((c) => c.includes('git commit') || c.includes('git push')));
201
+ });
202
+
203
+ test('a failed commit under push: false still reports the failure', () => {
204
+ const run = fakeRun({ status: DIRTY, 'rev-parse': ON_MAIN, commit: { status: 1, stdout: '' } });
205
+ const r = persistPin({ ...base, commitPin: true, push: false }, run, quiet, quiet);
206
+ assert.equal(r.committed, false);
207
+ assert.ok(!r.pushSkipped, 'nothing was skipped — the commit never happened');
208
+ });
209
+
149
210
  // ── preflightDbIdentity (task 1002712) ──────────────────────────────────────
150
211
  // migrate.sh uses peer auth, so `sudo bongos upgrade` connects as root, which is
151
212
  // almost never a Postgres role. That surfaced as a raw FATAL mid-run, AFTER the