@openparachute/hub 0.6.4 → 0.6.5-rc.2

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.
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
7
7
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
8
+ import { createDbHolder } from "../hub-db-liveness.ts";
8
9
  import { hubDbPath, openHubDb } from "../hub-db.ts";
9
10
  import {
10
11
  findServiceUpstream,
@@ -1240,14 +1241,10 @@ describe("hubFetch routing", () => {
1240
1241
  // open shouldn't cascade into a restart loop. The body advertises the
1241
1242
  // running version so a deploy verifier can confirm the rolled-out
1242
1243
  // image is the one it expected.
1243
- test("/health returns 200 JSON without invoking the db", async () => {
1244
+ test("/health returns 200 JSON; unconfigured db field when no getDb", async () => {
1244
1245
  const h = makeHarness();
1245
1246
  try {
1246
- const res = await hubFetch(h.dir, {
1247
- getDb: () => {
1248
- throw new Error("getDb must not be called by /health");
1249
- },
1250
- })(req("/health"));
1247
+ const res = await hubFetch(h.dir)(req("/health"));
1251
1248
  expect(res.status).toBe(200);
1252
1249
  expect(res.headers.get("content-type")).toContain("application/json");
1253
1250
  expect(res.headers.get("cache-control")).toBe("no-store");
@@ -1255,11 +1252,153 @@ describe("hubFetch routing", () => {
1255
1252
  expect(body.status).toBe("ok");
1256
1253
  expect(body.service).toBe("parachute-hub");
1257
1254
  expect(typeof body.version).toBe("string");
1255
+ expect(body.db).toBe("unconfigured");
1258
1256
  } finally {
1259
1257
  h.cleanup();
1260
1258
  }
1261
1259
  });
1262
1260
 
1261
+ test("/health db field is ok on a live db (#594)", async () => {
1262
+ const h = makeHarness();
1263
+ const db = openHubDb(hubDbPath(h.dir));
1264
+ try {
1265
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/health"));
1266
+ expect(res.status).toBe(200);
1267
+ const body = (await res.json()) as Record<string, unknown>;
1268
+ expect(body.status).toBe("ok");
1269
+ expect(body.db).toBe("ok");
1270
+ } finally {
1271
+ db.close();
1272
+ h.cleanup();
1273
+ }
1274
+ });
1275
+
1276
+ test("/health db field reports error: <class> on a dead handle, still 200 (#594)", async () => {
1277
+ const h = makeHarness();
1278
+ const db = openHubDb(hubDbPath(h.dir));
1279
+ db.close(); // dead handle — every SELECT throws
1280
+ try {
1281
+ const res = await hubFetch(h.dir, { getDb: () => db })(req("/health"));
1282
+ // Always 200 while the process is up — the HTTP status is process
1283
+ // liveness, the db field is the readiness signal.
1284
+ expect(res.status).toBe(200);
1285
+ const body = (await res.json()) as Record<string, unknown>;
1286
+ expect(body.status).toBe("ok");
1287
+ expect(typeof body.db).toBe("string");
1288
+ expect((body.db as string).startsWith("error:")).toBe(true);
1289
+ } finally {
1290
+ h.cleanup();
1291
+ }
1292
+ });
1293
+
1294
+ test("a fatal DB throw escaping a handler returns a structured body + invokes onDbError (#594)", async () => {
1295
+ const h = makeHarness();
1296
+ try {
1297
+ const fatal = Object.assign(new Error("disk I/O error"), {
1298
+ name: "SQLiteError",
1299
+ code: "SQLITE_IOERR",
1300
+ });
1301
+ let onDbErrorCalls = 0;
1302
+ // A non-/health, DB-touching route (`/`) whose getDb throws the fatal
1303
+ // class. The top-level self-heal wrapper catches it, calls onDbError,
1304
+ // and returns a structured db_unavailable body (not a bare 500).
1305
+ const res = await hubFetch(h.dir, {
1306
+ manifestPath: h.manifestPath,
1307
+ getDb: () => {
1308
+ throw fatal;
1309
+ },
1310
+ onDbError: () => {
1311
+ onDbErrorCalls += 1;
1312
+ return "healed";
1313
+ },
1314
+ })(req("/", { headers: { accept: "text/html" } }));
1315
+ expect(onDbErrorCalls).toBe(1);
1316
+ expect(res.status).toBe(503);
1317
+ const body = (await res.json()) as Record<string, unknown>;
1318
+ expect(body.error).toBe("db_unavailable");
1319
+ expect(typeof body.error_description).toBe("string");
1320
+ expect(body.error_description as string).toContain("reopened");
1321
+ } finally {
1322
+ h.cleanup();
1323
+ }
1324
+ });
1325
+
1326
+ test("a non-DB throw still propagates (not swallowed by the self-heal wrapper) (#594)", async () => {
1327
+ const h = makeHarness();
1328
+ try {
1329
+ let onDbErrorCalls = 0;
1330
+ const handler = hubFetch(h.dir, {
1331
+ manifestPath: h.manifestPath,
1332
+ getDb: () => {
1333
+ throw new Error("some unrelated programming error");
1334
+ },
1335
+ onDbError: () => {
1336
+ onDbErrorCalls += 1;
1337
+ return "ignored";
1338
+ },
1339
+ });
1340
+ await expect(handler(req("/", { headers: { accept: "text/html" } }))).rejects.toThrow(
1341
+ "some unrelated programming error",
1342
+ );
1343
+ expect(onDbErrorCalls).toBe(0);
1344
+ } finally {
1345
+ h.cleanup();
1346
+ }
1347
+ });
1348
+
1349
+ test("a transient SQLITE_BUSY escaping a handler → 503, does NOT exit the hub (#594)", async () => {
1350
+ const h = makeHarness();
1351
+ const db = openHubDb(hubDbPath(h.dir));
1352
+ try {
1353
+ // Wire a REAL DbHolder so this pins the end-to-end transient path: the
1354
+ // wrapper catches the throw, routes it to the holder's healOrExit, and
1355
+ // the holder must classify SQLITE_BUSY as transient → "ignored" → NO
1356
+ // reopen, NO exit. A spy `exit` that fails the test if ever called is the
1357
+ // regression guard ("a momentary lock never kills the hub").
1358
+ let exited = false;
1359
+ let reopened = false;
1360
+ const holder = createDbHolder(db, {
1361
+ reopen: () => {
1362
+ reopened = true;
1363
+ return db;
1364
+ },
1365
+ exit: () => {
1366
+ exited = true;
1367
+ },
1368
+ log: () => {},
1369
+ });
1370
+ const busy = Object.assign(new Error("database is locked"), {
1371
+ name: "SQLiteError",
1372
+ code: "SQLITE_BUSY",
1373
+ });
1374
+ // First getDb() (the wizard-redirect userCount read) throws BUSY; the
1375
+ // wrapper's catch routes it through the holder.
1376
+ let firstCall = true;
1377
+ const res = await hubFetch(h.dir, {
1378
+ manifestPath: h.manifestPath,
1379
+ getDb: () => {
1380
+ if (firstCall) {
1381
+ firstCall = false;
1382
+ throw busy;
1383
+ }
1384
+ return holder.get();
1385
+ },
1386
+ onDbError: (err) => holder.healOrExit(err),
1387
+ })(req("/", { headers: { accept: "text/html" } }));
1388
+ expect(res.status).toBe(503);
1389
+ const body = (await res.json()) as Record<string, unknown>;
1390
+ expect(body.error).toBe("db_unavailable");
1391
+ // Transient class is named in the structured body, not "reopened".
1392
+ expect(body.error_description as string).toContain("transient");
1393
+ // The crux: the hub did NOT exit and did NOT reopen on a transient lock.
1394
+ expect(exited).toBe(false);
1395
+ expect(reopened).toBe(false);
1396
+ } finally {
1397
+ db.close();
1398
+ h.cleanup();
1399
+ }
1400
+ });
1401
+
1263
1402
  // First-boot setup wizard (hub#259, expanding hub#258's static
1264
1403
  // placeholder). When no admin exists, GET /admin/setup renders the
1265
1404
  // wizard's account-step form. Once admin + vault both exist, it 301s
@@ -44,7 +44,7 @@ function fakeDeps(
44
44
  * `null` (hub not answering) or `{ ok, version }`. Drives
45
45
  * `probeHealthVersion` across the version-check + post-restart re-probe.
46
46
  */
47
- healthVersionSeq?: ({ ok: boolean; version?: string } | null)[];
47
+ healthVersionSeq?: ({ ok: boolean; version?: string; db?: string } | null)[];
48
48
  listeningSeq?: boolean[];
49
49
  installedUnit?: boolean;
50
50
  } = {},
@@ -756,4 +756,113 @@ describe("ensureHubVersionMatches — version-check-and-restart at adoption (#59
756
756
  expect(res.outcome).toBe("restart-failed");
757
757
  expect(res.messages.join("\n")).toContain("Unit parachute-hub.service not found.");
758
758
  });
759
+
760
+ // #594: a hub whose VERSION matches but whose /health reports a db fault
761
+ // (dead handle — state dir deleted under it) must be treated as needing a
762
+ // restart, through the same restart-once machinery.
763
+ test("version matches but /health reports db fault → restart-once → restarted when db heals", async () => {
764
+ const f = fakeDeps({
765
+ platform: "darwin",
766
+ getuid: () => 501,
767
+ installedUnit: true,
768
+ // first probe: right version but dead DB handle; after the restart the
769
+ // re-probe sees a live DB.
770
+ healthVersionSeq: [
771
+ { ok: true, version: INSTALLED, db: "error: fatal" },
772
+ { ok: true, version: INSTALLED, db: "ok" },
773
+ ],
774
+ });
775
+ const res = await ensureHubVersionMatches({
776
+ installedVersion: INSTALLED,
777
+ port: 1939,
778
+ deps: f.deps,
779
+ readyPollMs: 0,
780
+ });
781
+ expect(res.outcome).toBe("restarted");
782
+ const restarts = f.calls.filter((c) => c.includes("kickstart"));
783
+ expect(restarts).toHaveLength(1);
784
+ });
785
+
786
+ // #594: a SUSTAINED transient fault visible in /health (e.g. a write lock
787
+ // that never clears) is still an "error:" verdict, so the adoption probe
788
+ // treats it as needing a restart — same as the fatal case. Pins that
789
+ // `healthReportsDbFault` keys on the "error:" prefix, not the fatal class.
790
+ test("version matches but /health reports db error: transient → restart-once (#594)", async () => {
791
+ const f = fakeDeps({
792
+ platform: "darwin",
793
+ getuid: () => 501,
794
+ installedUnit: true,
795
+ // first probe: right version, sustained transient DB fault; after the
796
+ // restart the re-probe sees a live DB.
797
+ healthVersionSeq: [
798
+ { ok: true, version: INSTALLED, db: "error: transient" },
799
+ { ok: true, version: INSTALLED, db: "ok" },
800
+ ],
801
+ });
802
+ const res = await ensureHubVersionMatches({
803
+ installedVersion: INSTALLED,
804
+ port: 1939,
805
+ deps: f.deps,
806
+ readyPollMs: 0,
807
+ });
808
+ expect(res.outcome).toBe("restarted");
809
+ const restarts = f.calls.filter((c) => c.includes("kickstart"));
810
+ expect(restarts).toHaveLength(1);
811
+ });
812
+
813
+ test("version + db both ok → match, NO restart (#594 doesn't fire on a healthy hub)", async () => {
814
+ const f = fakeDeps({
815
+ platform: "darwin",
816
+ getuid: () => 501,
817
+ installedUnit: true,
818
+ healthVersionSeq: [{ ok: true, version: INSTALLED, db: "ok" }],
819
+ });
820
+ const res = await ensureHubVersionMatches({
821
+ installedVersion: INSTALLED,
822
+ port: 1939,
823
+ deps: f.deps,
824
+ readyPollMs: 0,
825
+ });
826
+ expect(res.outcome).toBe("match");
827
+ expect(f.calls).toEqual([]);
828
+ });
829
+
830
+ test("db fault persists after the restart → still-mismatched with a db-specific message (#594)", async () => {
831
+ const f = fakeDeps({
832
+ platform: "darwin",
833
+ getuid: () => 501,
834
+ installedUnit: true,
835
+ // Every probe reports the dead handle (state dir still gone). Restart
836
+ // once, then settle — no loop.
837
+ healthVersionSeq: [{ ok: true, version: INSTALLED, db: "error: fatal" }],
838
+ });
839
+ const res = await ensureHubVersionMatches({
840
+ installedVersion: INSTALLED,
841
+ port: 1939,
842
+ deps: f.deps,
843
+ readyTimeoutMs: 0,
844
+ readyPollMs: 0,
845
+ });
846
+ expect(res.outcome).toBe("still-mismatched");
847
+ const restarts = f.calls.filter((c) => c.includes("kickstart"));
848
+ expect(restarts).toHaveLength(1);
849
+ expect(res.messages.join("\n")).toContain("database still reports a fault");
850
+ });
851
+
852
+ test("a hub with NO db field (pre-#594) on a version match → match, not treated as a fault", async () => {
853
+ const f = fakeDeps({
854
+ platform: "darwin",
855
+ getuid: () => 501,
856
+ installedUnit: true,
857
+ healthVersionSeq: [{ ok: true, version: INSTALLED /* no db field */ }],
858
+ });
859
+ const res = await ensureHubVersionMatches({
860
+ installedVersion: INSTALLED,
861
+ port: 1939,
862
+ deps: f.deps,
863
+ readyPollMs: 0,
864
+ });
865
+ expect(res.outcome).toBe("match");
866
+ expect(f.calls).toEqual([]);
867
+ });
759
868
  });