@openparachute/hub 0.6.4-rc.8 → 0.6.4
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__/expose-cloudflare.test.ts +103 -0
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/hub-server.test.ts +133 -0
- package/src/__tests__/hub-unit.test.ts +181 -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/commands/expose-cloudflare.ts +28 -0
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +63 -1
- package/src/commands/install.ts +42 -1
- package/src/help.ts +1 -1
- package/src/hub-server.ts +34 -0
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +255 -0
package/package.json
CHANGED
|
@@ -1464,6 +1464,109 @@ describe("exposeCloudflareOff", () => {
|
|
|
1464
1464
|
}
|
|
1465
1465
|
});
|
|
1466
1466
|
|
|
1467
|
+
test("clears stale PARACHUTE_HUB_ORIGIN from vault/.env on last-tunnel down (#503)", async () => {
|
|
1468
|
+
const env = makeEnv();
|
|
1469
|
+
try {
|
|
1470
|
+
// Seed the stale public origin the up-path persisted into vault/.env.
|
|
1471
|
+
// After teardown the hub is loopback-only, so leaving this would pin a
|
|
1472
|
+
// public expected issuer and 401 every request on the next vault restart.
|
|
1473
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1474
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1475
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1476
|
+
|
|
1477
|
+
writeCloudflaredState(
|
|
1478
|
+
{
|
|
1479
|
+
version: 2,
|
|
1480
|
+
tunnels: {
|
|
1481
|
+
parachute: {
|
|
1482
|
+
pid: 55557,
|
|
1483
|
+
tunnelUuid: "ffffffff-0000-0000-0000-000000000006",
|
|
1484
|
+
tunnelName: "parachute",
|
|
1485
|
+
hostname: "vault.example.com",
|
|
1486
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1487
|
+
configPath: env.configPath,
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
env.statePath,
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const logs: string[] = [];
|
|
1495
|
+
const code = await exposeCloudflareOff({
|
|
1496
|
+
configDir: env.configDir,
|
|
1497
|
+
statePath: env.statePath,
|
|
1498
|
+
exposeStatePath: env.exposeStatePath,
|
|
1499
|
+
alive: () => false,
|
|
1500
|
+
kill: () => {},
|
|
1501
|
+
log: (l) => logs.push(l),
|
|
1502
|
+
});
|
|
1503
|
+
|
|
1504
|
+
expect(code).toBe(0);
|
|
1505
|
+
// The stale public origin is gone — vault reverts to its loopback default.
|
|
1506
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
1507
|
+
// Operator is told what to restart so a running vault picks up the change.
|
|
1508
|
+
expect(logs.join("\n")).toContain("cleared PARACHUTE_HUB_ORIGIN");
|
|
1509
|
+
expect(logs.join("\n")).toContain("parachute restart vault");
|
|
1510
|
+
} finally {
|
|
1511
|
+
env.cleanup();
|
|
1512
|
+
}
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
test("leaves vault/.env untouched while other tunnels survive (#503)", async () => {
|
|
1516
|
+
const env = makeEnv();
|
|
1517
|
+
try {
|
|
1518
|
+
const vaultEnvPath = join(env.configDir, "vault", ".env");
|
|
1519
|
+
require("node:fs").mkdirSync(join(env.configDir, "vault"), { recursive: true });
|
|
1520
|
+
writeFileSync(vaultEnvPath, "PARACHUTE_HUB_ORIGIN=https://vault.example.com\n");
|
|
1521
|
+
|
|
1522
|
+
// Two tunnels; tear down only one by name → the box stays exposed, so the
|
|
1523
|
+
// persisted public origin must remain (clearing it would break the live
|
|
1524
|
+
// tunnel's iss check). Symmetric with the expose-state.json retention.
|
|
1525
|
+
writeCloudflaredState(
|
|
1526
|
+
{
|
|
1527
|
+
version: 2,
|
|
1528
|
+
tunnels: {
|
|
1529
|
+
alpha: {
|
|
1530
|
+
pid: 55558,
|
|
1531
|
+
tunnelUuid: "11111111-0000-0000-0000-000000000007",
|
|
1532
|
+
tunnelName: "alpha",
|
|
1533
|
+
hostname: "alpha.example.com",
|
|
1534
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1535
|
+
configPath: env.configPath,
|
|
1536
|
+
},
|
|
1537
|
+
beta: {
|
|
1538
|
+
pid: 55559,
|
|
1539
|
+
tunnelUuid: "22222222-0000-0000-0000-000000000008",
|
|
1540
|
+
tunnelName: "beta",
|
|
1541
|
+
hostname: "beta.example.com",
|
|
1542
|
+
startedAt: "2026-04-22T12:00:00.000Z",
|
|
1543
|
+
configPath: env.configPath,
|
|
1544
|
+
},
|
|
1545
|
+
},
|
|
1546
|
+
},
|
|
1547
|
+
env.statePath,
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
const code = await exposeCloudflareOff({
|
|
1551
|
+
configDir: env.configDir,
|
|
1552
|
+
tunnelName: "alpha",
|
|
1553
|
+
statePath: env.statePath,
|
|
1554
|
+
exposeStatePath: env.exposeStatePath,
|
|
1555
|
+
alive: () => false,
|
|
1556
|
+
kill: () => {},
|
|
1557
|
+
log: () => {},
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
expect(code).toBe(0);
|
|
1561
|
+
// Beta tunnel survives → public origin stays.
|
|
1562
|
+
expect(readEnvFileValues(vaultEnvPath).PARACHUTE_HUB_ORIGIN).toBe(
|
|
1563
|
+
"https://vault.example.com",
|
|
1564
|
+
);
|
|
1565
|
+
} finally {
|
|
1566
|
+
env.cleanup();
|
|
1567
|
+
}
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1467
1570
|
test("clears stale state when the process is already gone", async () => {
|
|
1468
1571
|
const env = makeEnv();
|
|
1469
1572
|
try {
|
|
@@ -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
|
+
});
|
|
@@ -1630,6 +1630,139 @@ describe("hubFetch /vault/<name>/* dynamic proxy (#144)", () => {
|
|
|
1630
1630
|
}
|
|
1631
1631
|
});
|
|
1632
1632
|
|
|
1633
|
+
// #525: bare `/vault/<name>` POST (no `/mcp` suffix) half-connects MCP
|
|
1634
|
+
// clients. The fix 308-redirects the bare-path POST to `<mount>/mcp` BEFORE
|
|
1635
|
+
// proxying, so a compliant client re-POSTs to the right endpoint and clients
|
|
1636
|
+
// that don't follow redirects at least get an actionable Location + JSON body
|
|
1637
|
+
// (vs the old opaque vault 405).
|
|
1638
|
+
test("POST to bare /vault/<name> 308-redirects to /vault/<name>/mcp with an actionable body (#525)", async () => {
|
|
1639
|
+
const h = makeHarness();
|
|
1640
|
+
try {
|
|
1641
|
+
writeManifest({ services: [vaultEntry("aaron")] }, h.manifestPath);
|
|
1642
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1643
|
+
const res = await fetcher(
|
|
1644
|
+
req("/vault/aaron", {
|
|
1645
|
+
method: "POST",
|
|
1646
|
+
headers: { "content-type": "application/json" },
|
|
1647
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }),
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
expect(res.status).toBe(308);
|
|
1651
|
+
expect(res.headers.get("location")).toBe("/vault/aaron/mcp");
|
|
1652
|
+
// 308 is permanently cacheable by default — no-store prevents a cached
|
|
1653
|
+
// redirect outliving a remount.
|
|
1654
|
+
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
1655
|
+
const body = (await res.json()) as { error: string; mcp_url: string; message: string };
|
|
1656
|
+
expect(body.error).toBe("missing_mcp_suffix");
|
|
1657
|
+
expect(body.mcp_url).toBe("/vault/aaron/mcp");
|
|
1658
|
+
expect(body.message).toContain("/vault/aaron/mcp");
|
|
1659
|
+
} finally {
|
|
1660
|
+
h.cleanup();
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
test("bare-path 308 honors a trailing-slash mount: POST /vault/default → /vault/default/mcp (#525)", async () => {
|
|
1665
|
+
// Mounts in services.json sometimes carry a trailing slash (#197). The
|
|
1666
|
+
// redirect target must be built from the *normalized* mount so it stays
|
|
1667
|
+
// `/vault/default/mcp`, never `/vault/default//mcp`.
|
|
1668
|
+
const h = makeHarness();
|
|
1669
|
+
try {
|
|
1670
|
+
writeManifest(
|
|
1671
|
+
{
|
|
1672
|
+
services: [
|
|
1673
|
+
{
|
|
1674
|
+
name: "parachute-vault-default",
|
|
1675
|
+
port: 1940,
|
|
1676
|
+
paths: ["/vault/default/"],
|
|
1677
|
+
health: "/health",
|
|
1678
|
+
version: "0.4.0",
|
|
1679
|
+
},
|
|
1680
|
+
],
|
|
1681
|
+
},
|
|
1682
|
+
h.manifestPath,
|
|
1683
|
+
);
|
|
1684
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1685
|
+
const res = await fetcher(req("/vault/default", { method: "POST" }));
|
|
1686
|
+
expect(res.status).toBe(308);
|
|
1687
|
+
expect(res.headers.get("location")).toBe("/vault/default/mcp");
|
|
1688
|
+
} finally {
|
|
1689
|
+
h.cleanup();
|
|
1690
|
+
}
|
|
1691
|
+
});
|
|
1692
|
+
|
|
1693
|
+
test("GET to bare /vault/<name> is NOT redirected — proxies through untouched (#525)", async () => {
|
|
1694
|
+
// Only POST (the MCP transport verb) is caught. A browser GET to the bare
|
|
1695
|
+
// path keeps its existing proxy behavior so we don't break any bare-path
|
|
1696
|
+
// GET surface.
|
|
1697
|
+
const h = makeHarness();
|
|
1698
|
+
const upstream = startUpstream("bare-get");
|
|
1699
|
+
try {
|
|
1700
|
+
writeManifest(
|
|
1701
|
+
{
|
|
1702
|
+
services: [
|
|
1703
|
+
{
|
|
1704
|
+
name: "parachute-vault-aaron",
|
|
1705
|
+
port: upstream.port,
|
|
1706
|
+
paths: ["/vault/aaron"],
|
|
1707
|
+
health: "/health",
|
|
1708
|
+
version: "0.4.0",
|
|
1709
|
+
},
|
|
1710
|
+
],
|
|
1711
|
+
},
|
|
1712
|
+
h.manifestPath,
|
|
1713
|
+
);
|
|
1714
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1715
|
+
const res = await fetcher(req("/vault/aaron", { method: "GET" }));
|
|
1716
|
+
expect(res.status).toBe(200);
|
|
1717
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1718
|
+
expect(body.tag).toBe("bare-get");
|
|
1719
|
+
expect(body.method).toBe("GET");
|
|
1720
|
+
expect(body.pathname).toBe("/vault/aaron");
|
|
1721
|
+
} finally {
|
|
1722
|
+
upstream.stop();
|
|
1723
|
+
h.cleanup();
|
|
1724
|
+
}
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
test("POST to the canonical /vault/<name>/mcp sub-path is NOT redirected — proxies through (#525)", async () => {
|
|
1728
|
+
// The real MCP endpoint must keep working: a POST that already carries the
|
|
1729
|
+
// `/mcp` suffix is a sub-path (not the exact bare mount) and proxies
|
|
1730
|
+
// straight to the vault backend.
|
|
1731
|
+
const h = makeHarness();
|
|
1732
|
+
const upstream = startUpstream("mcp-post");
|
|
1733
|
+
try {
|
|
1734
|
+
writeManifest(
|
|
1735
|
+
{
|
|
1736
|
+
services: [
|
|
1737
|
+
{
|
|
1738
|
+
name: "parachute-vault-aaron",
|
|
1739
|
+
port: upstream.port,
|
|
1740
|
+
paths: ["/vault/aaron"],
|
|
1741
|
+
health: "/health",
|
|
1742
|
+
version: "0.4.0",
|
|
1743
|
+
},
|
|
1744
|
+
],
|
|
1745
|
+
},
|
|
1746
|
+
h.manifestPath,
|
|
1747
|
+
);
|
|
1748
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
1749
|
+
const res = await fetcher(
|
|
1750
|
+
req("/vault/aaron/mcp", {
|
|
1751
|
+
method: "POST",
|
|
1752
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
|
|
1753
|
+
}),
|
|
1754
|
+
);
|
|
1755
|
+
expect(res.status).toBe(200);
|
|
1756
|
+
const body = (await res.json()) as { tag: string; method: string; pathname: string };
|
|
1757
|
+
expect(body.tag).toBe("mcp-post");
|
|
1758
|
+
expect(body.method).toBe("POST");
|
|
1759
|
+
expect(body.pathname).toBe("/vault/aaron/mcp");
|
|
1760
|
+
} finally {
|
|
1761
|
+
upstream.stop();
|
|
1762
|
+
h.cleanup();
|
|
1763
|
+
}
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1633
1766
|
test("synthesizes X-Forwarded-Proto when edge didn't set it (direct HTTPS to hub)", async () => {
|
|
1634
1767
|
// Non-Render shape: hub bound directly to https (e.g. local TLS or a
|
|
1635
1768
|
// proxy that doesn't set X-Forwarded-Proto). isHttpsRequest sees the
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
NO_MANAGER_MESSAGE,
|
|
5
5
|
NO_UNIT_MESSAGE,
|
|
6
6
|
ensureHubUnit,
|
|
7
|
+
ensureHubVersionMatches,
|
|
7
8
|
installAndStartHubUnit,
|
|
8
9
|
queryHubUnitState,
|
|
9
10
|
restartHubUnit,
|
|
@@ -38,6 +39,12 @@ function fakeDeps(
|
|
|
38
39
|
over: Partial<HubUnitDeps> & {
|
|
39
40
|
runResults?: ServiceCommandResult[];
|
|
40
41
|
healthSeq?: boolean[];
|
|
42
|
+
/**
|
|
43
|
+
* #590: scripted version-aware /health probe results. Each element is
|
|
44
|
+
* `null` (hub not answering) or `{ ok, version }`. Drives
|
|
45
|
+
* `probeHealthVersion` across the version-check + post-restart re-probe.
|
|
46
|
+
*/
|
|
47
|
+
healthVersionSeq?: ({ ok: boolean; version?: string } | null)[];
|
|
41
48
|
listeningSeq?: boolean[];
|
|
42
49
|
installedUnit?: boolean;
|
|
43
50
|
} = {},
|
|
@@ -46,6 +53,7 @@ function fakeDeps(
|
|
|
46
53
|
const files = new Map<string, string>();
|
|
47
54
|
let runIdx = 0;
|
|
48
55
|
let healthIdx = 0;
|
|
56
|
+
let healthVersionIdx = 0;
|
|
49
57
|
let listeningIdx = 0;
|
|
50
58
|
const ok: ServiceCommandResult = { code: 0, stdout: "", stderr: "" };
|
|
51
59
|
|
|
@@ -96,6 +104,13 @@ function fakeDeps(
|
|
|
96
104
|
if (!seq) return false;
|
|
97
105
|
return seq[Math.min(healthIdx++, seq.length - 1)] ?? false;
|
|
98
106
|
}),
|
|
107
|
+
probeHealthVersion:
|
|
108
|
+
over.probeHealthVersion ??
|
|
109
|
+
(async () => {
|
|
110
|
+
const seq = over.healthVersionSeq;
|
|
111
|
+
if (!seq) return null;
|
|
112
|
+
return seq[Math.min(healthVersionIdx++, seq.length - 1)] ?? null;
|
|
113
|
+
}),
|
|
99
114
|
portListening:
|
|
100
115
|
over.portListening ??
|
|
101
116
|
(async () => {
|
|
@@ -576,3 +591,169 @@ describe("queryHubUnitState — §6.4 hub-row manager query", () => {
|
|
|
576
591
|
expect(r.detail).toContain("spawn EPERM");
|
|
577
592
|
});
|
|
578
593
|
});
|
|
594
|
+
|
|
595
|
+
describe("ensureHubVersionMatches — version-check-and-restart at adoption (#590)", () => {
|
|
596
|
+
const INSTALLED = "0.6.4-rc.9";
|
|
597
|
+
|
|
598
|
+
test("versions match → no-op, NO restart, NO manager call", async () => {
|
|
599
|
+
const f = fakeDeps({
|
|
600
|
+
platform: "darwin",
|
|
601
|
+
getuid: () => 501,
|
|
602
|
+
installedUnit: true,
|
|
603
|
+
healthVersionSeq: [{ ok: true, version: INSTALLED }],
|
|
604
|
+
});
|
|
605
|
+
const res = await ensureHubVersionMatches({
|
|
606
|
+
installedVersion: INSTALLED,
|
|
607
|
+
port: 1939,
|
|
608
|
+
deps: f.deps,
|
|
609
|
+
readyPollMs: 0,
|
|
610
|
+
});
|
|
611
|
+
expect(res.outcome).toBe("match");
|
|
612
|
+
expect(res.runningVersion).toBe(INSTALLED);
|
|
613
|
+
// No launchctl/systemctl call at all — the version agreed.
|
|
614
|
+
expect(f.calls).toEqual([]);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("mismatch + unit-managed → restarts ONCE + re-probe shows new version → restarted", async () => {
|
|
618
|
+
const f = fakeDeps({
|
|
619
|
+
platform: "darwin",
|
|
620
|
+
getuid: () => 501,
|
|
621
|
+
installedUnit: true,
|
|
622
|
+
// first probe: stale zombie; after the restart the re-probe sees the new code.
|
|
623
|
+
healthVersionSeq: [
|
|
624
|
+
{ ok: true, version: "0.5.14-rc.4" },
|
|
625
|
+
{ ok: true, version: INSTALLED },
|
|
626
|
+
],
|
|
627
|
+
});
|
|
628
|
+
const res = await ensureHubVersionMatches({
|
|
629
|
+
installedVersion: INSTALLED,
|
|
630
|
+
port: 1939,
|
|
631
|
+
deps: f.deps,
|
|
632
|
+
readyPollMs: 0,
|
|
633
|
+
});
|
|
634
|
+
expect(res.outcome).toBe("restarted");
|
|
635
|
+
// Exactly ONE restart (launchctl kickstart -k) was issued.
|
|
636
|
+
const restarts = f.calls.filter((c) => c.includes("kickstart"));
|
|
637
|
+
expect(restarts).toHaveLength(1);
|
|
638
|
+
expect(restarts[0]).toEqual(["launchctl", "kickstart", "-k", "gui/501/computer.parachute.hub"]);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
test("/health has NO version field (very old hub) → treated as mismatch → restart", async () => {
|
|
642
|
+
const f = fakeDeps({
|
|
643
|
+
platform: "linux",
|
|
644
|
+
getuid: () => 1000,
|
|
645
|
+
installedUnit: true,
|
|
646
|
+
healthVersionSeq: [{ ok: true /* no version */ }, { ok: true, version: INSTALLED }],
|
|
647
|
+
});
|
|
648
|
+
const res = await ensureHubVersionMatches({
|
|
649
|
+
installedVersion: INSTALLED,
|
|
650
|
+
port: 1939,
|
|
651
|
+
deps: f.deps,
|
|
652
|
+
readyPollMs: 0,
|
|
653
|
+
});
|
|
654
|
+
expect(res.outcome).toBe("restarted");
|
|
655
|
+
expect(f.calls).toContainEqual(["systemctl", "--user", "restart", "parachute-hub.service"]);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
test("mismatch but NOT unit-managed (no unit installed) → not-unit-managed, NO kill, actionable message", async () => {
|
|
659
|
+
const f = fakeDeps({
|
|
660
|
+
platform: "darwin",
|
|
661
|
+
getuid: () => 501,
|
|
662
|
+
installedUnit: false, // a detached legacy pid / dev `bun run serve`
|
|
663
|
+
healthVersionSeq: [{ ok: true, version: "0.5.14-rc.4" }],
|
|
664
|
+
});
|
|
665
|
+
const res = await ensureHubVersionMatches({
|
|
666
|
+
installedVersion: INSTALLED,
|
|
667
|
+
port: 1939,
|
|
668
|
+
deps: f.deps,
|
|
669
|
+
readyPollMs: 0,
|
|
670
|
+
});
|
|
671
|
+
expect(res.outcome).toBe("not-unit-managed");
|
|
672
|
+
// Did NOT issue any manager command — we never blindly kill a hub we don't own.
|
|
673
|
+
expect(f.calls).toEqual([]);
|
|
674
|
+
const joined = res.messages.join("\n");
|
|
675
|
+
expect(joined).toContain("NOT managed by a Parachute service unit");
|
|
676
|
+
expect(joined).toContain("0.5.14-rc.4");
|
|
677
|
+
expect(joined).toContain(INSTALLED);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
test("mismatch but no service manager at all → not-unit-managed (don't kill)", async () => {
|
|
681
|
+
const f = fakeDeps({
|
|
682
|
+
platform: "linux",
|
|
683
|
+
installedUnit: true,
|
|
684
|
+
which: () => null, // no systemctl → no manager
|
|
685
|
+
healthVersionSeq: [{ ok: true, version: "0.5.14-rc.4" }],
|
|
686
|
+
});
|
|
687
|
+
const res = await ensureHubVersionMatches({
|
|
688
|
+
installedVersion: INSTALLED,
|
|
689
|
+
port: 1939,
|
|
690
|
+
deps: f.deps,
|
|
691
|
+
readyPollMs: 0,
|
|
692
|
+
});
|
|
693
|
+
expect(res.outcome).toBe("not-unit-managed");
|
|
694
|
+
expect(f.calls).toEqual([]);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
test("restart-loop guard: still mismatched after the single restart → still-mismatched, restarts ONCE", async () => {
|
|
698
|
+
const f = fakeDeps({
|
|
699
|
+
platform: "darwin",
|
|
700
|
+
getuid: () => 501,
|
|
701
|
+
installedUnit: true,
|
|
702
|
+
// bun-linked branch checkout: the restart comes back STILL on the old
|
|
703
|
+
// version (package.json trails). Every probe returns the stale version.
|
|
704
|
+
healthVersionSeq: [{ ok: true, version: "0.5.14-rc.4" }],
|
|
705
|
+
});
|
|
706
|
+
const res = await ensureHubVersionMatches({
|
|
707
|
+
installedVersion: INSTALLED,
|
|
708
|
+
port: 1939,
|
|
709
|
+
deps: f.deps,
|
|
710
|
+
readyTimeoutMs: 0, // immediate deadline → one re-probe then settle
|
|
711
|
+
readyPollMs: 0,
|
|
712
|
+
});
|
|
713
|
+
expect(res.outcome).toBe("still-mismatched");
|
|
714
|
+
// Restarted AT MOST once — no loop.
|
|
715
|
+
const restarts = f.calls.filter((c) => c.includes("kickstart"));
|
|
716
|
+
expect(restarts).toHaveLength(1);
|
|
717
|
+
expect(res.messages.join("\n")).toContain("still not reporting");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("hub not answering /health at all → not-running (no-op, no restart)", async () => {
|
|
721
|
+
const f = fakeDeps({
|
|
722
|
+
platform: "darwin",
|
|
723
|
+
getuid: () => 501,
|
|
724
|
+
installedUnit: true,
|
|
725
|
+
healthVersionSeq: [null], // connection refused / timeout
|
|
726
|
+
});
|
|
727
|
+
const res = await ensureHubVersionMatches({
|
|
728
|
+
installedVersion: INSTALLED,
|
|
729
|
+
port: 1939,
|
|
730
|
+
deps: f.deps,
|
|
731
|
+
readyPollMs: 0,
|
|
732
|
+
});
|
|
733
|
+
expect(res.outcome).toBe("not-running");
|
|
734
|
+
expect(f.calls).toEqual([]);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("mismatch + unit-managed but the restart command fails → restart-failed", async () => {
|
|
738
|
+
const f = fakeDeps({
|
|
739
|
+
platform: "linux",
|
|
740
|
+
getuid: () => 1000,
|
|
741
|
+
installedUnit: true,
|
|
742
|
+
healthVersionSeq: [{ ok: true, version: "0.5.14-rc.4" }],
|
|
743
|
+
run: (cmd) => {
|
|
744
|
+
if (cmd.includes("restart")) {
|
|
745
|
+
return { code: 1, stdout: "", stderr: "Unit parachute-hub.service not found." };
|
|
746
|
+
}
|
|
747
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
const res = await ensureHubVersionMatches({
|
|
751
|
+
installedVersion: INSTALLED,
|
|
752
|
+
port: 1939,
|
|
753
|
+
deps: f.deps,
|
|
754
|
+
readyPollMs: 0,
|
|
755
|
+
});
|
|
756
|
+
expect(res.outcome).toBe("restart-failed");
|
|
757
|
+
expect(res.messages.join("\n")).toContain("Unit parachute-hub.service not found.");
|
|
758
|
+
});
|
|
759
|
+
});
|