@openparachute/hub 0.6.2 → 0.6.3-rc.1
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__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +423 -0
- package/src/__tests__/managed-unit.test.ts +575 -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 +569 -0
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +14 -4
- package/src/cloudflare/connector-service.ts +117 -322
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +366 -38
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +596 -49
- package/src/hub-server.ts +11 -0
- package/src/hub-unit.ts +735 -0
- package/src/managed-unit.ts +674 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +360 -14
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
|
*
|
package/src/api-modules.ts
CHANGED
|
@@ -42,8 +42,13 @@ import { FIRST_PARTY_FALLBACKS, KNOWN_MODULES } from "./service-spec.ts";
|
|
|
42
42
|
// still required) and the latter for vault/scribe/runner (post-FALLBACK
|
|
43
43
|
// retirement, hub#310). The local helper hides the split from the rest of
|
|
44
44
|
// this file.
|
|
45
|
-
import {
|
|
46
|
-
|
|
45
|
+
import {
|
|
46
|
+
type UiSubUnit,
|
|
47
|
+
type UiSubUnitStatus,
|
|
48
|
+
readManifest,
|
|
49
|
+
readManifestLenient,
|
|
50
|
+
} from "./services-manifest.ts";
|
|
51
|
+
import type { ModuleStartError, ModuleState, Supervisor } from "./supervisor.ts";
|
|
47
52
|
|
|
48
53
|
/**
|
|
49
54
|
* Resolve a curated module to the display + install bootstrap data the
|
|
@@ -174,6 +179,16 @@ interface ModuleWireShape {
|
|
|
174
179
|
latest_version: string | null;
|
|
175
180
|
supervisor_status: ModuleState["status"] | null;
|
|
176
181
|
pid: number | null;
|
|
182
|
+
/**
|
|
183
|
+
* Structured supervisor start-failure detail (§6.5 / §6.4), when the
|
|
184
|
+
* supervisor recorded one for this module — a preflight `MissingDependencyError`
|
|
185
|
+
* or the alive-but-never-bound shape (hub#487). Mirrors the services.json
|
|
186
|
+
* `lastStartError` the detached path persists, so `parachute status` and the
|
|
187
|
+
* SPA keep the SAME friendly missing-dependency surface (#188) whether a
|
|
188
|
+
* module was started via the supervisor or the detached path. Null when the
|
|
189
|
+
* module started cleanly (or the hub is in pidfile/CLI mode with no supervisor).
|
|
190
|
+
*/
|
|
191
|
+
supervisor_start_error: ModuleStartError | null;
|
|
177
192
|
/**
|
|
178
193
|
* The path on disk where the module is installed, if known. Surfaces
|
|
179
194
|
* the BUN_INSTALL or bun-link install location for operator debug —
|
|
@@ -477,6 +492,7 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
|
|
|
477
492
|
latest_version: latestByShort.get(short) ?? null,
|
|
478
493
|
supervisor_status: state?.status ?? null,
|
|
479
494
|
pid: state?.pid ?? null,
|
|
495
|
+
supervisor_start_error: state?.startError ?? null,
|
|
480
496
|
install_dir: installed?.installDir ?? null,
|
|
481
497
|
uis: toUisWireShape(installed?.uis),
|
|
482
498
|
management_url: managementUrlByShort.get(short) ?? null,
|
package/src/cli.ts
CHANGED
|
@@ -454,7 +454,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
454
454
|
console.log(statusHelp());
|
|
455
455
|
return 0;
|
|
456
456
|
}
|
|
457
|
-
|
|
457
|
+
// Pass an empty `supervisor` block so `status` takes the Phase 3c
|
|
458
|
+
// dual-dispatch: on a box with a hub unit installed it reads the platform
|
|
459
|
+
// manager + the running supervisor; on a legacy detached box it falls back
|
|
460
|
+
// to the pidfile readout (design §6.4). Tests drive the seams directly.
|
|
461
|
+
return await status({ supervisor: {} });
|
|
458
462
|
|
|
459
463
|
case "expose": {
|
|
460
464
|
const hubExtract = extractHubOrigin(rest);
|
|
@@ -624,7 +628,13 @@ async function main(argv: string[]): Promise<number> {
|
|
|
624
628
|
console.error(`parachute start: ${hubExtract.error}`);
|
|
625
629
|
return 1;
|
|
626
630
|
}
|
|
627
|
-
|
|
631
|
+
// `supervisor: {}` opts into the Phase 3b dual-dispatch: on a box with a
|
|
632
|
+
// hub unit installed, drive the running supervisor; on a legacy detached
|
|
633
|
+
// box (no unit), fall through to the unchanged detached path (design §3.3).
|
|
634
|
+
const startOpts = {
|
|
635
|
+
supervisor: {},
|
|
636
|
+
...(hubExtract.hubOrigin ? { hubOrigin: hubExtract.hubOrigin } : {}),
|
|
637
|
+
};
|
|
628
638
|
return await start(hubExtract.rest[0], startOpts);
|
|
629
639
|
}
|
|
630
640
|
|
|
@@ -633,7 +643,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
633
643
|
console.log(stopHelp());
|
|
634
644
|
return 0;
|
|
635
645
|
}
|
|
636
|
-
return await stop(rest[0]);
|
|
646
|
+
return await stop(rest[0], { supervisor: {} });
|
|
637
647
|
}
|
|
638
648
|
|
|
639
649
|
case "restart": {
|
|
@@ -641,7 +651,7 @@ async function main(argv: string[]): Promise<number> {
|
|
|
641
651
|
console.log(restartHelp());
|
|
642
652
|
return 0;
|
|
643
653
|
}
|
|
644
|
-
return await restart(rest[0]);
|
|
654
|
+
return await restart(rest[0], { supervisor: {} });
|
|
645
655
|
}
|
|
646
656
|
|
|
647
657
|
case "upgrade": {
|