@openparachute/hub 0.6.4-rc.9 → 0.6.5-rc.1
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__/expose-supervisor-version.test.ts +104 -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 +290 -0
- package/src/__tests__/init.test.ts +401 -0
- package/src/__tests__/install.test.ts +90 -0
- package/src/__tests__/migrate-cutover.test.ts +1 -0
- package/src/cloudflare/tunnel.ts +70 -0
- package/src/commands/expose-cloudflare.ts +157 -2
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +63 -1
- package/src/commands/install.ts +42 -1
- 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 +302 -0
package/package.json
CHANGED
|
@@ -3,9 +3,11 @@ import {
|
|
|
3
3
|
CloudflaredError,
|
|
4
4
|
createTunnel,
|
|
5
5
|
credentialsPath,
|
|
6
|
+
deleteTunnel,
|
|
6
7
|
findTunnelByName,
|
|
7
8
|
listTunnels,
|
|
8
9
|
routeDns,
|
|
10
|
+
tunnelConnectionCount,
|
|
9
11
|
} from "../cloudflare/tunnel.ts";
|
|
10
12
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
11
13
|
|
|
@@ -204,4 +206,80 @@ describe("cloudflare tunnel", () => {
|
|
|
204
206
|
test("credentialsPath joins uuid under the cloudflared home", () => {
|
|
205
207
|
expect(credentialsPath("abc", "/Users/x/.cloudflared")).toBe("/Users/x/.cloudflared/abc.json");
|
|
206
208
|
});
|
|
209
|
+
|
|
210
|
+
test("deleteTunnel passes --force and surfaces failures (#593)", async () => {
|
|
211
|
+
const { runner, seen } = makeRunner(
|
|
212
|
+
[["cloudflared", "tunnel", "delete", "--force", "parachute"]],
|
|
213
|
+
[{ code: 0, stdout: "Deleted tunnel parachute\n", stderr: "" }],
|
|
214
|
+
);
|
|
215
|
+
await deleteTunnel(runner, "parachute");
|
|
216
|
+
expect(seen[0]).toEqual(["cloudflared", "tunnel", "delete", "--force", "parachute"]);
|
|
217
|
+
|
|
218
|
+
const fail = makeRunner(
|
|
219
|
+
[["cloudflared", "tunnel", "delete", "--force", "parachute"]],
|
|
220
|
+
[{ code: 1, stdout: "", stderr: "tunnel has active connections" }],
|
|
221
|
+
);
|
|
222
|
+
await expect(deleteTunnel(fail.runner, "parachute")).rejects.toMatchObject({
|
|
223
|
+
message: expect.stringContaining("active connections"),
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("tunnelConnectionCount (#593)", () => {
|
|
228
|
+
function infoRunner(result: CommandResult): Runner {
|
|
229
|
+
return async (cmd) => {
|
|
230
|
+
expect([...cmd]).toEqual([
|
|
231
|
+
"cloudflared",
|
|
232
|
+
"tunnel",
|
|
233
|
+
"info",
|
|
234
|
+
"--output",
|
|
235
|
+
"json",
|
|
236
|
+
"parachute",
|
|
237
|
+
]);
|
|
238
|
+
return result;
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test("counts connector entries under `conns`", async () => {
|
|
243
|
+
const runner = infoRunner({
|
|
244
|
+
code: 0,
|
|
245
|
+
stdout: JSON.stringify({ conns: [{ id: "a" }, { id: "b" }] }),
|
|
246
|
+
stderr: "",
|
|
247
|
+
});
|
|
248
|
+
expect(await tunnelConnectionCount(runner, "parachute")).toBe(2);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("counts connector entries under the legacy `connections` shape", async () => {
|
|
252
|
+
const runner = infoRunner({
|
|
253
|
+
code: 0,
|
|
254
|
+
stdout: JSON.stringify({ connections: [{ id: "a" }] }),
|
|
255
|
+
stderr: "",
|
|
256
|
+
});
|
|
257
|
+
expect(await tunnelConnectionCount(runner, "parachute")).toBe(1);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("returns 0 on empty conns, non-zero exit, unparseable JSON, or a runner throw", async () => {
|
|
261
|
+
expect(
|
|
262
|
+
await tunnelConnectionCount(
|
|
263
|
+
infoRunner({ code: 0, stdout: '{"conns":[]}', stderr: "" }),
|
|
264
|
+
"parachute",
|
|
265
|
+
),
|
|
266
|
+
).toBe(0);
|
|
267
|
+
expect(
|
|
268
|
+
await tunnelConnectionCount(
|
|
269
|
+
infoRunner({ code: 1, stdout: "", stderr: "not found" }),
|
|
270
|
+
"parachute",
|
|
271
|
+
),
|
|
272
|
+
).toBe(0);
|
|
273
|
+
expect(
|
|
274
|
+
await tunnelConnectionCount(
|
|
275
|
+
infoRunner({ code: 0, stdout: "not json", stderr: "" }),
|
|
276
|
+
"parachute",
|
|
277
|
+
),
|
|
278
|
+
).toBe(0);
|
|
279
|
+
const thrower: Runner = async () => {
|
|
280
|
+
throw new Error("spawn failed");
|
|
281
|
+
};
|
|
282
|
+
expect(await tunnelConnectionCount(thrower, "parachute")).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
207
285
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "../cloudflare/state.ts";
|
|
13
13
|
import {
|
|
14
14
|
type CloudflaredSpawner,
|
|
15
|
+
defaultVerifyConnection,
|
|
15
16
|
exposeCloudflareOff,
|
|
16
17
|
exposeCloudflareUp,
|
|
17
18
|
} from "../commands/expose-cloudflare.ts";
|
|
@@ -118,6 +119,16 @@ function fakeSpawner(pid: number): { spawner: CloudflaredSpawner; seen: string[]
|
|
|
118
119
|
return { spawner, seen };
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Write a fake `~/.cloudflared/<uuid>.json` credentials file so the reuse-path
|
|
124
|
+
* credentials check (#593) sees a healthy local tunnel and reuses it instead of
|
|
125
|
+
* triggering the delete+recreate self-heal. Reuse-path tests that want to
|
|
126
|
+
* exercise the plain reuse behavior call this after `makeEnv()`.
|
|
127
|
+
*/
|
|
128
|
+
function seedCreds(env: TestEnv, uuid: string): void {
|
|
129
|
+
writeFileSync(join(env.cloudflaredHome, `${uuid}.json`), "{}");
|
|
130
|
+
}
|
|
131
|
+
|
|
121
132
|
describe("exposeCloudflareUp", () => {
|
|
122
133
|
test("happy path: creates tunnel, routes DNS, writes config + state, spawns cloudflared", async () => {
|
|
123
134
|
const env = makeEnv();
|
|
@@ -350,6 +361,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
350
361
|
const env = makeEnv();
|
|
351
362
|
try {
|
|
352
363
|
const uuid = "bbbbbbbb-0000-0000-0000-000000000002";
|
|
364
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
353
365
|
const { runner, calls } = queueRunner([
|
|
354
366
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
355
367
|
{
|
|
@@ -563,6 +575,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
563
575
|
const env = makeEnv();
|
|
564
576
|
try {
|
|
565
577
|
const uuid = "2c1a7c7e-1234-5678-9abc-def012345678";
|
|
578
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
566
579
|
const { runner } = queueRunner([
|
|
567
580
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
568
581
|
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
@@ -605,6 +618,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
605
618
|
test("stops a prior cloudflared process before spawning a new one", async () => {
|
|
606
619
|
const env = makeEnv();
|
|
607
620
|
try {
|
|
621
|
+
// Healthy local creds for the reused tunnel → plain reuse, no recreate (#593).
|
|
622
|
+
seedCreds(env, "cccccccc-0000-0000-0000-000000000003");
|
|
608
623
|
const priorRecord: CloudflaredTunnelRecord = {
|
|
609
624
|
pid: 99999,
|
|
610
625
|
tunnelUuid: "old-tunnel-uuid",
|
|
@@ -668,6 +683,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
668
683
|
const env = makeEnv();
|
|
669
684
|
try {
|
|
670
685
|
const uuid = "cccccccc-0000-0000-0000-000000000003";
|
|
686
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
671
687
|
const priorRecord: CloudflaredTunnelRecord = {
|
|
672
688
|
pid: 99999,
|
|
673
689
|
tunnelUuid: uuid,
|
|
@@ -727,6 +743,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
727
743
|
const env = makeEnv();
|
|
728
744
|
try {
|
|
729
745
|
const uuid = "dddddddd-0000-0000-0000-000000000004";
|
|
746
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
730
747
|
const { runner } = queueRunner([
|
|
731
748
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
732
749
|
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
@@ -774,6 +791,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
774
791
|
const env = makeEnv();
|
|
775
792
|
try {
|
|
776
793
|
const uuid = "eeeeeeee-0000-0000-0000-000000000006";
|
|
794
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
777
795
|
const { runner } = queueRunner([
|
|
778
796
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
779
797
|
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
@@ -817,6 +835,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
817
835
|
const env = makeEnv();
|
|
818
836
|
try {
|
|
819
837
|
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
838
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
820
839
|
const { runner } = queueRunner([
|
|
821
840
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
822
841
|
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
@@ -1158,6 +1177,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
1158
1177
|
const env = makeEnv();
|
|
1159
1178
|
try {
|
|
1160
1179
|
const uuid = "bbbb8888-1111-2222-3333-444455556666";
|
|
1180
|
+
seedCreds(env, uuid); // healthy local creds → plain reuse, no recreate (#593)
|
|
1161
1181
|
const legacy: CloudflaredTunnelRecord = {
|
|
1162
1182
|
pid: 71001,
|
|
1163
1183
|
tunnelUuid: uuid,
|
|
@@ -1379,6 +1399,239 @@ describe("exposeCloudflareUp", () => {
|
|
|
1379
1399
|
}
|
|
1380
1400
|
});
|
|
1381
1401
|
});
|
|
1402
|
+
|
|
1403
|
+
describe("#593: reuse-path credentials self-heal", () => {
|
|
1404
|
+
test("recreates the tunnel when the local credentials file is missing", async () => {
|
|
1405
|
+
const env = makeEnv();
|
|
1406
|
+
try {
|
|
1407
|
+
const staleUuid = "11110000-0000-0000-0000-0000000005aa";
|
|
1408
|
+
const freshUuid = "22220000-0000-0000-0000-0000000005bb";
|
|
1409
|
+
// NO seedCreds → the reused tunnel's local creds file is absent, the
|
|
1410
|
+
// exact field state (account-side tunnel survives, local creds lost).
|
|
1411
|
+
const { runner, calls } = queueRunner([
|
|
1412
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
1413
|
+
{
|
|
1414
|
+
code: 0,
|
|
1415
|
+
stdout: JSON.stringify([{ id: staleUuid, name: "parachute" }]),
|
|
1416
|
+
stderr: "",
|
|
1417
|
+
}, // tunnel list (exists account-side)
|
|
1418
|
+
{ code: 0, stdout: "Deleted tunnel parachute\n", stderr: "" }, // tunnel delete --force
|
|
1419
|
+
{
|
|
1420
|
+
code: 0,
|
|
1421
|
+
stdout: `Created tunnel parachute with id ${freshUuid}\n`,
|
|
1422
|
+
stderr: "",
|
|
1423
|
+
}, // tunnel create (fresh creds)
|
|
1424
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1425
|
+
]);
|
|
1426
|
+
const { spawner } = fakeSpawner(43000);
|
|
1427
|
+
const logs: string[] = [];
|
|
1428
|
+
|
|
1429
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1430
|
+
runner,
|
|
1431
|
+
spawner,
|
|
1432
|
+
alive: () => false,
|
|
1433
|
+
kill: () => {},
|
|
1434
|
+
log: (l) => logs.push(l),
|
|
1435
|
+
manifestPath: env.manifestPath,
|
|
1436
|
+
statePath: env.statePath,
|
|
1437
|
+
exposeStatePath: env.exposeStatePath,
|
|
1438
|
+
configPath: env.configPath,
|
|
1439
|
+
logPath: env.logPath,
|
|
1440
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1441
|
+
configDir: env.configDir,
|
|
1442
|
+
skipHub: true,
|
|
1443
|
+
tunnelName: "parachute",
|
|
1444
|
+
});
|
|
1445
|
+
|
|
1446
|
+
expect(code).toBe(0);
|
|
1447
|
+
const cmds = calls.map((c) => c.cmd.join(" "));
|
|
1448
|
+
expect(cmds).toContain("cloudflared tunnel delete --force parachute");
|
|
1449
|
+
expect(cmds).toContain("cloudflared tunnel create parachute");
|
|
1450
|
+
// State + config reflect the FRESH uuid, not the stale one.
|
|
1451
|
+
const state = readCloudflaredState(env.statePath);
|
|
1452
|
+
expect(findTunnelRecord(state, "parachute")?.tunnelUuid).toBe(freshUuid);
|
|
1453
|
+
const yaml = readFileSync(env.configPath, "utf8");
|
|
1454
|
+
expect(yaml).toContain(`tunnel: ${freshUuid}`);
|
|
1455
|
+
const joined = logs.join("\n");
|
|
1456
|
+
expect(joined).toContain("local credentials");
|
|
1457
|
+
expect(joined).toContain("Recreated tunnel");
|
|
1458
|
+
} finally {
|
|
1459
|
+
env.cleanup();
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test("fails with recovery commands when the stale-tunnel delete itself fails", async () => {
|
|
1464
|
+
const env = makeEnv();
|
|
1465
|
+
try {
|
|
1466
|
+
const staleUuid = "11110000-0000-0000-0000-0000000005cc";
|
|
1467
|
+
const { runner, calls } = queueRunner([
|
|
1468
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
1469
|
+
{
|
|
1470
|
+
code: 0,
|
|
1471
|
+
stdout: JSON.stringify([{ id: staleUuid, name: "parachute" }]),
|
|
1472
|
+
stderr: "",
|
|
1473
|
+
}, // tunnel list
|
|
1474
|
+
{ code: 1, stdout: "", stderr: "tunnel has active connections" }, // delete fails
|
|
1475
|
+
]);
|
|
1476
|
+
const { spawner } = fakeSpawner(43001);
|
|
1477
|
+
const logs: string[] = [];
|
|
1478
|
+
|
|
1479
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1480
|
+
runner,
|
|
1481
|
+
spawner,
|
|
1482
|
+
alive: () => false,
|
|
1483
|
+
kill: () => {},
|
|
1484
|
+
log: (l) => logs.push(l),
|
|
1485
|
+
manifestPath: env.manifestPath,
|
|
1486
|
+
statePath: env.statePath,
|
|
1487
|
+
exposeStatePath: env.exposeStatePath,
|
|
1488
|
+
configPath: env.configPath,
|
|
1489
|
+
logPath: env.logPath,
|
|
1490
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1491
|
+
configDir: env.configDir,
|
|
1492
|
+
skipHub: true,
|
|
1493
|
+
tunnelName: "parachute",
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
expect(code).toBe(1);
|
|
1497
|
+
// No connector spawned, no success print.
|
|
1498
|
+
const joined = logs.join("\n");
|
|
1499
|
+
expect(joined).toContain("Couldn't delete the stale tunnel");
|
|
1500
|
+
expect(joined).toContain("cloudflared tunnel delete --force parachute");
|
|
1501
|
+
expect(joined).not.toContain("Cloudflare tunnel up");
|
|
1502
|
+
// create + route never ran.
|
|
1503
|
+
const cmds = calls.map((c) => c.cmd.join(" "));
|
|
1504
|
+
expect(cmds.some((c) => c.startsWith("cloudflared tunnel create"))).toBe(false);
|
|
1505
|
+
} finally {
|
|
1506
|
+
env.cleanup();
|
|
1507
|
+
}
|
|
1508
|
+
});
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
describe("#593: post-start connection verification", () => {
|
|
1512
|
+
test("fails loudly when the connector never registers a connection (timeout)", async () => {
|
|
1513
|
+
const env = makeEnv();
|
|
1514
|
+
try {
|
|
1515
|
+
const uuid = "33330000-0000-0000-0000-0000000005dd";
|
|
1516
|
+
seedCreds(env, uuid);
|
|
1517
|
+
const { runner } = queueRunner([
|
|
1518
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1519
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
1520
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1521
|
+
]);
|
|
1522
|
+
const { spawner } = fakeSpawner(43002);
|
|
1523
|
+
const logs: string[] = [];
|
|
1524
|
+
|
|
1525
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1526
|
+
runner,
|
|
1527
|
+
spawner,
|
|
1528
|
+
alive: () => false,
|
|
1529
|
+
kill: () => {},
|
|
1530
|
+
log: (l) => logs.push(l),
|
|
1531
|
+
manifestPath: env.manifestPath,
|
|
1532
|
+
statePath: env.statePath,
|
|
1533
|
+
exposeStatePath: env.exposeStatePath,
|
|
1534
|
+
configPath: env.configPath,
|
|
1535
|
+
logPath: env.logPath,
|
|
1536
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1537
|
+
configDir: env.configDir,
|
|
1538
|
+
skipHub: true,
|
|
1539
|
+
tunnelName: "parachute",
|
|
1540
|
+
// Drive the timeout branch directly (no real cloudflared/poll).
|
|
1541
|
+
verifyConnection: async () => false,
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
expect(code).toBe(1);
|
|
1545
|
+
const joined = logs.join("\n");
|
|
1546
|
+
expect(joined).toContain("never registered a tunnel connection");
|
|
1547
|
+
expect(joined).toContain("error 1033");
|
|
1548
|
+
expect(joined).toContain(env.logPath); // names the connector log
|
|
1549
|
+
expect(joined).not.toContain("✓ Cloudflare tunnel up");
|
|
1550
|
+
} finally {
|
|
1551
|
+
env.cleanup();
|
|
1552
|
+
}
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
test("prints success when the connector verifies connected", async () => {
|
|
1556
|
+
const env = makeEnv();
|
|
1557
|
+
try {
|
|
1558
|
+
const uuid = "44440000-0000-0000-0000-0000000005ee";
|
|
1559
|
+
seedCreds(env, uuid);
|
|
1560
|
+
const { runner } = queueRunner([
|
|
1561
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
1562
|
+
{ code: 0, stdout: JSON.stringify([{ id: uuid, name: "parachute" }]), stderr: "" },
|
|
1563
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
1564
|
+
]);
|
|
1565
|
+
const { spawner } = fakeSpawner(43003);
|
|
1566
|
+
const logs: string[] = [];
|
|
1567
|
+
|
|
1568
|
+
const code = await exposeCloudflareUp("vault.example.com", {
|
|
1569
|
+
runner,
|
|
1570
|
+
spawner,
|
|
1571
|
+
alive: () => false,
|
|
1572
|
+
kill: () => {},
|
|
1573
|
+
log: (l) => logs.push(l),
|
|
1574
|
+
manifestPath: env.manifestPath,
|
|
1575
|
+
statePath: env.statePath,
|
|
1576
|
+
exposeStatePath: env.exposeStatePath,
|
|
1577
|
+
configPath: env.configPath,
|
|
1578
|
+
logPath: env.logPath,
|
|
1579
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
1580
|
+
configDir: env.configDir,
|
|
1581
|
+
skipHub: true,
|
|
1582
|
+
tunnelName: "parachute",
|
|
1583
|
+
verifyConnection: async () => true,
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
expect(code).toBe(0);
|
|
1587
|
+
const joined = logs.join("\n");
|
|
1588
|
+
expect(joined).toContain("Connector connected.");
|
|
1589
|
+
expect(joined).toContain("✓ Cloudflare tunnel up");
|
|
1590
|
+
} finally {
|
|
1591
|
+
env.cleanup();
|
|
1592
|
+
}
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
test("defaultVerifyConnection polls tunnelConnectionCount and returns true once connected", async () => {
|
|
1596
|
+
// Drives the real default poll loop against a queued runner — first
|
|
1597
|
+
// `tunnel info` reports no conns, the second reports one. No real sleep.
|
|
1598
|
+
let infoCalls = 0;
|
|
1599
|
+
const runner: Runner = async (cmd) => {
|
|
1600
|
+
if (cmd.join(" ").startsWith("cloudflared tunnel info")) {
|
|
1601
|
+
infoCalls += 1;
|
|
1602
|
+
return infoCalls === 1
|
|
1603
|
+
? { code: 0, stdout: JSON.stringify({ conns: [] }), stderr: "" }
|
|
1604
|
+
: { code: 0, stdout: JSON.stringify({ conns: [{ id: "c1" }] }), stderr: "" };
|
|
1605
|
+
}
|
|
1606
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
1607
|
+
};
|
|
1608
|
+
const connected = await defaultVerifyConnection({
|
|
1609
|
+
runner,
|
|
1610
|
+
tunnelName: "parachute",
|
|
1611
|
+
timeoutMs: 5_000,
|
|
1612
|
+
pollMs: 1,
|
|
1613
|
+
sleep: async () => {},
|
|
1614
|
+
});
|
|
1615
|
+
expect(connected).toBe(true);
|
|
1616
|
+
expect(infoCalls).toBe(2);
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
test("defaultVerifyConnection returns false when the budget elapses with no connection", async () => {
|
|
1620
|
+
const runner: Runner = async () => ({
|
|
1621
|
+
code: 0,
|
|
1622
|
+
stdout: JSON.stringify({ conns: [] }),
|
|
1623
|
+
stderr: "",
|
|
1624
|
+
});
|
|
1625
|
+
const connected = await defaultVerifyConnection({
|
|
1626
|
+
runner,
|
|
1627
|
+
tunnelName: "parachute",
|
|
1628
|
+
timeoutMs: 0, // immediate deadline → one probe then false
|
|
1629
|
+
pollMs: 1,
|
|
1630
|
+
sleep: async () => {},
|
|
1631
|
+
});
|
|
1632
|
+
expect(connected).toBe(false);
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1382
1635
|
});
|
|
1383
1636
|
|
|
1384
1637
|
describe("exposeCloudflareOff", () => {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { ensureHubUnitForExpose, resolveExposeSupervisor } from "../commands/expose-supervisor.ts";
|
|
3
|
+
import type { EnsureHubVersionMatchesResult } from "../hub-unit.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* #590: `ensureHubUnitForExpose` must run the version-check-and-restart at the
|
|
7
|
+
* expose adoption point, so an expose never wires a tunnel to a stale zombie
|
|
8
|
+
* that merely answers /health on the canonical port. These tests drive the
|
|
9
|
+
* version-check seam directly (no real launchctl / live hub).
|
|
10
|
+
*/
|
|
11
|
+
describe("ensureHubUnitForExpose — version-check at the expose adoption point (#590)", () => {
|
|
12
|
+
function sup(
|
|
13
|
+
ensureHubUnitOutcome: "already-up" | "started" | "no-unit",
|
|
14
|
+
versionResult: EnsureHubVersionMatchesResult,
|
|
15
|
+
versionSpy?: (port: number) => void,
|
|
16
|
+
) {
|
|
17
|
+
return resolveExposeSupervisor({
|
|
18
|
+
ensureHubUnit: async ({ port }) => ({
|
|
19
|
+
outcome: ensureHubUnitOutcome,
|
|
20
|
+
port: port ?? 1939,
|
|
21
|
+
messages: ensureHubUnitOutcome === "no-unit" ? ["no hub unit installed"] : [],
|
|
22
|
+
}),
|
|
23
|
+
ensureHubVersion: async ({ port }) => {
|
|
24
|
+
versionSpy?.(port);
|
|
25
|
+
return versionResult;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("hub up + version matches → ok, version check ran with the probed port", async () => {
|
|
31
|
+
const logs: string[] = [];
|
|
32
|
+
let checkedPort: number | undefined;
|
|
33
|
+
const s = sup(
|
|
34
|
+
"already-up",
|
|
35
|
+
{
|
|
36
|
+
outcome: "match",
|
|
37
|
+
runningVersion: "0.6.4-rc.9",
|
|
38
|
+
installedVersion: "0.6.4-rc.9",
|
|
39
|
+
messages: [],
|
|
40
|
+
},
|
|
41
|
+
(p) => {
|
|
42
|
+
checkedPort = p;
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
46
|
+
expect(res.ok).toBe(true);
|
|
47
|
+
expect(checkedPort).toBe(1939);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("hub up but a stale zombie → restarted → ok (tunnel binds to NEW code)", async () => {
|
|
51
|
+
const logs: string[] = [];
|
|
52
|
+
const s = sup("already-up", {
|
|
53
|
+
outcome: "restarted",
|
|
54
|
+
runningVersion: "0.6.4-rc.9",
|
|
55
|
+
installedVersion: "0.6.4-rc.9",
|
|
56
|
+
messages: ["✓ hub unit restarted; now running 0.6.4-rc.9."],
|
|
57
|
+
});
|
|
58
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
59
|
+
expect(res.ok).toBe(true);
|
|
60
|
+
expect(logs.join("\n")).toContain("now running 0.6.4-rc.9");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("hub up but mismatch + NOT unit-managed → expose FAILS (don't tunnel to a zombie)", async () => {
|
|
64
|
+
const logs: string[] = [];
|
|
65
|
+
const s = sup("already-up", {
|
|
66
|
+
outcome: "not-unit-managed",
|
|
67
|
+
runningVersion: "0.5.14-rc.4",
|
|
68
|
+
installedVersion: "0.6.4-rc.9",
|
|
69
|
+
messages: ["⚠ the running hub is 0.5.14-rc.4 but 0.6.4-rc.9 is installed."],
|
|
70
|
+
});
|
|
71
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
72
|
+
expect(res.ok).toBe(false);
|
|
73
|
+
expect(logs.join("\n")).toContain("0.5.14-rc.4");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("still-mismatched after restart → expose CONTINUES (warn, don't block)", async () => {
|
|
77
|
+
const logs: string[] = [];
|
|
78
|
+
const s = sup("already-up", {
|
|
79
|
+
outcome: "still-mismatched",
|
|
80
|
+
runningVersion: "0.6.4-rc.8",
|
|
81
|
+
installedVersion: "0.6.4-rc.9",
|
|
82
|
+
messages: ["⚠ restarted the hub unit, but it is still not reporting 0.6.4-rc.9."],
|
|
83
|
+
});
|
|
84
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
85
|
+
expect(res.ok).toBe(true);
|
|
86
|
+
expect(logs.join("\n")).toContain("still not reporting");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("hub NOT up (no unit) → fails BEFORE the version check (no false adoption)", async () => {
|
|
90
|
+
const logs: string[] = [];
|
|
91
|
+
let versionRan = false;
|
|
92
|
+
const s = sup(
|
|
93
|
+
"no-unit",
|
|
94
|
+
{ outcome: "match", installedVersion: "0.6.4-rc.9", messages: [] },
|
|
95
|
+
() => {
|
|
96
|
+
versionRan = true;
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
const res = await ensureHubUnitForExpose(s, 1939, (l) => logs.push(l));
|
|
100
|
+
expect(res.ok).toBe(false);
|
|
101
|
+
// The version check only runs once the hub is confirmed up.
|
|
102
|
+
expect(versionRan).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { classifyDbError, createDbHolder, probeDbLiveness } from "../hub-db-liveness.ts";
|
|
4
|
+
|
|
5
|
+
/** Build a `SQLiteError`-shaped object with the given code + message. */
|
|
6
|
+
function sqliteErr(code: string, message: string): Error & { code: string } {
|
|
7
|
+
const e = new Error(message) as Error & { code: string };
|
|
8
|
+
e.name = "SQLiteError";
|
|
9
|
+
e.code = code;
|
|
10
|
+
return e;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("classifyDbError (#594)", () => {
|
|
14
|
+
test("the persistent-corruption class is fatal", () => {
|
|
15
|
+
expect(classifyDbError(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("fatal");
|
|
16
|
+
expect(classifyDbError(new Error("disk I/O error"))).toBe("fatal");
|
|
17
|
+
expect(classifyDbError(sqliteErr("SQLITE_CORRUPT", "database disk image is malformed"))).toBe(
|
|
18
|
+
"fatal",
|
|
19
|
+
);
|
|
20
|
+
expect(classifyDbError(sqliteErr("SQLITE_NOTADB", "file is not a database"))).toBe("fatal");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("transient locks are NOT fatal", () => {
|
|
24
|
+
expect(classifyDbError(sqliteErr("SQLITE_BUSY", "database is locked"))).toBe("transient");
|
|
25
|
+
expect(classifyDbError(sqliteErr("SQLITE_LOCKED", "database table is locked"))).toBe(
|
|
26
|
+
"transient",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("unrelated errors classify as other", () => {
|
|
31
|
+
expect(classifyDbError(new Error("UNIQUE constraint failed: users.id"))).toBe("other");
|
|
32
|
+
expect(classifyDbError(new TypeError("undefined is not a function"))).toBe("other");
|
|
33
|
+
expect(classifyDbError(null)).toBe("other");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("probeDbLiveness (#594)", () => {
|
|
38
|
+
test("returns ok on a live in-memory db", () => {
|
|
39
|
+
const db = new Database(":memory:");
|
|
40
|
+
expect(probeDbLiveness(db)).toBe("ok");
|
|
41
|
+
db.close();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns error: <class> on a closed handle, never throws", () => {
|
|
45
|
+
const db = new Database(":memory:");
|
|
46
|
+
db.close();
|
|
47
|
+
const result = probeDbLiveness(db);
|
|
48
|
+
expect(result.startsWith("error:")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("createDbHolder (#594)", () => {
|
|
53
|
+
test("non-fatal errors are ignored (no reopen, no exit)", () => {
|
|
54
|
+
const initial = new Database(":memory:");
|
|
55
|
+
let reopens = 0;
|
|
56
|
+
let exits = 0;
|
|
57
|
+
const holder = createDbHolder(initial, {
|
|
58
|
+
reopen: () => {
|
|
59
|
+
reopens += 1;
|
|
60
|
+
return new Database(":memory:");
|
|
61
|
+
},
|
|
62
|
+
exit: () => {
|
|
63
|
+
exits += 1;
|
|
64
|
+
},
|
|
65
|
+
log: () => {},
|
|
66
|
+
});
|
|
67
|
+
expect(holder.healOrExit(sqliteErr("SQLITE_BUSY", "database is locked"))).toBe("ignored");
|
|
68
|
+
expect(holder.healOrExit(new Error("UNIQUE constraint failed"))).toBe("ignored");
|
|
69
|
+
expect(reopens).toBe(0);
|
|
70
|
+
expect(exits).toBe(0);
|
|
71
|
+
expect(holder.get()).toBe(initial);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a fatal error reopens the handle ONCE and swaps it in", () => {
|
|
75
|
+
const initial = new Database(":memory:");
|
|
76
|
+
const fresh = new Database(":memory:");
|
|
77
|
+
let reopens = 0;
|
|
78
|
+
let exits = 0;
|
|
79
|
+
let closedOld = false;
|
|
80
|
+
const holder = createDbHolder(initial, {
|
|
81
|
+
reopen: () => {
|
|
82
|
+
reopens += 1;
|
|
83
|
+
return fresh;
|
|
84
|
+
},
|
|
85
|
+
exit: () => {
|
|
86
|
+
exits += 1;
|
|
87
|
+
},
|
|
88
|
+
closeOld: () => {
|
|
89
|
+
closedOld = true;
|
|
90
|
+
},
|
|
91
|
+
log: () => {},
|
|
92
|
+
});
|
|
93
|
+
expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("healed");
|
|
94
|
+
expect(reopens).toBe(1);
|
|
95
|
+
expect(exits).toBe(0);
|
|
96
|
+
expect(closedOld).toBe(true);
|
|
97
|
+
expect(holder.get()).toBe(fresh);
|
|
98
|
+
fresh.close();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a fatal error exits(1) when reopen throws", () => {
|
|
102
|
+
const initial = new Database(":memory:");
|
|
103
|
+
let exitCode: number | undefined;
|
|
104
|
+
const holder = createDbHolder(initial, {
|
|
105
|
+
reopen: () => {
|
|
106
|
+
throw sqliteErr("SQLITE_IOERR", "disk I/O error");
|
|
107
|
+
},
|
|
108
|
+
// Non-exiting spy so the test process survives.
|
|
109
|
+
exit: (code) => {
|
|
110
|
+
exitCode = code;
|
|
111
|
+
},
|
|
112
|
+
log: () => {},
|
|
113
|
+
});
|
|
114
|
+
expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("exited");
|
|
115
|
+
expect(exitCode).toBe(1);
|
|
116
|
+
// Handle is unchanged (we couldn't reopen).
|
|
117
|
+
expect(holder.get()).toBe(initial);
|
|
118
|
+
initial.close();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("a fatal error exits(1) when the REOPENED handle is also dead", () => {
|
|
122
|
+
const initial = new Database(":memory:");
|
|
123
|
+
// Reopen returns an already-closed handle → the holder's SELECT 1 verify
|
|
124
|
+
// throws → exit. This is the "state dir still gone after reopen" case.
|
|
125
|
+
const deadFresh = new Database(":memory:");
|
|
126
|
+
deadFresh.close();
|
|
127
|
+
let exitCode: number | undefined;
|
|
128
|
+
const holder = createDbHolder(initial, {
|
|
129
|
+
reopen: () => deadFresh,
|
|
130
|
+
exit: (code) => {
|
|
131
|
+
exitCode = code;
|
|
132
|
+
},
|
|
133
|
+
log: () => {},
|
|
134
|
+
});
|
|
135
|
+
expect(holder.healOrExit(sqliteErr("SQLITE_IOERR", "disk I/O error"))).toBe("exited");
|
|
136
|
+
expect(exitCode).toBe(1);
|
|
137
|
+
initial.close();
|
|
138
|
+
});
|
|
139
|
+
});
|