@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
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `POST /api/hub/upgrade` + `GET /api/hub/upgrade/status` — the SPA-driven
|
|
3
|
+
* hub self-upgrade (design 2026-06-01 §5 item 3 / §5.3 / D4).
|
|
4
|
+
*
|
|
5
|
+
* ── WHY A DEDICATED ENDPOINT (not /api/modules/hub/*) ──────────────────────
|
|
6
|
+
*
|
|
7
|
+
* The hub is NOT a supervised module — `CURATED_MODULES` rejects `hub`, so
|
|
8
|
+
* `parseModulesPath("/api/modules/hub/upgrade")` returns undefined and the
|
|
9
|
+
* module-ops switch never reaches a hub case. The hub needs its OWN endpoint
|
|
10
|
+
* because the constraint is unique: the hub can't restart itself synchronously
|
|
11
|
+
* (the request dies with the old process before it can report success). So:
|
|
12
|
+
*
|
|
13
|
+
* 1. Validate strictly + respond **202** immediately with
|
|
14
|
+
* `{ operation_id, target_version, channel, mode }`.
|
|
15
|
+
* 2. Spawn a **detached one-shot helper** (`detached:true`+`unref()`) that
|
|
16
|
+
* OUTLIVES the hub: it rewrites the binary then drives the platform
|
|
17
|
+
* restart. The request handler does NOT do the rewrite/restart inline.
|
|
18
|
+
* 3. The SPA polls `GET /api/hub/upgrade/status` + `/health` + `/api/hub`
|
|
19
|
+
* version until the new binary answers.
|
|
20
|
+
*
|
|
21
|
+
* ── SECURITY ───────────────────────────────────────────────────────────────
|
|
22
|
+
*
|
|
23
|
+
* - **Strict host-admin gate** — reuses the EXACT `authorize` path module-ops
|
|
24
|
+
* uses (`parachute:host:admin`, validated against the hub DB + issuer). A
|
|
25
|
+
* `:auth`-only token gets 403.
|
|
26
|
+
* - **Closed-enum channel** — the optional `channel` is `"rc" | "latest"` ONLY
|
|
27
|
+
* (default: auto-detected from the current version). This value flows toward
|
|
28
|
+
* `bun add -g @openparachute/hub@<channel>`, so it MUST be a closed enum,
|
|
29
|
+
* never free input. There is no shell-string interpolation: the rewrite goes
|
|
30
|
+
* through `upgrade.ts`'s `UpgradeRunner` (argv arrays), so even the enum is
|
|
31
|
+
* defense-in-depth, not the only barrier.
|
|
32
|
+
*
|
|
33
|
+
* ── REDEPLOY-REQUIRED SHORT-CIRCUIT (§5.3) ─────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* When the in-place-vs-redeploy detection (`hub-upgrade-mode.ts`) returns
|
|
36
|
+
* `redeploy-required` (image-pinned container), we DO NOT spawn a helper and
|
|
37
|
+
* DO NOT do a misleading no-op rewrite that's lost on the next container
|
|
38
|
+
* restart. We respond 202 with `mode: "redeploy-required"` + seed a status
|
|
39
|
+
* file in the `redeploy-required` phase; the SPA renders "redeploy from your
|
|
40
|
+
* platform dashboard" instead of a false "upgraded."
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import type { Database } from "bun:sqlite";
|
|
44
|
+
import { randomUUID } from "node:crypto";
|
|
45
|
+
import { dirname } from "node:path";
|
|
46
|
+
import { fileURLToPath } from "node:url";
|
|
47
|
+
import { defaultRunner, detectChannel } from "./commands/upgrade.ts";
|
|
48
|
+
import { HUB_PACKAGE } from "./hub-control.ts";
|
|
49
|
+
import { type HubUpgradeMode, detectHubUpgradeMode } from "./hub-upgrade-mode.ts";
|
|
50
|
+
import {
|
|
51
|
+
type HubUpgradeStatus,
|
|
52
|
+
readHubUpgradeStatus,
|
|
53
|
+
writeHubUpgradeStatus,
|
|
54
|
+
} from "./hub-upgrade-status.ts";
|
|
55
|
+
import { detectHubInstallSource } from "./install-source.ts";
|
|
56
|
+
import { validateAccessToken } from "./jwt-sign.ts";
|
|
57
|
+
|
|
58
|
+
/** Same scope module-ops gates on — destructive host-admin action. */
|
|
59
|
+
export const HUB_UPGRADE_REQUIRED_SCOPE = "parachute:host:admin";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Non-terminal phases — an upgrade in any of these is still in flight, so a
|
|
63
|
+
* second `POST /api/hub/upgrade` is rejected 409. The terminal phases
|
|
64
|
+
* (`failed`, `redeploy-required`, and the SPA-inferred `succeeded`) are NOT
|
|
65
|
+
* here, so a new upgrade may start once the prior op reached one of them.
|
|
66
|
+
*/
|
|
67
|
+
const IN_FLIGHT_PHASES = new Set<HubUpgradeStatus["phase"]>(["pending", "running", "restarting"]);
|
|
68
|
+
|
|
69
|
+
export interface SpawnHelperArgs {
|
|
70
|
+
operationId: string;
|
|
71
|
+
channel: "rc" | "latest";
|
|
72
|
+
configDir: string;
|
|
73
|
+
/** Hub PID for the container graceful-exit path (undefined on unit-managed). */
|
|
74
|
+
hubPid?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ApiHubUpgradeDeps {
|
|
78
|
+
db: Database;
|
|
79
|
+
/** Hub origin — validates the bearer's `iss`. */
|
|
80
|
+
issuer: string;
|
|
81
|
+
/** PARACHUTE_HOME — where the status file is read/written. */
|
|
82
|
+
configDir: string;
|
|
83
|
+
/**
|
|
84
|
+
* Spawn the detached one-shot helper. Production wires
|
|
85
|
+
* `spawnDetachedHubUpgradeHelper`; tests inject a recorder so no real process
|
|
86
|
+
* is forked and the handler-does-not-rewrite-inline invariant is asserted.
|
|
87
|
+
*/
|
|
88
|
+
spawnHelper?: (args: SpawnHelperArgs) => void;
|
|
89
|
+
/**
|
|
90
|
+
* Resolve `<pkg>@<channel>` → concrete version for the 202 `target_version`
|
|
91
|
+
* (best-effort; null on a registry miss). Production uses `npm view` via the
|
|
92
|
+
* upgrade runner; tests stub it.
|
|
93
|
+
*/
|
|
94
|
+
resolveTargetVersion?: (channel: "rc" | "latest") => Promise<string | null>;
|
|
95
|
+
/** Read the hub's current version. Production reads the nearest package.json. */
|
|
96
|
+
currentVersion?: () => string;
|
|
97
|
+
/** Override the install-source dir + env for mode detection (test seam). */
|
|
98
|
+
hubSrcDir?: string;
|
|
99
|
+
env?: Record<string, string | undefined>;
|
|
100
|
+
/**
|
|
101
|
+
* Override the in-place-vs-redeploy detection (test seam). Production runs
|
|
102
|
+
* `detectHubUpgradeMode` against the real install source + env; tests inject
|
|
103
|
+
* a fixed result so the redeploy-required short-circuit can be exercised
|
|
104
|
+
* without faking a Render image's on-disk layout.
|
|
105
|
+
*/
|
|
106
|
+
detectMode?: typeof detectHubUpgradeMode;
|
|
107
|
+
/** Override "now" (test seam). */
|
|
108
|
+
now?: () => Date;
|
|
109
|
+
/**
|
|
110
|
+
* Read the current on-disk status (for the 409 in-flight guard). Defaults to
|
|
111
|
+
* `readHubUpgradeStatus`; injectable so a test can drive the guard without
|
|
112
|
+
* seeding a real file.
|
|
113
|
+
*/
|
|
114
|
+
readStatus?: (configDir: string) => HubUpgradeStatus | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ParsedBody {
|
|
118
|
+
channel?: "rc" | "latest";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function authorize(req: Request, deps: ApiHubUpgradeDeps): Promise<Response | undefined> {
|
|
122
|
+
const auth = req.headers.get("authorization");
|
|
123
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
124
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
125
|
+
}
|
|
126
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
127
|
+
if (!bearer) return jsonError(401, "unauthenticated", "empty bearer token");
|
|
128
|
+
try {
|
|
129
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
130
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
131
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
132
|
+
}
|
|
133
|
+
const scopes =
|
|
134
|
+
typeof validated.payload.scope === "string"
|
|
135
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
136
|
+
: [];
|
|
137
|
+
if (!scopes.includes(HUB_UPGRADE_REQUIRED_SCOPE)) {
|
|
138
|
+
return jsonError(
|
|
139
|
+
403,
|
|
140
|
+
"insufficient_scope",
|
|
141
|
+
`bearer token lacks ${HUB_UPGRADE_REQUIRED_SCOPE}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Parse + STRICTLY validate the optional `{ channel }` body. A present-but-
|
|
153
|
+
* non-enum channel is a hard 400 (the operator typed something the rewrite
|
|
154
|
+
* can't honor — don't silently fall back). Empty / non-JSON / absent body all
|
|
155
|
+
* resolve to "auto-detect," signalled by `channel: undefined`.
|
|
156
|
+
*/
|
|
157
|
+
async function parseBody(req: Request): Promise<ParsedBody | Response> {
|
|
158
|
+
if (!req.headers.get("content-type")?.includes("application/json")) return {};
|
|
159
|
+
let body: { channel?: unknown };
|
|
160
|
+
try {
|
|
161
|
+
body = (await req.json()) as { channel?: unknown };
|
|
162
|
+
} catch {
|
|
163
|
+
return {}; // empty / unparseable — auto-detect
|
|
164
|
+
}
|
|
165
|
+
if (body && body.channel !== undefined) {
|
|
166
|
+
if (body.channel !== "rc" && body.channel !== "latest") {
|
|
167
|
+
return jsonError(
|
|
168
|
+
400,
|
|
169
|
+
"invalid_channel",
|
|
170
|
+
`channel must be "rc" or "latest" (got ${JSON.stringify(body.channel)})`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return { channel: body.channel };
|
|
174
|
+
}
|
|
175
|
+
return {};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Default current-version reader: climb from the running source dir to the
|
|
180
|
+
* nearest package.json. Mirrors `api-hub.ts`'s `readHubVersion`.
|
|
181
|
+
*/
|
|
182
|
+
function defaultCurrentVersion(hubSrcDir: string): string {
|
|
183
|
+
// Lazy import to avoid a hot dependency; reuse the install-source detector's
|
|
184
|
+
// version read (it already climbs to the nearest package.json).
|
|
185
|
+
const source = detectHubInstallSource(hubSrcDir);
|
|
186
|
+
return source.livePackageVersion ?? "unknown";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Default target-version resolver via `npm view <pkg>@<channel> version`. */
|
|
190
|
+
async function defaultResolveTargetVersion(channel: "rc" | "latest"): Promise<string | null> {
|
|
191
|
+
const { code, stdout } = await defaultRunner.capture([
|
|
192
|
+
"npm",
|
|
193
|
+
"view",
|
|
194
|
+
`${HUB_PACKAGE}@${channel}`,
|
|
195
|
+
"version",
|
|
196
|
+
]);
|
|
197
|
+
if (code !== 0) return null;
|
|
198
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
199
|
+
return lines[lines.length - 1] ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* POST /api/hub/upgrade — accept + dispatch the detached helper. Returns 202
|
|
204
|
+
* with `{ operation_id, target_version, channel, mode }`. Never blocks on the
|
|
205
|
+
* upgrade itself.
|
|
206
|
+
*/
|
|
207
|
+
export async function handleHubUpgrade(req: Request, deps: ApiHubUpgradeDeps): Promise<Response> {
|
|
208
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
209
|
+
const authFail = await authorize(req, deps);
|
|
210
|
+
if (authFail) return authFail;
|
|
211
|
+
|
|
212
|
+
const parsed = await parseBody(req);
|
|
213
|
+
if (parsed instanceof Response) return parsed;
|
|
214
|
+
|
|
215
|
+
// ── 409 in-flight guard ────────────────────────────────────────────────────
|
|
216
|
+
// The status file is single-slot (one hub, one upgrade). If a prior upgrade
|
|
217
|
+
// is still in a non-terminal phase (pending/running/restarting), starting a
|
|
218
|
+
// SECOND would overwrite its operation_id — and a still-running first helper
|
|
219
|
+
// would then either clobber the new op's status or be silently superseded.
|
|
220
|
+
// The SPA disables the button while upgrading, but the API must guard
|
|
221
|
+
// server-side too (a second tab, a stale page, a scripted POST). Reject with
|
|
222
|
+
// 409 unless the slot is free (no file) or the prior op reached a terminal
|
|
223
|
+
// phase (failed / redeploy-required / succeeded).
|
|
224
|
+
const readStatus = deps.readStatus ?? readHubUpgradeStatus;
|
|
225
|
+
const existing = readStatus(deps.configDir);
|
|
226
|
+
if (existing && IN_FLIGHT_PHASES.has(existing.phase)) {
|
|
227
|
+
return jsonError(
|
|
228
|
+
409,
|
|
229
|
+
"upgrade_in_flight",
|
|
230
|
+
`a hub upgrade is already ${existing.phase} (operation ${existing.operation_id}); poll GET /api/hub/upgrade/status or wait for it to finish before starting another`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const hubSrcDir = deps.hubSrcDir ?? dirname(fileURLToPath(import.meta.url));
|
|
235
|
+
const env = deps.env ?? process.env;
|
|
236
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
237
|
+
|
|
238
|
+
const currentVersion = (deps.currentVersion ?? (() => defaultCurrentVersion(hubSrcDir)))();
|
|
239
|
+
// Auto-detect the channel from the current version when not explicitly set —
|
|
240
|
+
// an rc operator stays on rc (governance rule 2), a stable operator on latest.
|
|
241
|
+
const channel: "rc" | "latest" =
|
|
242
|
+
parsed.channel ?? (currentVersion !== "unknown" ? detectChannel(currentVersion) : "latest");
|
|
243
|
+
|
|
244
|
+
// §5.3 detection — does an in-place rewrite persist, or is the hub image-pinned?
|
|
245
|
+
const detectMode = deps.detectMode ?? detectHubUpgradeMode;
|
|
246
|
+
const modeResult = detectMode({ env, hubSrcDir });
|
|
247
|
+
const mode: HubUpgradeMode = modeResult.mode;
|
|
248
|
+
|
|
249
|
+
const resolveTarget = deps.resolveTargetVersion ?? defaultResolveTargetVersion;
|
|
250
|
+
const targetVersion = await resolveTarget(channel).catch(() => null);
|
|
251
|
+
|
|
252
|
+
const operationId = randomUUID();
|
|
253
|
+
|
|
254
|
+
// ── redeploy-required: do NOT spawn a helper / do NOT no-op-rewrite (§5.3) ──
|
|
255
|
+
if (mode === "redeploy-required") {
|
|
256
|
+
const status: HubUpgradeStatus = {
|
|
257
|
+
operation_id: operationId,
|
|
258
|
+
phase: "redeploy-required",
|
|
259
|
+
mode,
|
|
260
|
+
current_version: currentVersion,
|
|
261
|
+
target_version: targetVersion,
|
|
262
|
+
channel,
|
|
263
|
+
log: [modeResult.reason],
|
|
264
|
+
started_at: now.toISOString(),
|
|
265
|
+
finished_at: now.toISOString(),
|
|
266
|
+
};
|
|
267
|
+
writeHubUpgradeStatus(deps.configDir, status);
|
|
268
|
+
return accepted({ operationId, targetVersion, channel, mode });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── in-place: seed the status file, then spawn the detached helper ─────────
|
|
272
|
+
const status: HubUpgradeStatus = {
|
|
273
|
+
operation_id: operationId,
|
|
274
|
+
phase: "pending",
|
|
275
|
+
mode,
|
|
276
|
+
current_version: currentVersion,
|
|
277
|
+
target_version: targetVersion,
|
|
278
|
+
channel,
|
|
279
|
+
log: [`accepted hub-upgrade (${mode}); ${modeResult.reason}`],
|
|
280
|
+
started_at: now.toISOString(),
|
|
281
|
+
};
|
|
282
|
+
writeHubUpgradeStatus(deps.configDir, status);
|
|
283
|
+
|
|
284
|
+
// On a container the helper must signal the (current) hub to exit so the
|
|
285
|
+
// runtime re-runs CMD on the new binary — pass our own PID. On a unit-managed
|
|
286
|
+
// box the manager owns the restart, so no pid is needed (the helper's
|
|
287
|
+
// `upgrade` dual-dispatch calls `restartHubUnit`).
|
|
288
|
+
const spawn = deps.spawnHelper ?? spawnDetachedHubUpgradeHelper;
|
|
289
|
+
const spawnArgs: SpawnHelperArgs = { operationId, channel, configDir: deps.configDir };
|
|
290
|
+
if (modeResult.source === "container") spawnArgs.hubPid = process.pid;
|
|
291
|
+
// CRITICAL: spawn-and-return. The handler does NOT await a rewrite/restart —
|
|
292
|
+
// that's the helper's job, and it must outlive this process.
|
|
293
|
+
spawn(spawnArgs);
|
|
294
|
+
|
|
295
|
+
return accepted({ operationId, targetVersion, channel, mode });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* GET /api/hub/upgrade/status — poll the on-disk status file. Matches the
|
|
300
|
+
* module-ops operation-poll shape (status + log + timestamps) so the SPA's
|
|
301
|
+
* polling code reads consistently. 404 when no upgrade has been started.
|
|
302
|
+
*/
|
|
303
|
+
export async function handleHubUpgradeStatus(
|
|
304
|
+
req: Request,
|
|
305
|
+
deps: ApiHubUpgradeDeps,
|
|
306
|
+
): Promise<Response> {
|
|
307
|
+
if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
|
|
308
|
+
const authFail = await authorize(req, deps);
|
|
309
|
+
if (authFail) return authFail;
|
|
310
|
+
|
|
311
|
+
const status = readHubUpgradeStatus(deps.configDir);
|
|
312
|
+
if (!status) {
|
|
313
|
+
return jsonError(404, "not_found", "no hub upgrade has been started");
|
|
314
|
+
}
|
|
315
|
+
return new Response(JSON.stringify(status), {
|
|
316
|
+
status: 200,
|
|
317
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Production helper-spawn: `bun <abs hub-upgrade-helper.ts> --op … --channel …
|
|
323
|
+
* --config-dir … [--hub-pid …]`, **detached + unref'd** so it OUTLIVES the hub
|
|
324
|
+
* it's about to restart. This is the one legitimate detached process in the
|
|
325
|
+
* unified model (§5.3) — everything else stays supervised.
|
|
326
|
+
*
|
|
327
|
+
* `detached: true` puts it in its own process group + session leader, so the
|
|
328
|
+
* hub exiting does not deliver a death signal to it; `unref()` removes it from
|
|
329
|
+
* the parent's event-loop ref-count so the hub can exit without waiting on it.
|
|
330
|
+
*/
|
|
331
|
+
export function spawnDetachedHubUpgradeHelper(args: SpawnHelperArgs): void {
|
|
332
|
+
const helperPath = fileURLToPath(new URL("./hub-upgrade-helper.ts", import.meta.url));
|
|
333
|
+
const cmd = [
|
|
334
|
+
"bun",
|
|
335
|
+
helperPath,
|
|
336
|
+
"--op",
|
|
337
|
+
args.operationId,
|
|
338
|
+
"--channel",
|
|
339
|
+
args.channel,
|
|
340
|
+
"--config-dir",
|
|
341
|
+
args.configDir,
|
|
342
|
+
];
|
|
343
|
+
if (args.hubPid !== undefined) {
|
|
344
|
+
cmd.push("--hub-pid", String(args.hubPid));
|
|
345
|
+
}
|
|
346
|
+
const proc = Bun.spawn(cmd, {
|
|
347
|
+
// Own process group/session so the hub's exit doesn't SIGHUP/SIGTERM us —
|
|
348
|
+
// we MUST outlive the hub to drive its restart.
|
|
349
|
+
detached: true,
|
|
350
|
+
// Inherit env so the helper's `bun add -g` / git sees PATH, BUN_INSTALL,
|
|
351
|
+
// PARACHUTE_HOME, TMPDIR — same rationale as commands/upgrade.ts's runner.
|
|
352
|
+
env: process.env,
|
|
353
|
+
// Detach stdio so the helper isn't tied to the hub's pipes (which close on
|
|
354
|
+
// hub exit). The helper records progress to the status FILE, not stdout.
|
|
355
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
356
|
+
});
|
|
357
|
+
// Remove from the parent's ref-count so the hub can exit cleanly while the
|
|
358
|
+
// helper keeps running.
|
|
359
|
+
proc.unref();
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function accepted(body: {
|
|
363
|
+
operationId: string;
|
|
364
|
+
targetVersion: string | null;
|
|
365
|
+
channel: "rc" | "latest";
|
|
366
|
+
mode: HubUpgradeMode;
|
|
367
|
+
}): Response {
|
|
368
|
+
return new Response(
|
|
369
|
+
JSON.stringify({
|
|
370
|
+
operation_id: body.operationId,
|
|
371
|
+
target_version: body.targetVersion,
|
|
372
|
+
channel: body.channel,
|
|
373
|
+
mode: body.mode,
|
|
374
|
+
}),
|
|
375
|
+
{ status: 202, headers: { "content-type": "application/json", "cache-control": "no-store" } },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function jsonError(status: number, error: string, description: string): Response {
|
|
380
|
+
return new Response(JSON.stringify({ error, error_description: description }), {
|
|
381
|
+
status,
|
|
382
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
|
383
|
+
});
|
|
384
|
+
}
|
package/src/api-hub.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
|
|
6
6
|
import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
|
|
7
|
+
import { CONTAINER_HOME } from "./hub-control.ts";
|
|
7
8
|
import { detectHubInstallSource } from "./install-source.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -120,7 +121,7 @@ export async function handleApiHub(req: Request, deps: ApiHubDeps): Promise<Resp
|
|
|
120
121
|
// would label this `bun-linked` (the image runs from /app/src, not bun
|
|
121
122
|
// globals), which is technically true but misleading for the operator —
|
|
122
123
|
// "container" is what they actually want to see.
|
|
123
|
-
const isContainer = env.PARACHUTE_HOME ===
|
|
124
|
+
const isContainer = env.PARACHUTE_HOME === CONTAINER_HOME;
|
|
124
125
|
|
|
125
126
|
const body: HubStatusResponse = {
|
|
126
127
|
version,
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { MissingDependencyError, type MissingDependencyWire } from "@openparachu
|
|
|
39
39
|
import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
|
|
40
40
|
import { isLinked as defaultIsLinked } from "./bun-link.ts";
|
|
41
41
|
import { PARACHUTE_INSTALL_CHANNEL_ENV } from "./commands/install.ts";
|
|
42
|
+
import { buildModuleSpawnRequest } from "./commands/serve-boot.ts";
|
|
42
43
|
import { getModuleInstallChannel } from "./hub-settings.ts";
|
|
43
44
|
import { validateAccessToken } from "./jwt-sign.ts";
|
|
44
45
|
import { readModuleManifest } from "./module-manifest.ts";
|
|
@@ -456,6 +457,11 @@ async function spawnSupervised(
|
|
|
456
457
|
// anchors the child's iss expectation to the same value hub mints with.
|
|
457
458
|
//
|
|
458
459
|
// `deps.spawnEnv` still wins (test seam + first-boot vault-name pass-through).
|
|
460
|
+
//
|
|
461
|
+
// No per-service `.env` here, by design: the install path runs before the
|
|
462
|
+
// operator has had a chance to write `configDir/<short>/.env`, so install
|
|
463
|
+
// spawns with install-env only. The per-service `.env` is layered in by
|
|
464
|
+
// `buildModuleSpawnRequest` (serve-boot.ts) on the next `boot` or `start`.
|
|
459
465
|
const childEnv: Record<string, string> = {
|
|
460
466
|
PORT: String(entry.port),
|
|
461
467
|
...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
|
|
@@ -713,6 +719,115 @@ export async function runInstall(
|
|
|
713
719
|
registry.update(opId, { status: "succeeded" }, `${short} installed + spawned (pid ${state.pid})`);
|
|
714
720
|
}
|
|
715
721
|
|
|
722
|
+
/**
|
|
723
|
+
* POST /api/modules/:short/start — synchronous.
|
|
724
|
+
*
|
|
725
|
+
* A pure `supervisor.start(req)` of an ALREADY-INSTALLED module, using
|
|
726
|
+
* the same boot-derived SpawnRequest `bootSupervisedModules` builds
|
|
727
|
+
* (PORT / per-service .env / PARACHUTE_HUB_ORIGIN injection via the
|
|
728
|
+
* shared `buildModuleSpawnRequest`). This is the §3.3 endpoint Phase 3
|
|
729
|
+
* will repoint `parachute start <svc>` onto.
|
|
730
|
+
*
|
|
731
|
+
* Explicitly NOT an install: it does not run `bun add -g`, seed
|
|
732
|
+
* services.json, stamp installDir, or refresh well-known. If the module
|
|
733
|
+
* isn't in services.json (never installed) it returns 400 `not_installed`
|
|
734
|
+
* with an actionable hint — not a silent install. If services.json
|
|
735
|
+
* carries the row but no startCmd is resolvable (CLI-only module,
|
|
736
|
+
* unreadable module.json), it returns 422 `no_start_cmd`.
|
|
737
|
+
*
|
|
738
|
+
* Synchronous like restart: `supervisor.start` returns the new state in
|
|
739
|
+
* the body; no operation poll needed. Idempotent — starting an
|
|
740
|
+
* already-running module returns its existing state (the supervisor's
|
|
741
|
+
* own idempotent `start`).
|
|
742
|
+
*/
|
|
743
|
+
export async function handleStart(
|
|
744
|
+
req: Request,
|
|
745
|
+
short: CuratedModuleShort,
|
|
746
|
+
deps: ApiModulesOpsDeps,
|
|
747
|
+
): Promise<Response> {
|
|
748
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
749
|
+
const authFail = await authorize(req, deps);
|
|
750
|
+
if (authFail) return authFail;
|
|
751
|
+
|
|
752
|
+
const spec = specFor(short);
|
|
753
|
+
|
|
754
|
+
// Pure-spawn precondition: the module must already be installed
|
|
755
|
+
// (present in services.json). `start` never installs — that's the
|
|
756
|
+
// install endpoint's job, which is far heavier (bun add -g / seed /
|
|
757
|
+
// stamp). A missing row is an operator error worth a clear message.
|
|
758
|
+
const entry = findService(spec.manifestName, deps.manifestPath);
|
|
759
|
+
if (!entry) {
|
|
760
|
+
return jsonError(
|
|
761
|
+
400,
|
|
762
|
+
"not_installed",
|
|
763
|
+
`${short} is not installed (no services.json entry) — install it first via POST /api/modules/${short}/install`,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// KNOWN_MODULES shorts (vault / scribe / runner): module.json is the
|
|
768
|
+
// canonical source for startCmd. Re-resolve from
|
|
769
|
+
// `<installDir>/.parachute/module.json` when installDir is stamped so the
|
|
770
|
+
// module is authoritative for its own spawn cmd — mirroring runInstall's
|
|
771
|
+
// post-bun-add re-resolve. Falls back to the imperative `extras.startCmd`
|
|
772
|
+
// carried by `spec` when installDir is absent or module.json is unreadable.
|
|
773
|
+
let spawnSpec: ServiceSpec = spec;
|
|
774
|
+
if (entry.installDir && KNOWN_MODULES[short]) {
|
|
775
|
+
const resolved = await resolveSpawnSpec(short, entry.installDir);
|
|
776
|
+
if (resolved) spawnSpec = resolved;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const cmd = spawnSpec.startCmd?.(entry);
|
|
780
|
+
if (!cmd || cmd.length === 0) {
|
|
781
|
+
return jsonError(
|
|
782
|
+
422,
|
|
783
|
+
"no_start_cmd",
|
|
784
|
+
`${short} has no resolvable startCmd (CLI-only module, or <installDir>/.parachute/module.json missing a startCmd)`,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Build the SpawnRequest identically to the serve-boot path so `start`
|
|
789
|
+
// and boot produce the same child env (PORT / .env / HUB_ORIGIN). The
|
|
790
|
+
// test-seam / first-boot `spawnEnv` rides the shared helper's `extraEnv`
|
|
791
|
+
// and wins last, matching `spawnSupervised`'s precedence.
|
|
792
|
+
const spawnReq = buildModuleSpawnRequest(short, entry, cmd, {
|
|
793
|
+
configDir: deps.configDir,
|
|
794
|
+
...(deps.issuer ? { hubOrigin: deps.issuer } : {}),
|
|
795
|
+
...(deps.spawnEnv ? { extraEnv: deps.spawnEnv } : {}),
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
const state = await deps.supervisor.start(spawnReq);
|
|
799
|
+
return jsonOk({ short, state });
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* POST /api/modules/:short/stop — synchronous.
|
|
804
|
+
*
|
|
805
|
+
* A pure `supervisor.stop(short)` — SIGTERM the child, await exit (with
|
|
806
|
+
* SIGKILL escalation), mark `stopped`. Distinct from uninstall, which
|
|
807
|
+
* stops-then-removes the services.json row + `bun remove`s the package.
|
|
808
|
+
* `stop` leaves the module installed; it's the §3.3 endpoint Phase 3
|
|
809
|
+
* will repoint `parachute stop <svc>` onto.
|
|
810
|
+
*
|
|
811
|
+
* Idempotent: stopping a not-supervised module returns 200 with a
|
|
812
|
+
* `stopped: false` flag (nothing to stop) rather than erroring — the
|
|
813
|
+
* caller's intent ("ensure it's not running") is already satisfied.
|
|
814
|
+
*/
|
|
815
|
+
export async function handleStop(
|
|
816
|
+
req: Request,
|
|
817
|
+
short: CuratedModuleShort,
|
|
818
|
+
deps: ApiModulesOpsDeps,
|
|
819
|
+
): Promise<Response> {
|
|
820
|
+
if (req.method !== "POST") return jsonError(405, "method_not_allowed", "use POST");
|
|
821
|
+
const authFail = await authorize(req, deps);
|
|
822
|
+
if (authFail) return authFail;
|
|
823
|
+
|
|
824
|
+
const state = await deps.supervisor.stop(short);
|
|
825
|
+
if (!state) {
|
|
826
|
+
return jsonOk({ short, stopped: false });
|
|
827
|
+
}
|
|
828
|
+
return jsonOk({ short, stopped: true, state });
|
|
829
|
+
}
|
|
830
|
+
|
|
716
831
|
/**
|
|
717
832
|
* POST /api/modules/:short/restart — synchronous.
|
|
718
833
|
*
|
|
@@ -741,6 +856,112 @@ export async function handleRestart(
|
|
|
741
856
|
return jsonOk({ short, state });
|
|
742
857
|
}
|
|
743
858
|
|
|
859
|
+
/**
|
|
860
|
+
* GET /api/modules/:short/logs — synchronous.
|
|
861
|
+
*
|
|
862
|
+
* Serves the supervisor's bounded per-module ring buffer (§6.5): the most
|
|
863
|
+
* recent output the child wrote, INCLUDING the boot/crash lines that happened
|
|
864
|
+
* before the caller connected — which a naive connect-time SSE tap would lose
|
|
865
|
+
* (and which are "likely the most important one — the exit cause"). This is
|
|
866
|
+
* the §6 endpoint Phase 3 will repoint `parachute logs <svc>` onto.
|
|
867
|
+
*
|
|
868
|
+
* Returns the buffer as both a joined `text` blob and a `lines` array (the CLI
|
|
869
|
+
* tail wants the blob; a structured consumer wants lines). A module that isn't
|
|
870
|
+
* supervised returns 404 `not_supervised`, matching the `restart` handler's
|
|
871
|
+
* error contract for the same state — the caller can fall through to `start`.
|
|
872
|
+
*
|
|
873
|
+
* `?follow=1` is accepted as a best-effort streaming tap: we replay the buffer
|
|
874
|
+
* first (the must-have), then stream subsequent lines as `text/plain` chunks.
|
|
875
|
+
* The buffer replay is what captures the crash cause; the follow tail is the
|
|
876
|
+
* nice-to-have. Without `follow`, it's a one-shot JSON snapshot.
|
|
877
|
+
*/
|
|
878
|
+
export async function handleLogs(
|
|
879
|
+
req: Request,
|
|
880
|
+
short: CuratedModuleShort,
|
|
881
|
+
deps: ApiModulesOpsDeps,
|
|
882
|
+
): Promise<Response> {
|
|
883
|
+
if (req.method !== "GET") return jsonError(405, "method_not_allowed", "use GET");
|
|
884
|
+
const authFail = await authorize(req, deps);
|
|
885
|
+
if (authFail) return authFail;
|
|
886
|
+
|
|
887
|
+
const lines = deps.supervisor.logs(short);
|
|
888
|
+
if (lines === undefined) {
|
|
889
|
+
// Same shape + status as `restart` for a not-supervised module, so the
|
|
890
|
+
// CLI client can treat both identically (fall through to `start`).
|
|
891
|
+
return jsonError(
|
|
892
|
+
404,
|
|
893
|
+
"not_supervised",
|
|
894
|
+
`${short} is not currently supervised — install or start it first`,
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const follow = new URL(req.url).searchParams.get("follow");
|
|
899
|
+
if (follow === "1" || follow === "true") {
|
|
900
|
+
return streamModuleLogs(short, lines, deps);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// One-shot snapshot: the buffered lines as both a joined blob + the array.
|
|
904
|
+
return jsonOk({ short, lines, text: lines.join("") });
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Best-effort follow stream (§6.5 nice-to-have). Replays the buffered lines
|
|
909
|
+
* (the must-have — captures the boot/crash cause) then forwards subsequent
|
|
910
|
+
* output as `text/plain` chunks by subscribing to a tee of the supervisor's
|
|
911
|
+
* live tap. The buffer replay is guaranteed; the live tail is opportunistic
|
|
912
|
+
* (it ends when the client disconnects or the module stops). Implemented via
|
|
913
|
+
* a polling diff of the ring buffer so it stays decoupled from `pumpLines`'
|
|
914
|
+
* internal sink and needs no new supervisor wiring.
|
|
915
|
+
*/
|
|
916
|
+
function streamModuleLogs(
|
|
917
|
+
short: CuratedModuleShort,
|
|
918
|
+
initial: string[],
|
|
919
|
+
deps: ApiModulesOpsDeps,
|
|
920
|
+
): Response {
|
|
921
|
+
const encoder = new TextEncoder();
|
|
922
|
+
let lastLen = initial.length;
|
|
923
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
924
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
925
|
+
start(controller) {
|
|
926
|
+
// Replay the buffered lines first — the boot/crash cause.
|
|
927
|
+
for (const line of initial) controller.enqueue(encoder.encode(line));
|
|
928
|
+
timer = setInterval(() => {
|
|
929
|
+
const current = deps.supervisor.logs(short);
|
|
930
|
+
if (current === undefined) {
|
|
931
|
+
// Module went away (uninstalled / never-supervised) — end the stream.
|
|
932
|
+
if (timer) clearInterval(timer);
|
|
933
|
+
try {
|
|
934
|
+
controller.close();
|
|
935
|
+
} catch {
|
|
936
|
+
// already closed
|
|
937
|
+
}
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
// The ring buffer may have dropped old lines off the front; only
|
|
941
|
+
// forward genuinely-new tail lines. If the buffer shrank below our
|
|
942
|
+
// cursor (eviction), reset to its current length to avoid replaying.
|
|
943
|
+
// Limitation: new lines written during a heavy eviction burst (a chatty
|
|
944
|
+
// module overflowing the 64KiB cap between two polls) may be skipped in
|
|
945
|
+
// the live tail — use the one-shot snapshot (no ?follow) for crash investigation.
|
|
946
|
+
if (current.length < lastLen) lastLen = current.length;
|
|
947
|
+
for (let i = lastLen; i < current.length; i++) {
|
|
948
|
+
const line = current[i];
|
|
949
|
+
if (line !== undefined) controller.enqueue(encoder.encode(line));
|
|
950
|
+
}
|
|
951
|
+
lastLen = current.length;
|
|
952
|
+
}, 500);
|
|
953
|
+
},
|
|
954
|
+
cancel() {
|
|
955
|
+
// Stop polling when the consumer disconnects.
|
|
956
|
+
if (timer) clearInterval(timer);
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
return new Response(stream, {
|
|
960
|
+
status: 200,
|
|
961
|
+
headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
|
|
744
965
|
/**
|
|
745
966
|
* POST /api/modules/:short/upgrade — async.
|
|
746
967
|
*
|