@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.
- package/package.json +1 -1
- package/src/__tests__/cloudflare-tunnel.test.ts +78 -0
- package/src/__tests__/expose-cloudflare.test.ts +253 -0
- package/src/__tests__/hub-db-liveness.test.ts +139 -0
- package/src/__tests__/hub-server.test.ts +145 -6
- package/src/__tests__/hub-unit.test.ts +110 -1
- package/src/__tests__/oauth-handlers.test.ts +457 -0
- package/src/__tests__/oauth-ui.test.ts +27 -0
- package/src/cloudflare/tunnel.ts +70 -0
- package/src/commands/expose-cloudflare.ts +157 -2
- package/src/commands/serve.ts +14 -4
- package/src/hub-db-liveness.ts +211 -0
- package/src/hub-server.ts +1175 -1104
- package/src/hub-unit.ts +74 -27
- package/src/oauth-handlers.ts +69 -25
- package/src/oauth-ui.ts +28 -2
|
@@ -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
|
|
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
|
});
|