@openparachute/hub 0.6.1 → 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__/account-home-ui.test.ts +34 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +272 -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/account-home-ui.ts +4 -1
- 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 +273 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +143 -10
- 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": {
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ManagedUnit,
|
|
3
|
+
type ManagedUnitDeps,
|
|
4
|
+
type ManagedUnitInstallResult,
|
|
5
|
+
type ManagedUnitMessages,
|
|
6
|
+
type ManagedUnitRemoveResult,
|
|
7
|
+
type ServiceCommandResult,
|
|
8
|
+
defaultManagedUnitDeps,
|
|
9
|
+
installManagedUnit,
|
|
10
|
+
launchdPlistPathForLabel,
|
|
11
|
+
removeManagedUnit,
|
|
12
|
+
renderManagedLaunchdPlist,
|
|
13
|
+
renderManagedSystemdUnit,
|
|
14
|
+
systemdUnitPathForName,
|
|
15
|
+
} from "../managed-unit.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reboot-persistent cloudflared connector.
|
|
19
|
+
*
|
|
20
|
+
* Pre-0.6.2 `parachute expose public --cloudflare` spawned the connector as a
|
|
21
|
+
* bare detached background process (`Bun.spawn(...).unref()`), which dies on
|
|
22
|
+
* reboot — the operator had to re-run the expose command every time the box
|
|
23
|
+
* restarted. This module installs a per-tunnel OS service that runs the same
|
|
24
|
+
* `cloudflared tunnel --config <path> run` command on boot, so the connector
|
|
25
|
+
* survives reboots.
|
|
26
|
+
*
|
|
27
|
+
* Platform shapes:
|
|
28
|
+
* - macOS → a launchd LaunchAgent plist at
|
|
29
|
+
* `~/Library/LaunchAgents/computer.parachute.cloudflared.<tunnelName>.plist`
|
|
30
|
+
* (RunAtLoad + KeepAlive), bootstrapped into the per-user GUI domain. No
|
|
31
|
+
* sudo: a LaunchAgent runs as the logged-in user.
|
|
32
|
+
* - Linux (non-root) → a systemd *user* unit at
|
|
33
|
+
* `~/.config/systemd/user/parachute-cloudflared-<tunnelName>.service`,
|
|
34
|
+
* `systemctl --user enable --now`, plus a best-effort
|
|
35
|
+
* `loginctl enable-linger $USER` so the unit runs without an active login.
|
|
36
|
+
* - Linux (root) → a systemd *system* unit at
|
|
37
|
+
* `/etc/systemd/system/parachute-cloudflared-<tunnelName>.service`,
|
|
38
|
+
* `systemctl enable --now`. No linger needed — system units run on boot.
|
|
39
|
+
*
|
|
40
|
+
* As of Phase 2b of the hub-as-supervisor unification, the per-platform
|
|
41
|
+
* install/remove/render machinery lives in `src/managed-unit.ts` as a reusable
|
|
42
|
+
* `ManagedUnit` abstraction (so the hub unit can reuse it — design §4.1). This
|
|
43
|
+
* module is now a thin connector-specific layer over that machinery: it builds
|
|
44
|
+
* the connector's `ManagedUnit` descriptor (empty env, no crash-loop ceiling —
|
|
45
|
+
* which keeps its rendered output BYTE-IDENTICAL to the pre-extraction code, the
|
|
46
|
+
* hard constraint since the connector is live on production) and supplies the
|
|
47
|
+
* connector-flavored install/remove messages. The public exports below keep
|
|
48
|
+
* their original signatures so `expose-cloudflare.ts` and the connector tests
|
|
49
|
+
* need no behavioral change.
|
|
50
|
+
*
|
|
51
|
+
* Everything is behind an injectable deps seam (re-exported `ConnectorServiceDeps`
|
|
52
|
+
* = the generalized `ManagedUnitDeps`) so tests drive the install/remove without
|
|
53
|
+
* touching real launchctl/systemctl or the operator's home directory.
|
|
54
|
+
*
|
|
55
|
+
* Service name keyed by the same per-host tunnel name the 0.6.1 work derives
|
|
56
|
+
* (`deriveTunnelName`), so install / remove always target the connector for
|
|
57
|
+
* exactly one tunnel and the expose off / legacy-sweep paths can tear it down.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
/** Synchronous command result from the injected service runner. */
|
|
61
|
+
export type { ServiceCommandResult };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Injectable side-effect seam for the connector-service module. This is the
|
|
65
|
+
* generalized `ManagedUnitDeps` re-exported under the connector's historical
|
|
66
|
+
* name so `expose-cloudflare.ts` + the connector tests are unchanged.
|
|
67
|
+
*/
|
|
68
|
+
export type ConnectorServiceDeps = ManagedUnitDeps;
|
|
69
|
+
|
|
70
|
+
export const defaultServiceDeps: ConnectorServiceDeps = defaultManagedUnitDeps;
|
|
71
|
+
|
|
72
|
+
/** Reverse-DNS prefix for the launchd label + plist filename. */
|
|
73
|
+
const LAUNCHD_LABEL_PREFIX = "computer.parachute.cloudflared";
|
|
74
|
+
/** systemd unit name prefix. */
|
|
75
|
+
const SYSTEMD_UNIT_PREFIX = "parachute-cloudflared-";
|
|
76
|
+
/** Provenance comment baked into every rendered connector unit file. */
|
|
77
|
+
const CONNECTOR_HEADER = "Generated by parachute expose public --cloudflare — do not edit by hand.";
|
|
78
|
+
|
|
79
|
+
/** launchd label for a tunnel (also the plist basename, minus `.plist`). */
|
|
80
|
+
export function launchdLabel(tunnelName: string): string {
|
|
81
|
+
return `${LAUNCHD_LABEL_PREFIX}.${tunnelName}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** launchd plist path under the user's LaunchAgents dir. */
|
|
85
|
+
export function launchdPlistPath(tunnelName: string, home: string): string {
|
|
86
|
+
return launchdPlistPathForLabel(launchdLabel(tunnelName), home);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** systemd unit name (with `.service` suffix). */
|
|
90
|
+
export function systemdUnitName(tunnelName: string): string {
|
|
91
|
+
return `${SYSTEMD_UNIT_PREFIX}${tunnelName}.service`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** systemd unit path — user-level under $HOME, system-level under /etc. */
|
|
95
|
+
export function systemdUnitPath(tunnelName: string, home: string, root: boolean): string {
|
|
96
|
+
return systemdUnitPathForName(systemdUnitName(tunnelName), home, root);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build the connector's `ManagedUnit` descriptor. Empty `env` + no `crashLoop`
|
|
101
|
+
* are what keep the rendered output byte-identical to the pre-extraction code.
|
|
102
|
+
* `runAsInvokingUserOnSystemUnit: true` reproduces the connector's historical
|
|
103
|
+
* `User=` pin on the root/system unit.
|
|
104
|
+
*/
|
|
105
|
+
function connectorUnit(opts: {
|
|
106
|
+
tunnelName: string;
|
|
107
|
+
cloudflaredPath: string;
|
|
108
|
+
configPath: string;
|
|
109
|
+
logPath: string;
|
|
110
|
+
}): ManagedUnit {
|
|
111
|
+
return {
|
|
112
|
+
launchdLabel: launchdLabel(opts.tunnelName),
|
|
113
|
+
systemdUnitName: systemdUnitName(opts.tunnelName),
|
|
114
|
+
headerComment: CONNECTOR_HEADER,
|
|
115
|
+
systemdDescription: `Parachute Cloudflare connector (${opts.tunnelName})`,
|
|
116
|
+
execStart: [opts.cloudflaredPath, "tunnel", "--config", opts.configPath, "run"],
|
|
117
|
+
env: {},
|
|
118
|
+
logPath: opts.logPath,
|
|
119
|
+
runAsInvokingUserOnSystemUnit: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Render the launchd LaunchAgent plist. Thin wrapper over the generalized
|
|
125
|
+
* renderer with the connector's descriptor — preserved signature so existing
|
|
126
|
+
* callers/tests are unchanged.
|
|
127
|
+
*/
|
|
128
|
+
export function renderLaunchdPlist(opts: {
|
|
129
|
+
tunnelName: string;
|
|
130
|
+
cloudflaredPath: string;
|
|
131
|
+
configPath: string;
|
|
132
|
+
logPath: string;
|
|
133
|
+
}): string {
|
|
134
|
+
return renderManagedLaunchdPlist(connectorUnit(opts));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a systemd unit. Thin wrapper over the generalized renderer — preserved
|
|
139
|
+
* signature (`{ root, userName }` threaded through) so existing callers/tests
|
|
140
|
+
* are unchanged.
|
|
141
|
+
*/
|
|
142
|
+
export function renderSystemdUnit(opts: {
|
|
143
|
+
tunnelName: string;
|
|
144
|
+
cloudflaredPath: string;
|
|
145
|
+
configPath: string;
|
|
146
|
+
logPath: string;
|
|
147
|
+
root: boolean;
|
|
148
|
+
userName: string;
|
|
149
|
+
}): string {
|
|
150
|
+
const { root, userName } = opts;
|
|
151
|
+
return renderManagedSystemdUnit(connectorUnit(opts), { root, userName });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface InstallResult {
|
|
155
|
+
/**
|
|
156
|
+
* "installed" → an OS service now owns the connector (survives reboot).
|
|
157
|
+
* "fallback" → the service tool was unavailable / failed; the caller should
|
|
158
|
+
* fall back to the transient `proc.unref()` spawn (does NOT survive reboot).
|
|
159
|
+
*/
|
|
160
|
+
outcome: "installed" | "fallback";
|
|
161
|
+
/** Which init system installed the service (when outcome === "installed"). */
|
|
162
|
+
kind?: "launchd" | "systemd-user" | "systemd-system" | "unsupported";
|
|
163
|
+
/** Path of the written service file (when outcome === "installed"). */
|
|
164
|
+
servicePath?: string;
|
|
165
|
+
/** Human-readable lines for the CLI to print (warnings, hints). */
|
|
166
|
+
messages: string[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ConnectorServiceOpts {
|
|
170
|
+
tunnelName: string;
|
|
171
|
+
configPath: string;
|
|
172
|
+
logPath: string;
|
|
173
|
+
deps?: ConnectorServiceDeps;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Connector-flavored install/remove messages, supplied to the generalized installer. */
|
|
177
|
+
function connectorMessages(): ManagedUnitMessages {
|
|
178
|
+
const lingerWarning =
|
|
179
|
+
"Note: could not enable lingering (loginctl enable-linger) — the connector will run while you're logged in but may not start on a cold boot before login. To run on cold boot without an active login, re-run this command as root (installs a system unit that needs no linger).";
|
|
180
|
+
return {
|
|
181
|
+
launchctlMissing: "launchctl not found; using a transient connector (won't survive a reboot).",
|
|
182
|
+
systemctlMissing: "systemctl not found; using a transient connector (won't survive a reboot).",
|
|
183
|
+
lingerWarning,
|
|
184
|
+
writeFailedPrefix:
|
|
185
|
+
"Failed to write service file; using a transient connector (won't survive a reboot)",
|
|
186
|
+
launchctlLoadFailedPrefix:
|
|
187
|
+
"launchctl could not load the connector service; using a transient connector (won't survive a reboot)",
|
|
188
|
+
daemonReloadFailedPrefix:
|
|
189
|
+
"systemctl daemon-reload failed; using a transient connector (won't survive a reboot)",
|
|
190
|
+
enableFailedPrefix:
|
|
191
|
+
"systemctl enable --now failed; using a transient connector (won't survive a reboot)",
|
|
192
|
+
// The connector always installs with start:true, so the `started` param these
|
|
193
|
+
// callbacks receive is unused here — the message is always the "starts now" variant.
|
|
194
|
+
launchdInstalled: (label, _started) =>
|
|
195
|
+
`Installed launchd LaunchAgent ${label} — the connector now starts on login/boot.`,
|
|
196
|
+
systemdInstalled: (unitName, root, _started) =>
|
|
197
|
+
`Installed systemd ${root ? "system" : "user"} unit ${unitName} — the connector now starts on boot.`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Install (or refresh) the reboot-persistent connector service for one tunnel
|
|
203
|
+
* and start it. Idempotent: re-installing overwrites the service file and
|
|
204
|
+
* re-loads it, so re-`expose` of the same hostname converges on exactly one
|
|
205
|
+
* managed connector.
|
|
206
|
+
*
|
|
207
|
+
* Graceful fallback: if the platform's service tool is missing or any step
|
|
208
|
+
* fails, returns `{ outcome: "fallback", messages }` WITHOUT throwing — the
|
|
209
|
+
* caller then spawns the transient connector and warns it won't survive a
|
|
210
|
+
* reboot. We never hard-fail the expose because the service install didn't take.
|
|
211
|
+
*/
|
|
212
|
+
export function installConnectorService(opts: ConnectorServiceOpts): InstallResult {
|
|
213
|
+
const deps = opts.deps ?? defaultServiceDeps;
|
|
214
|
+
const cloudflaredPath = deps.which("cloudflared");
|
|
215
|
+
if (!cloudflaredPath) {
|
|
216
|
+
return {
|
|
217
|
+
outcome: "fallback",
|
|
218
|
+
messages: ["Could not resolve the cloudflared binary path; skipping boot-service install."],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (deps.platform !== "darwin" && deps.platform !== "linux") {
|
|
222
|
+
return {
|
|
223
|
+
outcome: "fallback",
|
|
224
|
+
messages: [
|
|
225
|
+
`Boot-persistent connector isn't supported on ${deps.platform}; using a transient connector.`,
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result: ManagedUnitInstallResult = installManagedUnit({
|
|
231
|
+
unit: connectorUnit({
|
|
232
|
+
tunnelName: opts.tunnelName,
|
|
233
|
+
cloudflaredPath,
|
|
234
|
+
configPath: opts.configPath,
|
|
235
|
+
logPath: opts.logPath,
|
|
236
|
+
}),
|
|
237
|
+
deps,
|
|
238
|
+
messages: connectorMessages(),
|
|
239
|
+
// start: true (default) — the connector's behavior is unchanged.
|
|
240
|
+
});
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface RemoveResult {
|
|
245
|
+
/** True when a service file was found + removed (best-effort tool teardown ran). */
|
|
246
|
+
removed: boolean;
|
|
247
|
+
messages: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Stop + remove the reboot-persistent connector service for one tunnel.
|
|
252
|
+
* Idempotent + best-effort: a missing service file is a no-op; tool failures
|
|
253
|
+
* never throw (the expose-off path must always succeed at clearing state even
|
|
254
|
+
* if the OS service tool hiccups). Mirrors `installConnectorService`'s seam.
|
|
255
|
+
*
|
|
256
|
+
* Called by `exposeCloudflareOff` (and the legacy-tunnel sweep) so tearing down
|
|
257
|
+
* a tunnel also tears down its boot service — otherwise the service would
|
|
258
|
+
* resurrect a dead connector on the next reboot.
|
|
259
|
+
*/
|
|
260
|
+
export function removeConnectorService(opts: {
|
|
261
|
+
tunnelName: string;
|
|
262
|
+
deps?: ConnectorServiceDeps;
|
|
263
|
+
}): RemoveResult {
|
|
264
|
+
const deps = opts.deps ?? defaultServiceDeps;
|
|
265
|
+
const result: ManagedUnitRemoveResult = removeManagedUnit({
|
|
266
|
+
launchdLabel: launchdLabel(opts.tunnelName),
|
|
267
|
+
systemdUnitName: systemdUnitName(opts.tunnelName),
|
|
268
|
+
deps,
|
|
269
|
+
removedLaunchdMessage: (label) => `Removed launchd LaunchAgent ${label}.`,
|
|
270
|
+
removedSystemdMessage: (unitName) => `Removed systemd unit ${unitName}.`,
|
|
271
|
+
});
|
|
272
|
+
return result;
|
|
273
|
+
}
|
package/src/cloudflare/state.ts
CHANGED
|
@@ -25,6 +25,14 @@ export interface CloudflaredTunnelRecord {
|
|
|
25
25
|
startedAt: string;
|
|
26
26
|
/** Absolute path to the cloudflared config.yml driving this tunnel. */
|
|
27
27
|
configPath: string;
|
|
28
|
+
/**
|
|
29
|
+
* True when a reboot-persistent OS service (launchd/systemd) owns this
|
|
30
|
+
* connector (0.6.2). Drives the off-path to remove the service (not just
|
|
31
|
+
* SIGTERM the pid — a still-enabled service would otherwise restart the
|
|
32
|
+
* connector it just killed). Optional + defaults false so pre-0.6.2 state
|
|
33
|
+
* files (and the transient-fallback path) validate + read as unmanaged.
|
|
34
|
+
*/
|
|
35
|
+
serviceManaged?: boolean;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
/**
|
|
@@ -64,7 +72,7 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
|
|
|
64
72
|
throw new CloudflaredStateError(`${path}: tunnel record must be an object`);
|
|
65
73
|
}
|
|
66
74
|
const r = raw as Record<string, unknown>;
|
|
67
|
-
|
|
75
|
+
const record: CloudflaredTunnelRecord = {
|
|
68
76
|
pid: requirePositiveInt(r, "pid", path),
|
|
69
77
|
tunnelUuid: requireString(r, "tunnelUuid", path),
|
|
70
78
|
tunnelName: requireString(r, "tunnelName", path),
|
|
@@ -72,6 +80,10 @@ function validateRecord(raw: unknown, path: string): CloudflaredTunnelRecord {
|
|
|
72
80
|
startedAt: requireString(r, "startedAt", path),
|
|
73
81
|
configPath: requireString(r, "configPath", path),
|
|
74
82
|
};
|
|
83
|
+
// Optional — present from 0.6.2 onward. A non-boolean (or absent) value
|
|
84
|
+
// reads as unmanaged so legacy state files keep validating.
|
|
85
|
+
if (r.serviceManaged === true) record.serviceManaged = true;
|
|
86
|
+
return record;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
function validate(raw: unknown, path: string): CloudflaredState {
|