@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/commands/migrate.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
5
5
|
import { HUB_SVC } from "../hub-control.ts";
|
|
6
|
+
import {
|
|
7
|
+
type HubUnitDeps,
|
|
8
|
+
type HubUnitState,
|
|
9
|
+
defaultHubUnitDeps,
|
|
10
|
+
queryHubUnitState,
|
|
11
|
+
} from "../hub-unit.ts";
|
|
6
12
|
import { type AliveFn, defaultAlive, processState } from "../process-state.ts";
|
|
7
13
|
import { knownServices, shortNameForManifest } from "../service-spec.ts";
|
|
8
14
|
import { readManifestLenient } from "../services-manifest.ts";
|
|
@@ -335,6 +341,31 @@ export async function defaultPrompt(question: string): Promise<string> {
|
|
|
335
341
|
return answer;
|
|
336
342
|
}
|
|
337
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Hub-unit-state query seam for the archive guard (§7.3). Production uses the
|
|
346
|
+
* real `queryHubUnitState` over `defaultHubUnitDeps`; tests inject a stub so the
|
|
347
|
+
* unit-managed-hub-detected-as-running path is exercised without a live
|
|
348
|
+
* systemctl/launchctl. Returns the platform manager's view of the hub unit.
|
|
349
|
+
*/
|
|
350
|
+
export type HubUnitStateQuery = (deps: HubUnitDeps) => { state: HubUnitState };
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The §7.3 archive-guard fix: a hub unit is "running, leave it alone" when the
|
|
354
|
+
* platform manager reports it `active` or `activating`. `failed` / `inactive` /
|
|
355
|
+
* `no-unit` / `no-manager` / `unknown` are NOT treated as running — those mean
|
|
356
|
+
* the unit isn't actively holding state at this moment.
|
|
357
|
+
*
|
|
358
|
+
* Deliberately conservative on `unknown`: an unparseable manager response is
|
|
359
|
+
* NOT a license to archive (that would re-introduce the silent fail-open), but
|
|
360
|
+
* it's also not a clear "running" signal — so we fall through to the pidfile
|
|
361
|
+
* check (which catches a detached-era hub) rather than blanket-refusing on a
|
|
362
|
+
* transient manager hiccup. `active`/`activating` is the unambiguous unit-up
|
|
363
|
+
* signal that the pidfile-only guard missed.
|
|
364
|
+
*/
|
|
365
|
+
function hubUnitReportsRunning(state: HubUnitState): boolean {
|
|
366
|
+
return state === "active" || state === "activating";
|
|
367
|
+
}
|
|
368
|
+
|
|
338
369
|
/**
|
|
339
370
|
* Probe whether any managed service (or the hub) is currently running.
|
|
340
371
|
* Returns the list of short names that are live; an empty list means the
|
|
@@ -343,15 +374,42 @@ export async function defaultPrompt(question: string): Promise<string> {
|
|
|
343
374
|
* Reads services.json leniently so a malformed entry doesn't block the
|
|
344
375
|
* pre-flight (better to surface "running: vault" + a corrupt-entry warning
|
|
345
376
|
* elsewhere than to refuse migration on an unrelated parsing problem).
|
|
377
|
+
*
|
|
378
|
+
* §7.3 archive-guard fix: the hub liveness check is BOTH the pidfile
|
|
379
|
+
* (`processState(HUB_SVC)`, the detached-era signal) AND the platform manager
|
|
380
|
+
* (`queryHubUnitState`, the unit-era signal). A unit-managed hub writes NO
|
|
381
|
+
* pidfile, so `processState(HUB_SVC)` reports it not-running and the
|
|
382
|
+
* refuse-while-running guard would silently FAIL OPEN — `migrate` could archive
|
|
383
|
+
* `~/.parachute` out from under a live unit-managed hub. Querying the manager
|
|
384
|
+
* too closes that hole: an `active`/`activating` hub unit is correctly detected
|
|
385
|
+
* as running and the guard holds.
|
|
346
386
|
*/
|
|
347
387
|
export function listRunningServices(
|
|
348
388
|
configDir: string,
|
|
349
389
|
manifestPath: string,
|
|
350
390
|
alive: AliveFn,
|
|
391
|
+
hubUnitState: HubUnitStateQuery = queryHubUnitState,
|
|
392
|
+
hubUnitDeps: HubUnitDeps = defaultHubUnitDeps,
|
|
351
393
|
): string[] {
|
|
352
394
|
const running: string[] = [];
|
|
395
|
+
// Detached-era signal: the hub pidfile.
|
|
353
396
|
const hubState = processState(HUB_SVC, configDir, alive);
|
|
354
|
-
|
|
397
|
+
let hubRunning = hubState.status === "running";
|
|
398
|
+
// Unit-era signal (§7.3): the platform manager. A unit-managed hub has no
|
|
399
|
+
// pidfile, so without this it would fail open. Never throws — `queryHubUnitState`
|
|
400
|
+
// degrades to `unknown` on a manager hiccup; we only escalate to "running" on
|
|
401
|
+
// an unambiguous active/activating.
|
|
402
|
+
if (!hubRunning) {
|
|
403
|
+
try {
|
|
404
|
+
const unit = hubUnitState(hubUnitDeps);
|
|
405
|
+
if (hubUnitReportsRunning(unit.state)) hubRunning = true;
|
|
406
|
+
} catch {
|
|
407
|
+
// A manager-query failure must not crash the guard — fall through. The
|
|
408
|
+
// pidfile check already ran; treat an unqueryable manager as "no extra
|
|
409
|
+
// signal," neither forcing-running nor opening the gate.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (hubRunning) running.push(HUB_SVC);
|
|
355
413
|
let manifest: ReturnType<typeof readManifestLenient>;
|
|
356
414
|
try {
|
|
357
415
|
manifest = readManifestLenient(manifestPath);
|
|
@@ -385,6 +443,15 @@ export interface MigrateOpts {
|
|
|
385
443
|
* non-interactive guard without manipulating real fds.
|
|
386
444
|
*/
|
|
387
445
|
isTty?: boolean;
|
|
446
|
+
/**
|
|
447
|
+
* Test seam: the §7.3 platform-manager hub-unit-state query used by the
|
|
448
|
+
* archive guard. Production uses `queryHubUnitState`; tests inject a stub to
|
|
449
|
+
* exercise the unit-managed-hub-detected-as-running path without a live
|
|
450
|
+
* systemctl/launchctl.
|
|
451
|
+
*/
|
|
452
|
+
hubUnitState?: HubUnitStateQuery;
|
|
453
|
+
/** Test seam: the hub-unit deps passed to `hubUnitState` (default production). */
|
|
454
|
+
hubUnitDeps?: HubUnitDeps;
|
|
388
455
|
}
|
|
389
456
|
|
|
390
457
|
export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
@@ -397,6 +464,8 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
|
397
464
|
const yes = opts.yes ?? false;
|
|
398
465
|
const alive = opts.alive ?? defaultAlive;
|
|
399
466
|
const isTty = opts.isTty ?? Boolean(process.stdin.isTTY);
|
|
467
|
+
const hubUnitState = opts.hubUnitState ?? queryHubUnitState;
|
|
468
|
+
const hubUnitDeps = opts.hubUnitDeps ?? defaultHubUnitDeps;
|
|
400
469
|
|
|
401
470
|
// Refuse-while-running: archiving a path a live daemon owns can corrupt
|
|
402
471
|
// its state. Print the runners and bail before we read the directory.
|
|
@@ -404,7 +473,7 @@ export async function migrate(opts: MigrateOpts = {}): Promise<number> {
|
|
|
404
473
|
// operator may explicitly want to see what would move while things are
|
|
405
474
|
// up.
|
|
406
475
|
if (!dryRun) {
|
|
407
|
-
const running = listRunningServices(configDir, manifestPath, alive);
|
|
476
|
+
const running = listRunningServices(configDir, manifestPath, alive, hubUnitState, hubUnitDeps);
|
|
408
477
|
if (running.length > 0) {
|
|
409
478
|
log("parachute migrate: services are currently running — refusing to sweep:");
|
|
410
479
|
for (const short of running) log(` - ${short}`);
|
|
@@ -47,6 +47,62 @@ export interface BootedModule {
|
|
|
47
47
|
readonly reason?: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export interface SpawnReqShape {
|
|
51
|
+
short: string;
|
|
52
|
+
cmd: readonly string[];
|
|
53
|
+
cwd?: string;
|
|
54
|
+
env?: Record<string, string>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BuildSpawnRequestOpts {
|
|
58
|
+
/** Config dir ($PARACHUTE_HOME). Used to read the module's per-service `.env`. */
|
|
59
|
+
readonly configDir: string;
|
|
60
|
+
/** Canonical hub origin → child env `PARACHUTE_HUB_ORIGIN`. Skipped when absent. */
|
|
61
|
+
readonly hubOrigin?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Extra env merged on top of the derived env (PORT / .env / HUB_ORIGIN).
|
|
64
|
+
* Wins over all of them. Used by the API `start` handler's test seam +
|
|
65
|
+
* first-boot vault-name pass-through (`spawnEnv`). Empty/absent on the
|
|
66
|
+
* boot path.
|
|
67
|
+
*/
|
|
68
|
+
readonly extraEnv?: Record<string, string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the `Supervisor.start` request for a single module, identically on
|
|
73
|
+
* both the serve-boot path and the `POST /api/modules/:short/start` handler.
|
|
74
|
+
*
|
|
75
|
+
* Env layering (later wins):
|
|
76
|
+
* 1. `PORT` from the services.json `entry.port` — overrides hub's own PORT
|
|
77
|
+
* so supervised children honor their canonical port assignment
|
|
78
|
+
* (hub#356/#357).
|
|
79
|
+
* 2. per-service `.env` at `<configDir>/<short>/.env` — operator-configured
|
|
80
|
+
* values (e.g. scribe provider keys) override the bare PORT.
|
|
81
|
+
* 3. `PARACHUTE_HUB_ORIGIN` = `opts.hubOrigin` — anchors the child's `iss`
|
|
82
|
+
* expectation to the value hub mints with (hub#365).
|
|
83
|
+
* 4. `opts.extraEnv` — test seam / first-boot pass-through; wins last.
|
|
84
|
+
*
|
|
85
|
+
* `cwd` is set to `entry.installDir` when present (third-party modules ship
|
|
86
|
+
* relative startCmds that need it; first-party fallbacks use absolute / PATH
|
|
87
|
+
* binaries so cwd is a no-op there).
|
|
88
|
+
*/
|
|
89
|
+
export function buildModuleSpawnRequest(
|
|
90
|
+
short: string,
|
|
91
|
+
entry: ServiceEntry,
|
|
92
|
+
cmd: readonly string[],
|
|
93
|
+
opts: BuildSpawnRequestOpts,
|
|
94
|
+
): SpawnReqShape {
|
|
95
|
+
const fileEnv = readEnvFileValues(join(opts.configDir, short, ".env"));
|
|
96
|
+
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
97
|
+
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
98
|
+
if (opts.extraEnv) Object.assign(env, opts.extraEnv);
|
|
99
|
+
|
|
100
|
+
const req: SpawnReqShape = { short, cmd };
|
|
101
|
+
if (entry.installDir) req.cwd = entry.installDir;
|
|
102
|
+
if (Object.keys(env).length > 0) req.env = env;
|
|
103
|
+
return req;
|
|
104
|
+
}
|
|
105
|
+
|
|
50
106
|
/**
|
|
51
107
|
* Walk services.json, spawn every manageable module via the
|
|
52
108
|
* supervisor. Returns a per-module decision log so the caller can
|
|
@@ -92,32 +148,22 @@ export async function bootSupervisedModules(
|
|
|
92
148
|
continue;
|
|
93
149
|
}
|
|
94
150
|
|
|
95
|
-
// PORT override (hub#357 — third spawn site missed by hub#356)
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const env: Record<string, string> = { PORT: String(entry.port), ...fileEnv };
|
|
104
|
-
if (opts.hubOrigin) env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
105
|
-
|
|
106
|
-
const req: {
|
|
107
|
-
short: string;
|
|
108
|
-
cmd: readonly string[];
|
|
109
|
-
cwd?: string;
|
|
110
|
-
env?: Record<string, string>;
|
|
111
|
-
} = {
|
|
112
|
-
short,
|
|
113
|
-
cmd,
|
|
114
|
-
};
|
|
115
|
-
// Third-party modules ship clean relative startCmds — cwd:
|
|
116
|
-
// installDir makes them resolve. First-party fallbacks use
|
|
117
|
-
// absolute / PATH binaries so cwd is a no-op there.
|
|
118
|
-
if (entry.installDir) req.cwd = entry.installDir;
|
|
119
|
-
if (Object.keys(env).length > 0) req.env = env;
|
|
151
|
+
// PORT override (hub#357 — third spawn site missed by hub#356), per-service
|
|
152
|
+
// .env merge, and PARACHUTE_HUB_ORIGIN propagation (hub#365) all live in the
|
|
153
|
+
// shared `buildModuleSpawnRequest` so the `POST /api/modules/:short/start`
|
|
154
|
+
// handler builds an identical request (design 2026-06-01 §3.3).
|
|
155
|
+
const req = buildModuleSpawnRequest(short, entry, cmd, {
|
|
156
|
+
configDir: opts.configDir,
|
|
157
|
+
...(opts.hubOrigin !== undefined ? { hubOrigin: opts.hubOrigin } : {}),
|
|
158
|
+
});
|
|
120
159
|
|
|
160
|
+
// Serial await, not Promise.all: `supervisor.start` now carries a bounded
|
|
161
|
+
// post-spawn port-readiness gate (DEFAULT_START_READY_MS), so boot latency
|
|
162
|
+
// is the SUM of each slow-binding module's gate wait before `Bun.serve`
|
|
163
|
+
// comes up. Intentional — sequential boot keeps the start-error/install-card
|
|
164
|
+
// surface ordered and avoids a thundering-herd of port probes. Don't switch
|
|
165
|
+
// to `Promise.all` without accounting for the gate (it'd overlap the waits
|
|
166
|
+
// but also fire N concurrent readiness probes mid-boot).
|
|
121
167
|
await supervisor.start(req);
|
|
122
168
|
log(`[supervisor] ${short}: started (cmd=${cmd.join(" ")}).`);
|
|
123
169
|
results.push({ short, entryName: entry.name, status: "started" });
|