@bongos/core 1.19.679 → 1.19.681

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/upgrade.mjs CHANGED
@@ -1091,6 +1091,265 @@ t('runUpgrade: refuses BEFORE the pin moves when the instance names no database'
1091
1091
  assert.match(readFileSync(join(dir, 'package.json'), 'utf8'), /"@bongos\/core": "1\.0\.0"/);
1092
1092
  });
1093
1093
 
1094
+ // ---- task 1002884: an upgrade may not declare success it has not proven -----
1095
+ // The 2026-08-11 cloudbongos.com incident, as executable tests. The sweep's `systemctl restart`
1096
+ // failed with "Interactive authentication required", upgrade.js logged it as a warning and
1097
+ // carried on, the health poll hit the STILL-RUNNING old process and got 200, and the run
1098
+ // reported "✓ upgrade complete — core 1.19.13 → 1.19.56" while live served 1.19.13.
1099
+ // Both halves are covered: a failed restart must be fatal, and a served version that did not
1100
+ // change must be caught even when the restart reported success.
1101
+
1102
+ // A run shim whose `systemctl restart` fails, optionally letting `sudo -n systemctl restart` win.
1103
+ function restartFailingRun(calls, { dir, sudoWorks = false } = {}) {
1104
+ const base = pinAwareRun(calls, { dir });
1105
+ return (cmd, args = []) => {
1106
+ if (cmd === 'systemctl' && args[0] === 'restart') { calls.push(`systemctl ${args.join(' ')}`); return { status: 1 }; }
1107
+ if (cmd === 'sudo') { calls.push(`sudo ${args.join(' ')}`); return { status: sudoWorks ? 0 : 1 }; }
1108
+ return base(cmd, args);
1109
+ };
1110
+ }
1111
+
1112
+ // A /version endpoint that reports whatever the live process is "serving".
1113
+ function versionFetch(serving, { healthOk = true } = {}) {
1114
+ return async (url) => {
1115
+ if (String(url).endsWith('/version')) return { ok: true, status: 200, json: async () => ({ coreVersion: serving }) };
1116
+ return { ok: healthOk, status: healthOk ? 200 : 503 };
1117
+ };
1118
+ }
1119
+
1120
+ t('restartService: a non-root systemctl failure retries through `sudo -n` and can succeed there', () => {
1121
+ const calls = [];
1122
+ const run = (cmd, args = []) => { calls.push(`${cmd} ${args.join(' ')}`); return { status: cmd === 'sudo' ? 0 : 1 }; };
1123
+ const r = u.restartService({ service: 'demo.service' }, run, { isRoot: false });
1124
+ assert.equal(r.ok, true, 'the sudo -n retry restarted the service');
1125
+ assert.equal(r.triedSudo, true);
1126
+ assert.equal(r.directCode, 1, 'the direct attempt is reported alongside');
1127
+ assert.deepEqual(calls, ['systemctl restart demo.service', 'sudo -n systemctl restart demo.service']);
1128
+ });
1129
+
1130
+ t('restartService: as root there is no sudo retry (nothing to escalate to)', () => {
1131
+ const calls = [];
1132
+ const run = (cmd, args = []) => { calls.push(`${cmd} ${args.join(' ')}`); return { status: 1 }; };
1133
+ const r = u.restartService({ service: 'demo.service' }, run, { isRoot: true });
1134
+ assert.equal(r.ok, false);
1135
+ assert.ok(!r.triedSudo, 'root does not shell out to sudo');
1136
+ assert.deepEqual(calls, ['systemctl restart demo.service']);
1137
+ });
1138
+
1139
+ t('restartService: an explicit --restart-cmd is never escalated (the operator owns that contract)', () => {
1140
+ const calls = [];
1141
+ const run = (cmd, args = []) => { calls.push(`${cmd} ${args.join(' ')}`); return { status: 1 }; };
1142
+ const r = u.restartService({ restartCmd: 'deploy restart' }, run, { isRoot: false });
1143
+ assert.equal(r.ok, false);
1144
+ assert.deepEqual(calls, ['deploy restart'], 'no sudo retry for an explicit command');
1145
+ });
1146
+
1147
+ t('the 2026-08-11 incident: a failed restart FAILS the bump even though /healthz answers 200', async () => {
1148
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1149
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1150
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1151
+ const calls = [], queries = [];
1152
+ const res = await u.runUpgrade(
1153
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1154
+ {
1155
+ log: () => {}, err: () => {},
1156
+ run: restartFailingRun(calls, { dir, sudoWorks: false }),
1157
+ isRoot: false,
1158
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1159
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
1160
+ // the old process is still up and healthy — the exact false-pass this task exists to kill
1161
+ fetch: async () => ({ ok: true, status: 200 }),
1162
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1163
+ }
1164
+ );
1165
+ assert.equal(res.ok, false, 'a failed restart is not a successful upgrade');
1166
+ assert.equal(res.restartOk, false);
1167
+ assert.match(res.error, /restart failed/i);
1168
+ assert.match(res.error, /still running the previous core/i);
1169
+ assert.equal(res.rolledBack, true, 'the bump reverts rather than leaving disk ahead of the process');
1170
+ assert.equal(u.readPinnedCoreVersion(dir), '1.14.1', 'pin reverted');
1171
+ assert.equal(queries.length, 1, 'no phantom "upgraded" ledger row');
1172
+ assert.match(queries[0].params[3], /^rolled_back: restart failed/);
1173
+ assert.ok(calls.includes('sudo -n systemctl restart demo.service'), 'it tried to escalate before giving up');
1174
+ });
1175
+
1176
+ t('a restart that fails directly but succeeds under sudo -n carries the bump through', async () => {
1177
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1178
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1179
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1180
+ const calls = [], queries = [];
1181
+ const res = await u.runUpgrade(
1182
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1183
+ {
1184
+ log: () => {}, err: () => {},
1185
+ run: restartFailingRun(calls, { dir, sudoWorks: true }),
1186
+ isRoot: false,
1187
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1188
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
1189
+ fetch: versionFetch('1.15.0'),
1190
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1191
+ }
1192
+ );
1193
+ assert.equal(res.ok, true, 'escalating is a success, not a warning');
1194
+ assert.equal(res.servedOk, true);
1195
+ assert.equal(res.servedVersion, '1.15.0');
1196
+ assert.equal(queries.length, 1);
1197
+ assert.ok(!/rolled_back/.test(queries[0].params[3] || ''), 'a real upgraded row, not a reversal');
1198
+ });
1199
+
1200
+ t('served-version mismatch: restart reports success but the OLD core is still serving → fail + rollback', async () => {
1201
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1202
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1203
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1204
+ const calls = [], queries = [];
1205
+ const res = await u.runUpgrade(
1206
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1207
+ {
1208
+ log: () => {}, err: () => {},
1209
+ run: pinAwareRun(calls, { dir }), // systemctl exits 0 — nothing else can catch this
1210
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1211
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
1212
+ fetch: versionFetch('1.14.1'), // the process never swapped
1213
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1214
+ }
1215
+ );
1216
+ assert.equal(res.ok, false, 'disk-and-health agreement is not proof the new core is serving');
1217
+ assert.equal(res.servedOk, false);
1218
+ assert.equal(res.servedVersion, '1.14.1');
1219
+ assert.equal(res.rolledBack, true);
1220
+ assert.equal(queries.length, 1, 'no phantom "upgraded" row');
1221
+ assert.match(queries[0].params[3], /^rolled_back: served core version did not change/);
1222
+ });
1223
+
1224
+ t('served-version match: the bump is confirmed against the running process', async () => {
1225
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1226
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1227
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1228
+ const urls = [];
1229
+ const res = await u.runUpgrade(
1230
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1231
+ {
1232
+ log: () => {}, err: () => {},
1233
+ run: pinAwareRun([], { dir }),
1234
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1235
+ fetch: async (url) => { urls.push(String(url)); return versionFetch('1.15.0')(url); },
1236
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1237
+ }
1238
+ );
1239
+ assert.equal(res.ok, true);
1240
+ assert.equal(res.servedOk, true);
1241
+ assert.ok(urls.includes('http://x/version'), 'the version URL is derived from the health URL origin');
1242
+ });
1243
+
1244
+ t('an unreadable DERIVED version endpoint warns but does not fail the bump (no regression)', async () => {
1245
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1246
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1247
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1248
+ const warnings = [];
1249
+ const res = await u.runUpgrade(
1250
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1251
+ {
1252
+ log: () => {}, err: (m) => warnings.push(String(m)),
1253
+ run: pinAwareRun([], { dir }),
1254
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1255
+ fetch: async (url) => (String(url).endsWith('/version') ? { ok: false, status: 404 } : { ok: true, status: 200 }),
1256
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1257
+ }
1258
+ );
1259
+ assert.equal(res.ok, true, 'an instance that does not serve /version can still be upgraded');
1260
+ assert.equal(res.servedOk, null, 'unconfirmed is neither pass nor fail');
1261
+ assert.ok(warnings.some((w) => /NOT confirmed against the running process/.test(w)), 'but it says so loudly');
1262
+ });
1263
+
1264
+ t('an unreadable EXPLICIT --version-url fails the bump (proof was asked for)', async () => {
1265
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1266
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1267
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1268
+ const res = await u.runUpgrade(
1269
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz', versionUrl: 'http://x/v', rollbackOnFailure: false },
1270
+ {
1271
+ log: () => {}, err: () => {},
1272
+ run: pinAwareRun([], { dir }),
1273
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1274
+ fetch: async (url) => (String(url) === 'http://x/v' ? { ok: false, status: 500 } : { ok: true, status: 200 }),
1275
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1276
+ }
1277
+ );
1278
+ assert.equal(res.ok, false);
1279
+ assert.equal(res.servedOk, false);
1280
+ });
1281
+
1282
+ t('--no-health-check also opts out of the served-version read-back (one documented escape hatch)', async () => {
1283
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1284
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1285
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1286
+ let fetched = false;
1287
+ const res = await u.runUpgrade(
1288
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthCheck: false },
1289
+ {
1290
+ log: () => {}, err: () => {},
1291
+ run: pinAwareRun([], { dir }),
1292
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1293
+ fetch: async () => { fetched = true; return { ok: true, status: 200 }; },
1294
+ }
1295
+ );
1296
+ assert.equal(res.ok, true);
1297
+ assert.equal(fetched, false, 'nothing is polled when the confirmation is explicitly waived');
1298
+ });
1299
+
1300
+ t('the ROLLBACK restart escalates too — the restart after a failure is the one that matters', async () => {
1301
+ const src = mkdtempSync(join(tmpdir(), 'src-'));
1302
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1303
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1304
+ const calls = [], queries = [];
1305
+ // The forward restart works; the health check then fails, and the rollback's restart is the
1306
+ // one that needs sudo. Without deps reaching rollback's restartService there is no seam to
1307
+ // assert this on, and the escalation that runs after a failure would go untested.
1308
+ let restarts = 0;
1309
+ const base = pinAwareRun(calls, { dir });
1310
+ const run = (cmd, args = []) => {
1311
+ if (cmd === 'systemctl' && args[0] === 'restart') {
1312
+ restarts++;
1313
+ calls.push(`systemctl ${args.join(' ')}`);
1314
+ return { status: restarts === 1 ? 0 : 1 }; // forward ok, rollback needs escalation
1315
+ }
1316
+ if (cmd === 'sudo') { calls.push(`sudo ${args.join(' ')}`); return { status: 0 }; }
1317
+ return base(cmd, args);
1318
+ };
1319
+ const res = await u.runUpgrade(
1320
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'demo.service', healthUrl: 'http://x/healthz' },
1321
+ {
1322
+ log: () => {}, err: () => {},
1323
+ run, isRoot: false,
1324
+ materialize: () => ({ skills: 1 }), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1325
+ databaseUrl: 'postgres://x/y', pg: recordingPg(queries),
1326
+ fetch: async () => ({ ok: false, status: 503 }), // health fails forward AND after rollback
1327
+ sleep: async () => {}, now: () => 0, healthBudgetMs: 0,
1328
+ }
1329
+ );
1330
+ assert.equal(res.ok, false);
1331
+ assert.equal(res.rolledBack, true);
1332
+ assert.equal(res.rollback.restartOk, true, 'the rollback restart succeeded via sudo -n');
1333
+ assert.ok(calls.includes('sudo -n systemctl restart demo.service'), 'the rollback path escalated');
1334
+ assert.equal(u.readInstalledCoreVersion(dir), '1.14.1', 'the previous core is back on disk');
1335
+ });
1336
+
1337
+ t('deriveVersionUrl: /version on the health URL origin, query + fragment dropped', () => {
1338
+ assert.equal(u.deriveVersionUrl('http://127.0.0.1:3002/healthz'), 'http://127.0.0.1:3002/version');
1339
+ assert.equal(u.deriveVersionUrl('https://demo.example.com/healthz?probe=1#x'), 'https://demo.example.com/version');
1340
+ assert.equal(u.deriveVersionUrl('not a url'), null);
1341
+ });
1342
+
1343
+ t('pollServedVersion: gives up rather than spinning when the clock never advances', async () => {
1344
+ const r = await u.pollServedVersion('http://x/version', {
1345
+ fetch: async () => ({ ok: true, status: 200, json: async () => ({}) }), // never carries a coreVersion
1346
+ sleep: async () => {}, now: () => 0, versionBudgetMs: 1_000, versionMaxAttempts: 3,
1347
+ });
1348
+ assert.equal(r.ok, false);
1349
+ assert.equal(r.attempts, 3, 'the attempt cap bounds a frozen clock');
1350
+ assert.match(r.error, /no coreVersion/);
1351
+ });
1352
+
1094
1353
  await Promise.all(pending);
1095
1354
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
1096
1355
  process.exit(failed ? 1 : 0);