@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
package/src/hub-control.ts
CHANGED
|
@@ -1,43 +1,34 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:net";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { CONFIG_DIR } from "./config.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
type AliveFn,
|
|
10
|
-
clearPid,
|
|
11
|
-
defaultAlive,
|
|
12
|
-
ensureLogPath,
|
|
13
|
-
readPid,
|
|
14
|
-
runDir,
|
|
15
|
-
writePid,
|
|
16
|
-
} from "./process-state.ts";
|
|
17
|
-
import { WELL_KNOWN_DIR } from "./well-known.ts";
|
|
6
|
+
import { type AliveFn, clearPid, defaultAlive, readPid, runDir } from "./process-state.ts";
|
|
18
7
|
|
|
19
8
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
9
|
+
* Hub identity + port helpers + `stopHub` (the detached-stop path the migrate
|
|
10
|
+
* cutover still uses). The hub is *not* a user-facing service (not in
|
|
11
|
+
* services.json). Phase 5b retired the detached `ensureHubRunning` bringup — the
|
|
12
|
+
* hub now runs under a platform unit (`parachute serve`, see `hub-unit.ts` /
|
|
13
|
+
* `managed-unit.ts`); `init` brings it up via `installAndStartHubUnit`. This
|
|
14
|
+
* file keeps `stopHub` (used by `migrate` to stop a legacy detached hub during
|
|
15
|
+
* the cutover) + the canonical-port readers/writers.
|
|
24
16
|
*
|
|
25
17
|
* The hub lives under `svc = "hub"` in the process-state world, so its PID,
|
|
26
|
-
* logs, and runtime files land at `~/.parachute/hub/{run,logs}
|
|
27
|
-
* every other managed service.
|
|
18
|
+
* logs, and runtime files land at `~/.parachute/hub/{run,logs}/…`.
|
|
28
19
|
*/
|
|
29
20
|
|
|
30
21
|
export const HUB_SVC = "hub";
|
|
31
22
|
export const HUB_PACKAGE = "@openparachute/hub";
|
|
32
23
|
export const HUB_DEFAULT_PORT = 1939;
|
|
33
24
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
25
|
+
* The container `PARACHUTE_HOME` — the Render Blueprint (and the shared Fly
|
|
26
|
+
* image) pins this exact path. `PARACHUTE_HOME === CONTAINER_HOME` is the most
|
|
27
|
+
* reliable container-mode signal the hub has. Single source of truth so the
|
|
28
|
+
* `/api/hub` status surface (`api-hub.ts`) and the in-place-vs-redeploy
|
|
29
|
+
* detection (`hub-upgrade-mode.ts`) can't drift on the magic path.
|
|
37
30
|
*/
|
|
38
|
-
export const
|
|
39
|
-
|
|
40
|
-
const HUB_SERVER_PATH = fileURLToPath(new URL("./hub-server.ts", import.meta.url));
|
|
31
|
+
export const CONTAINER_HOME = "/parachute";
|
|
41
32
|
|
|
42
33
|
export function hubPortPath(configDir: string = CONFIG_DIR): string {
|
|
43
34
|
return join(runDir(HUB_SVC, configDir), `${HUB_SVC}.port`);
|
|
@@ -62,29 +53,6 @@ export function clearHubPort(configDir: string = CONFIG_DIR): void {
|
|
|
62
53
|
if (existsSync(p)) rmSync(p, { force: true });
|
|
63
54
|
}
|
|
64
55
|
|
|
65
|
-
/**
|
|
66
|
-
* Seam over `Bun.spawn`, mirroring the lifecycle Spawner — tests never want
|
|
67
|
-
* to actually fork a process. The real implementation opens the log file,
|
|
68
|
-
* pipes stdout+stderr into it, and detaches.
|
|
69
|
-
*/
|
|
70
|
-
export interface HubSpawner {
|
|
71
|
-
spawn(cmd: readonly string[], logFile: string): number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export const defaultHubSpawner: HubSpawner = {
|
|
75
|
-
spawn(cmd, logFile) {
|
|
76
|
-
const fd = openSync(logFile, "a");
|
|
77
|
-
// Inherit env so the hub child process sees PATH, HOME, PARACHUTE_HOME,
|
|
78
|
-
// etc. Bun.spawn defaults to empty env — see api-modules-ops.ts.
|
|
79
|
-
const proc = Bun.spawn([...cmd], {
|
|
80
|
-
stdio: ["ignore", fd, fd],
|
|
81
|
-
env: process.env,
|
|
82
|
-
});
|
|
83
|
-
proc.unref();
|
|
84
|
-
return proc.pid;
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
|
|
88
56
|
export type HubPortProbe = (port: number) => Promise<boolean>;
|
|
89
57
|
export type KillFn = (pid: number, signal: NodeJS.Signals | number) => void;
|
|
90
58
|
export type SleepFn = (ms: number) => Promise<void>;
|
|
@@ -151,126 +119,19 @@ export const defaultPortProbe: HubPortProbe = (port) =>
|
|
|
151
119
|
server.listen(port, "127.0.0.1");
|
|
152
120
|
});
|
|
153
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Ensure-hub options shape. Phase 5b retired the detached `ensureHubRunning`
|
|
124
|
+
* spawn this used to drive; `init`'s `defaultEnsureHubViaUnit` (hub-unit-backed)
|
|
125
|
+
* reuses this opts shape for its parameter signature. Only `configDir` /
|
|
126
|
+
* `startPort` / `log` are read on that path.
|
|
127
|
+
*/
|
|
154
128
|
export interface EnsureHubOpts {
|
|
155
129
|
configDir?: string;
|
|
156
|
-
|
|
157
|
-
spawner?: HubSpawner;
|
|
158
|
-
alive?: AliveFn;
|
|
159
|
-
probe?: HubPortProbe;
|
|
160
|
-
sleep?: SleepFn;
|
|
161
|
-
/**
|
|
162
|
-
* Look up the PID listening on `port`. Production default uses `lsof`;
|
|
163
|
-
* tests inject a stub. Used to report which orphan process is holding
|
|
164
|
-
* the canonical hub port when the bind probe fails — so the operator
|
|
165
|
-
* has a concrete PID to point `parachute restart hub` at, not just an
|
|
166
|
-
* "unavailable" error. See hub#287.
|
|
167
|
-
*/
|
|
168
|
-
pidOnPort?: PidOnPortFn;
|
|
169
|
-
/** Starting port (default 1939). First port that probe()s true wins. */
|
|
130
|
+
/** Starting port (default 1939). */
|
|
170
131
|
startPort?: number;
|
|
171
|
-
/** How many ports to try before giving up (default 20). */
|
|
172
|
-
fallbackRange?: number;
|
|
173
|
-
/**
|
|
174
|
-
* Ports to skip during fallback — typically service ports from services.json
|
|
175
|
-
* so the hub doesn't steal a port a registered service will bind later.
|
|
176
|
-
* Probed ports that happen to be listening still fail the probe on their own;
|
|
177
|
-
* this guards the case where the service isn't running yet.
|
|
178
|
-
*/
|
|
179
|
-
reservedPorts?: Iterable<number>;
|
|
180
|
-
/** How long to wait after spawn before claiming readiness. Short — tests set to 0. */
|
|
181
|
-
readyWaitMs?: number;
|
|
182
|
-
/**
|
|
183
|
-
* Public origin to use as the OAuth `iss` claim and as the base for the
|
|
184
|
-
* authorization-server metadata document. Forwarded to the hub server as
|
|
185
|
-
* `--issuer <url>`. When omitted, the hub falls back to the request's own
|
|
186
|
-
* origin — fine for loopback testing, wrong under tailscale where the
|
|
187
|
-
* request origin is `http://127.0.0.1:<port>`.
|
|
188
|
-
*/
|
|
189
|
-
issuer?: string;
|
|
190
132
|
log?: (line: string) => void;
|
|
191
133
|
}
|
|
192
134
|
|
|
193
|
-
export interface EnsureHubResult {
|
|
194
|
-
pid: number;
|
|
195
|
-
port: number;
|
|
196
|
-
/** True when this call spawned the hub; false when it was already running. */
|
|
197
|
-
started: boolean;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export async function ensureHubRunning(opts: EnsureHubOpts = {}): Promise<EnsureHubResult> {
|
|
201
|
-
const configDir = opts.configDir ?? CONFIG_DIR;
|
|
202
|
-
const wellKnownDir = opts.wellKnownDir ?? WELL_KNOWN_DIR;
|
|
203
|
-
const spawner = opts.spawner ?? defaultHubSpawner;
|
|
204
|
-
const alive = opts.alive ?? defaultAlive;
|
|
205
|
-
const probe = opts.probe ?? defaultPortProbe;
|
|
206
|
-
const sleep = opts.sleep ?? defaultSleep;
|
|
207
|
-
const pidOnPort = opts.pidOnPort ?? defaultPidOnPort;
|
|
208
|
-
const startPort = opts.startPort ?? HUB_DEFAULT_PORT;
|
|
209
|
-
const fallbackRange = opts.fallbackRange ?? HUB_PORT_FALLBACK_RANGE;
|
|
210
|
-
const reservedPorts = new Set(opts.reservedPorts ?? []);
|
|
211
|
-
const readyWaitMs = opts.readyWaitMs ?? 150;
|
|
212
|
-
const log = opts.log ?? (() => {});
|
|
213
|
-
|
|
214
|
-
const existingPid = readPid(HUB_SVC, configDir);
|
|
215
|
-
const existingPort = readHubPort(configDir);
|
|
216
|
-
if (existingPid !== undefined && alive(existingPid) && existingPort !== undefined) {
|
|
217
|
-
return { pid: existingPid, port: existingPort, started: false };
|
|
218
|
-
}
|
|
219
|
-
// Any stale state (pid without live process, port without pid) — wipe.
|
|
220
|
-
if (existingPid !== undefined) clearPid(HUB_SVC, configDir);
|
|
221
|
-
clearHubPort(configDir);
|
|
222
|
-
|
|
223
|
-
let chosenPort: number | undefined;
|
|
224
|
-
for (let i = 0; i < fallbackRange; i++) {
|
|
225
|
-
const candidate = startPort + i;
|
|
226
|
-
if (reservedPorts.has(candidate)) continue;
|
|
227
|
-
if (await probe(candidate)) {
|
|
228
|
-
chosenPort = candidate;
|
|
229
|
-
break;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
if (chosenPort === undefined) {
|
|
233
|
-
// Port is held by *something*. If we can name the PID (lsof on macOS /
|
|
234
|
-
// Linux), point the operator at `parachute restart hub` — which now
|
|
235
|
-
// detects and kills the orphan even when hub.port is missing or stale
|
|
236
|
-
// (hub#287). Without a PID, fall back to the classic lsof hint.
|
|
237
|
-
const range =
|
|
238
|
-
fallbackRange === 1 ? `${startPort}` : `${startPort}..${startPort + fallbackRange - 1}`;
|
|
239
|
-
const orphanPid = fallbackRange === 1 ? pidOnPort(startPort) : undefined;
|
|
240
|
-
if (orphanPid !== undefined) {
|
|
241
|
-
throw new Error(
|
|
242
|
-
`hub: port ${range} unavailable — PID ${orphanPid} is already listening. Run \`parachute restart hub\` to clean up and restart, or \`kill ${orphanPid}\` then \`parachute start hub\`.`,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
throw new Error(
|
|
246
|
-
`hub: port ${range} unavailable. Run \`lsof -iTCP:${startPort}\` to find what's using it, or pass --hub-port to override.`,
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const logFile = ensureLogPath(HUB_SVC, configDir);
|
|
251
|
-
const cmd = [
|
|
252
|
-
"bun",
|
|
253
|
-
HUB_SERVER_PATH,
|
|
254
|
-
"--port",
|
|
255
|
-
String(chosenPort),
|
|
256
|
-
"--well-known-dir",
|
|
257
|
-
wellKnownDir,
|
|
258
|
-
"--db",
|
|
259
|
-
hubDbPath(configDir),
|
|
260
|
-
...(opts.issuer ? ["--issuer", opts.issuer] : []),
|
|
261
|
-
];
|
|
262
|
-
const pid = spawner.spawn(cmd, logFile);
|
|
263
|
-
writePid(HUB_SVC, pid, configDir);
|
|
264
|
-
writeHubPort(chosenPort, configDir);
|
|
265
|
-
|
|
266
|
-
// A tiny grace period so the subsequent `tailscale serve` proxy target
|
|
267
|
-
// isn't pointed at a not-yet-listening socket.
|
|
268
|
-
if (readyWaitMs > 0) await sleep(readyWaitMs);
|
|
269
|
-
|
|
270
|
-
log(`hub listening on 127.0.0.1:${chosenPort} (pid ${pid}); logs: ${logFile}`);
|
|
271
|
-
return { pid, port: chosenPort, started: true };
|
|
272
|
-
}
|
|
273
|
-
|
|
274
135
|
export interface StopHubOpts {
|
|
275
136
|
configDir?: string;
|
|
276
137
|
kill?: KillFn;
|
package/src/hub-server.ts
CHANGED
|
@@ -47,9 +47,13 @@
|
|
|
47
47
|
* /admin/vault-admin-token/<n> (GET) → per-vault bearer mint (cookie-gated)
|
|
48
48
|
* /api/me (GET) → who-am-I (session+CSRF or hasSession:false)
|
|
49
49
|
* /api/hub (GET) → hub version + uptime + install-source (host:admin)
|
|
50
|
+
* /api/hub/upgrade (POST) → SPA-driven hub self-upgrade → 202 + detached helper (host:admin, §5.3/D4)
|
|
51
|
+
* /api/hub/upgrade/status (GET) → poll the on-disk hub-upgrade status (host:admin)
|
|
50
52
|
* /api/modules (GET) → curated + installed module catalog (host:auth)
|
|
51
53
|
* /api/modules/channel (PUT) → operator channel toggle (host:admin)
|
|
52
54
|
* /api/modules/:short/install (POST) → bun add + spawn (async op)
|
|
55
|
+
* /api/modules/:short/start (POST) → supervisor.start of an installed module (sync)
|
|
56
|
+
* /api/modules/:short/stop (POST) → supervisor.stop (sync)
|
|
53
57
|
* /api/modules/:short/restart (POST) → supervisor restart (sync)
|
|
54
58
|
* /api/modules/:short/upgrade (POST) → bun add @<channel> + restart (async op)
|
|
55
59
|
* /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
|
|
@@ -134,6 +138,7 @@ import {
|
|
|
134
138
|
handleAccountChangePasswordPost,
|
|
135
139
|
handleAccountHomeGet,
|
|
136
140
|
} from "./api-account.ts";
|
|
141
|
+
import { handleHubUpgrade, handleHubUpgradeStatus } from "./api-hub-upgrade.ts";
|
|
137
142
|
import { handleApiHub } from "./api-hub.ts";
|
|
138
143
|
import { handleApiMe } from "./api-me.ts";
|
|
139
144
|
import { handleApiMintToken } from "./api-mint-token.ts";
|
|
@@ -141,8 +146,11 @@ import { handleApiModulesConfig, parseModulesConfigPath } from "./api-modules-co
|
|
|
141
146
|
import {
|
|
142
147
|
getDefaultOperationsRegistry,
|
|
143
148
|
handleInstall,
|
|
149
|
+
handleLogs,
|
|
144
150
|
handleOperationGet,
|
|
145
151
|
handleRestart,
|
|
152
|
+
handleStart,
|
|
153
|
+
handleStop,
|
|
146
154
|
handleUninstall,
|
|
147
155
|
handleUpgrade,
|
|
148
156
|
parseModulesPath,
|
|
@@ -1737,6 +1745,31 @@ export function hubFetch(
|
|
|
1737
1745
|
return handleApiMe(req, { db: getDb() });
|
|
1738
1746
|
}
|
|
1739
1747
|
|
|
1748
|
+
// SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4). Dedicated
|
|
1749
|
+
// endpoint — the hub is NOT a supervised module (no /api/modules/hub/*),
|
|
1750
|
+
// so it gets its own route. Checked BEFORE the `/api/hub` exact match
|
|
1751
|
+
// below (and the `/api/modules/*` switch) so the more-specific path wins.
|
|
1752
|
+
// Does NOT require a supervisor: the hub upgrades itself via a detached
|
|
1753
|
+
// helper, not the supervisor. Host-admin gated inside the handler (reuses
|
|
1754
|
+
// the same validateAccessToken + scope check the module-ops API uses); the
|
|
1755
|
+
// channel param is a closed enum (rc|latest) — no injection surface.
|
|
1756
|
+
if (pathname === "/api/hub/upgrade") {
|
|
1757
|
+
if (!getDb) return dbNotConfigured();
|
|
1758
|
+
return handleHubUpgrade(req, {
|
|
1759
|
+
db: getDb(),
|
|
1760
|
+
issuer: oauthDeps(req).issuer,
|
|
1761
|
+
configDir: CONFIG_DIR,
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
if (pathname === "/api/hub/upgrade/status") {
|
|
1765
|
+
if (!getDb) return dbNotConfigured();
|
|
1766
|
+
return handleHubUpgradeStatus(req, {
|
|
1767
|
+
db: getDb(),
|
|
1768
|
+
issuer: oauthDeps(req).issuer,
|
|
1769
|
+
configDir: CONFIG_DIR,
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1740
1773
|
// Hub version + uptime + install-source — drives the admin SPA's
|
|
1741
1774
|
// version badge (hub#348). Bearer-gated on `parachute:host:admin`
|
|
1742
1775
|
// (same as the rest of the operator-only admin surface).
|
|
@@ -1865,8 +1898,14 @@ export function hubFetch(
|
|
|
1865
1898
|
switch (match.rest) {
|
|
1866
1899
|
case "install":
|
|
1867
1900
|
return handleInstall(req, match.short, opsDeps);
|
|
1901
|
+
case "start":
|
|
1902
|
+
return handleStart(req, match.short, opsDeps);
|
|
1903
|
+
case "stop":
|
|
1904
|
+
return handleStop(req, match.short, opsDeps);
|
|
1868
1905
|
case "restart":
|
|
1869
1906
|
return handleRestart(req, match.short, opsDeps);
|
|
1907
|
+
case "logs":
|
|
1908
|
+
return handleLogs(req, match.short, opsDeps);
|
|
1870
1909
|
case "upgrade":
|
|
1871
1910
|
return handleUpgrade(req, match.short, opsDeps);
|
|
1872
1911
|
case "uninstall":
|