@openparachute/hub 0.6.2 → 0.6.3-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/README.md +87 -35
- package/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +690 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/expose-cloudflare.test.ts +163 -72
- package/src/__tests__/expose-off-auto.test.ts +26 -1
- package/src/__tests__/expose.test.ts +260 -240
- package/src/__tests__/hub-control.test.ts +1 -242
- package/src/__tests__/hub-server.test.ts +64 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +416 -1448
- package/src/__tests__/managed-unit.test.ts +575 -0
- package/src/__tests__/migrate-cutover.test.ts +840 -0
- package/src/__tests__/migrate-offer.test.ts +240 -0
- package/src/__tests__/migrate.test.ts +132 -0
- package/src/__tests__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +504 -0
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/__tests__/upgrade.test.ts +351 -5
- package/src/api-hub-upgrade.ts +384 -0
- package/src/api-hub.ts +2 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +97 -12
- package/src/cloudflare/connector-service.ts +117 -322
- package/src/commands/expose-cloudflare.ts +63 -71
- package/src/commands/expose-supervisor.ts +247 -0
- package/src/commands/expose.ts +59 -48
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +455 -816
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +535 -235
- package/src/commands/upgrade.ts +100 -2
- package/src/help.ts +128 -68
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +39 -0
- package/src/hub-unit.ts +735 -0
- package/src/hub-upgrade-helper.ts +306 -0
- package/src/hub-upgrade-mode.ts +209 -0
- package/src/hub-upgrade-status.ts +150 -0
- package/src/managed-unit.ts +692 -0
- package/src/migrate-offer.ts +186 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/process-state.ts +19 -3
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +389 -38
- package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
- package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
|
@@ -2,21 +2,11 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
6
|
-
type HubPortProbe,
|
|
7
|
-
type HubSpawner,
|
|
8
|
-
clearHubPort,
|
|
9
|
-
ensureHubRunning,
|
|
10
|
-
hubPortPath,
|
|
11
|
-
readHubPort,
|
|
12
|
-
stopHub,
|
|
13
|
-
writeHubPort,
|
|
14
|
-
} from "../hub-control.ts";
|
|
5
|
+
import { clearHubPort, hubPortPath, readHubPort, stopHub, writeHubPort } from "../hub-control.ts";
|
|
15
6
|
import { pidPath, readPid, writePid } from "../process-state.ts";
|
|
16
7
|
|
|
17
8
|
interface Harness {
|
|
18
9
|
configDir: string;
|
|
19
|
-
wellKnownDir: string;
|
|
20
10
|
cleanup: () => void;
|
|
21
11
|
}
|
|
22
12
|
|
|
@@ -24,32 +14,10 @@ function makeHarness(): Harness {
|
|
|
24
14
|
const dir = mkdtempSync(join(tmpdir(), "pcli-hub-ctl-"));
|
|
25
15
|
return {
|
|
26
16
|
configDir: dir,
|
|
27
|
-
wellKnownDir: join(dir, "well-known"),
|
|
28
17
|
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
29
18
|
};
|
|
30
19
|
}
|
|
31
20
|
|
|
32
|
-
interface SpawnerStub {
|
|
33
|
-
spawn: HubSpawner["spawn"];
|
|
34
|
-
calls: Array<{ cmd: readonly string[]; logFile: string }>;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function makeSpawner(pid: number): SpawnerStub {
|
|
38
|
-
const calls: Array<{ cmd: readonly string[]; logFile: string }> = [];
|
|
39
|
-
return {
|
|
40
|
-
calls,
|
|
41
|
-
spawn(cmd, logFile) {
|
|
42
|
-
calls.push({ cmd: [...cmd], logFile });
|
|
43
|
-
return pid;
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Probe that claims every port in a set is taken. */
|
|
49
|
-
function probeTaken(taken: Set<number>): HubPortProbe {
|
|
50
|
-
return async (p) => !taken.has(p);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
21
|
describe("port persistence helpers", () => {
|
|
54
22
|
test("writeHubPort + readHubPort round-trip", () => {
|
|
55
23
|
const h = makeHarness();
|
|
@@ -65,215 +33,6 @@ describe("port persistence helpers", () => {
|
|
|
65
33
|
});
|
|
66
34
|
});
|
|
67
35
|
|
|
68
|
-
describe("ensureHubRunning", () => {
|
|
69
|
-
test("spawns with --port + --well-known-dir, writes pid + port files", async () => {
|
|
70
|
-
const h = makeHarness();
|
|
71
|
-
try {
|
|
72
|
-
const spawner = makeSpawner(5555);
|
|
73
|
-
const result = await ensureHubRunning({
|
|
74
|
-
configDir: h.configDir,
|
|
75
|
-
wellKnownDir: h.wellKnownDir,
|
|
76
|
-
spawner,
|
|
77
|
-
alive: () => true,
|
|
78
|
-
probe: probeTaken(new Set()),
|
|
79
|
-
readyWaitMs: 0,
|
|
80
|
-
});
|
|
81
|
-
expect(result.started).toBe(true);
|
|
82
|
-
expect(result.pid).toBe(5555);
|
|
83
|
-
expect(result.port).toBe(1939);
|
|
84
|
-
expect(spawner.calls).toHaveLength(1);
|
|
85
|
-
const cmd = spawner.calls[0]?.cmd ?? [];
|
|
86
|
-
expect(cmd[0]).toBe("bun");
|
|
87
|
-
expect(cmd).toContain("--port");
|
|
88
|
-
expect(cmd).toContain("1939");
|
|
89
|
-
expect(cmd).toContain("--well-known-dir");
|
|
90
|
-
expect(cmd).toContain(h.wellKnownDir);
|
|
91
|
-
expect(readPid("hub", h.configDir)).toBe(5555);
|
|
92
|
-
expect(readHubPort(h.configDir)).toBe(1939);
|
|
93
|
-
} finally {
|
|
94
|
-
h.cleanup();
|
|
95
|
-
}
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("default fallback is 1 slot: fails when 1939 is taken (no pid resolvable)", async () => {
|
|
99
|
-
// Canonical layout pins hub to 1939. Walking up would collide with the
|
|
100
|
-
// next service's slot, so the default is to fail and let the user unblock
|
|
101
|
-
// the port — not quietly land somewhere else. When `lsof` can't name a
|
|
102
|
-
// PID (Windows, lsof missing, permission), fall back to the classic
|
|
103
|
-
// lsof-iTCP hint.
|
|
104
|
-
const h = makeHarness();
|
|
105
|
-
try {
|
|
106
|
-
const spawner = makeSpawner(7777);
|
|
107
|
-
await expect(
|
|
108
|
-
ensureHubRunning({
|
|
109
|
-
configDir: h.configDir,
|
|
110
|
-
wellKnownDir: h.wellKnownDir,
|
|
111
|
-
spawner,
|
|
112
|
-
alive: () => true,
|
|
113
|
-
probe: probeTaken(new Set([1939])),
|
|
114
|
-
pidOnPort: () => undefined,
|
|
115
|
-
readyWaitMs: 0,
|
|
116
|
-
}),
|
|
117
|
-
).rejects.toThrow(/lsof -iTCP:1939/);
|
|
118
|
-
} finally {
|
|
119
|
-
h.cleanup();
|
|
120
|
-
}
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
test("fails with orphan-PID hint when lsof names the holder (hub#287)", async () => {
|
|
124
|
-
// When `lsof` can resolve the orphan PID holding the canonical port,
|
|
125
|
-
// we surface it + recommend `parachute restart hub` (which now detects
|
|
126
|
-
// and kills the orphan) rather than the bare lsof hint.
|
|
127
|
-
const h = makeHarness();
|
|
128
|
-
try {
|
|
129
|
-
const spawner = makeSpawner(7777);
|
|
130
|
-
await expect(
|
|
131
|
-
ensureHubRunning({
|
|
132
|
-
configDir: h.configDir,
|
|
133
|
-
wellKnownDir: h.wellKnownDir,
|
|
134
|
-
spawner,
|
|
135
|
-
alive: () => true,
|
|
136
|
-
probe: probeTaken(new Set([1939])),
|
|
137
|
-
pidOnPort: () => 4242,
|
|
138
|
-
readyWaitMs: 0,
|
|
139
|
-
}),
|
|
140
|
-
).rejects.toThrow(/PID 4242 is already listening.*parachute restart hub/);
|
|
141
|
-
} finally {
|
|
142
|
-
h.cleanup();
|
|
143
|
-
}
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("fallback walks up when caller widens the range (debug/tests only)", async () => {
|
|
147
|
-
const h = makeHarness();
|
|
148
|
-
try {
|
|
149
|
-
const spawner = makeSpawner(7777);
|
|
150
|
-
const result = await ensureHubRunning({
|
|
151
|
-
configDir: h.configDir,
|
|
152
|
-
wellKnownDir: h.wellKnownDir,
|
|
153
|
-
spawner,
|
|
154
|
-
alive: () => true,
|
|
155
|
-
probe: probeTaken(new Set([1939, 1940])),
|
|
156
|
-
readyWaitMs: 0,
|
|
157
|
-
fallbackRange: 5,
|
|
158
|
-
});
|
|
159
|
-
expect(result.port).toBe(1941);
|
|
160
|
-
expect(readHubPort(h.configDir)).toBe(1941);
|
|
161
|
-
} finally {
|
|
162
|
-
h.cleanup();
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
test("idempotent: returns existing pid + port when hub is already running", async () => {
|
|
167
|
-
const h = makeHarness();
|
|
168
|
-
try {
|
|
169
|
-
writePid("hub", 12345, h.configDir);
|
|
170
|
-
writeHubPort(1944, h.configDir);
|
|
171
|
-
const spawner = makeSpawner(9999);
|
|
172
|
-
const result = await ensureHubRunning({
|
|
173
|
-
configDir: h.configDir,
|
|
174
|
-
wellKnownDir: h.wellKnownDir,
|
|
175
|
-
spawner,
|
|
176
|
-
alive: () => true,
|
|
177
|
-
probe: probeTaken(new Set()),
|
|
178
|
-
readyWaitMs: 0,
|
|
179
|
-
});
|
|
180
|
-
expect(result.started).toBe(false);
|
|
181
|
-
expect(result.pid).toBe(12345);
|
|
182
|
-
expect(result.port).toBe(1944);
|
|
183
|
-
expect(spawner.calls).toHaveLength(0);
|
|
184
|
-
} finally {
|
|
185
|
-
h.cleanup();
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
test("stale pid (process gone) is cleared and a fresh hub is spawned", async () => {
|
|
190
|
-
const h = makeHarness();
|
|
191
|
-
try {
|
|
192
|
-
writePid("hub", 99, h.configDir);
|
|
193
|
-
writeHubPort(1939, h.configDir);
|
|
194
|
-
const spawner = makeSpawner(100);
|
|
195
|
-
const result = await ensureHubRunning({
|
|
196
|
-
configDir: h.configDir,
|
|
197
|
-
wellKnownDir: h.wellKnownDir,
|
|
198
|
-
spawner,
|
|
199
|
-
alive: () => false,
|
|
200
|
-
probe: probeTaken(new Set()),
|
|
201
|
-
readyWaitMs: 0,
|
|
202
|
-
});
|
|
203
|
-
expect(result.started).toBe(true);
|
|
204
|
-
expect(result.pid).toBe(100);
|
|
205
|
-
expect(spawner.calls).toHaveLength(1);
|
|
206
|
-
} finally {
|
|
207
|
-
h.cleanup();
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
test("throws when no port in the fallback range is free", async () => {
|
|
212
|
-
const h = makeHarness();
|
|
213
|
-
try {
|
|
214
|
-
const spawner = makeSpawner(1);
|
|
215
|
-
await expect(
|
|
216
|
-
ensureHubRunning({
|
|
217
|
-
configDir: h.configDir,
|
|
218
|
-
wellKnownDir: h.wellKnownDir,
|
|
219
|
-
spawner,
|
|
220
|
-
alive: () => true,
|
|
221
|
-
probe: async () => false,
|
|
222
|
-
pidOnPort: () => undefined,
|
|
223
|
-
readyWaitMs: 0,
|
|
224
|
-
fallbackRange: 3,
|
|
225
|
-
}),
|
|
226
|
-
).rejects.toThrow(/unavailable/);
|
|
227
|
-
} finally {
|
|
228
|
-
h.cleanup();
|
|
229
|
-
}
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
test("skips reserved service ports during fallback (widened range)", async () => {
|
|
233
|
-
// Fallback is off by default (range=1). When a caller opens it up for
|
|
234
|
-
// debug, reservedPorts must still be honored so the hub never steals a
|
|
235
|
-
// registered service's slot even if the service isn't yet bound.
|
|
236
|
-
const h = makeHarness();
|
|
237
|
-
try {
|
|
238
|
-
const spawner = makeSpawner(3333);
|
|
239
|
-
const result = await ensureHubRunning({
|
|
240
|
-
configDir: h.configDir,
|
|
241
|
-
wellKnownDir: h.wellKnownDir,
|
|
242
|
-
spawner,
|
|
243
|
-
alive: () => true,
|
|
244
|
-
probe: probeTaken(new Set([1939])), // default port is held
|
|
245
|
-
reservedPorts: [1940], // vault's reservation
|
|
246
|
-
readyWaitMs: 0,
|
|
247
|
-
fallbackRange: 5,
|
|
248
|
-
});
|
|
249
|
-
// 1939 is taken, 1940 is reserved → we get 1941.
|
|
250
|
-
expect(result.port).toBe(1941);
|
|
251
|
-
expect(readHubPort(h.configDir)).toBe(1941);
|
|
252
|
-
} finally {
|
|
253
|
-
h.cleanup();
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
test("honors startPort override", async () => {
|
|
258
|
-
const h = makeHarness();
|
|
259
|
-
try {
|
|
260
|
-
const spawner = makeSpawner(2222);
|
|
261
|
-
const result = await ensureHubRunning({
|
|
262
|
-
configDir: h.configDir,
|
|
263
|
-
wellKnownDir: h.wellKnownDir,
|
|
264
|
-
spawner,
|
|
265
|
-
alive: () => true,
|
|
266
|
-
probe: probeTaken(new Set()),
|
|
267
|
-
readyWaitMs: 0,
|
|
268
|
-
startPort: 18080,
|
|
269
|
-
});
|
|
270
|
-
expect(result.port).toBe(18080);
|
|
271
|
-
} finally {
|
|
272
|
-
h.cleanup();
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
36
|
describe("stopHub", () => {
|
|
278
37
|
test("SIGTERMs running hub, clears pid + port", async () => {
|
|
279
38
|
const h = makeHarness();
|
|
@@ -4268,3 +4268,67 @@ describe("POST /account/vault-token/<name> — friend scoped mint (routed end-to
|
|
|
4268
4268
|
}
|
|
4269
4269
|
});
|
|
4270
4270
|
});
|
|
4271
|
+
|
|
4272
|
+
describe("hubFetch routing — /api/hub/upgrade (D4 SPA hub-upgrade)", () => {
|
|
4273
|
+
test("POST /api/hub/upgrade dispatches to the handler (401 without bearer, NOT 404)", async () => {
|
|
4274
|
+
const h = makeHarness();
|
|
4275
|
+
try {
|
|
4276
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4277
|
+
try {
|
|
4278
|
+
rotateSigningKey(db);
|
|
4279
|
+
await createUser(db, "owner", "pw");
|
|
4280
|
+
// No bearer → the handler's auth gate returns 401. A 404 would mean
|
|
4281
|
+
// the route never reached the handler (dispatch missing). The endpoint
|
|
4282
|
+
// does NOT require a supervisor — assert it works without one.
|
|
4283
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4284
|
+
req("/api/hub/upgrade", { method: "POST" }),
|
|
4285
|
+
);
|
|
4286
|
+
expect(res.status).toBe(401);
|
|
4287
|
+
} finally {
|
|
4288
|
+
db.close();
|
|
4289
|
+
}
|
|
4290
|
+
} finally {
|
|
4291
|
+
h.cleanup();
|
|
4292
|
+
}
|
|
4293
|
+
});
|
|
4294
|
+
|
|
4295
|
+
test("GET /api/hub/upgrade/status dispatches to the handler (401 without bearer)", async () => {
|
|
4296
|
+
const h = makeHarness();
|
|
4297
|
+
try {
|
|
4298
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4299
|
+
try {
|
|
4300
|
+
rotateSigningKey(db);
|
|
4301
|
+
await createUser(db, "owner", "pw");
|
|
4302
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4303
|
+
req("/api/hub/upgrade/status"),
|
|
4304
|
+
);
|
|
4305
|
+
expect(res.status).toBe(401);
|
|
4306
|
+
} finally {
|
|
4307
|
+
db.close();
|
|
4308
|
+
}
|
|
4309
|
+
} finally {
|
|
4310
|
+
h.cleanup();
|
|
4311
|
+
}
|
|
4312
|
+
});
|
|
4313
|
+
|
|
4314
|
+
test("GET on /api/hub/upgrade → 405 (POST-only) once authenticated path is reached", async () => {
|
|
4315
|
+
const h = makeHarness();
|
|
4316
|
+
try {
|
|
4317
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
4318
|
+
try {
|
|
4319
|
+
rotateSigningKey(db);
|
|
4320
|
+
await createUser(db, "owner", "pw");
|
|
4321
|
+
// GET on the POST-only upgrade route: no bearer means auth runs first
|
|
4322
|
+
// (401). The point of this case is the route is wired (not 404).
|
|
4323
|
+
const res = await hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath })(
|
|
4324
|
+
req("/api/hub/upgrade", { method: "GET" }),
|
|
4325
|
+
);
|
|
4326
|
+
expect(res.status).not.toBe(404);
|
|
4327
|
+
} finally {
|
|
4328
|
+
db.close();
|
|
4329
|
+
}
|
|
4330
|
+
} finally {
|
|
4331
|
+
h.cleanup();
|
|
4332
|
+
}
|
|
4333
|
+
});
|
|
4334
|
+
});
|