@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.
Files changed (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -15,9 +15,12 @@ import {
15
15
  exposeCloudflareOff,
16
16
  exposeCloudflareUp,
17
17
  } from "../commands/expose-cloudflare.ts";
18
+ import type { ExposeSupervisorOpts } from "../commands/expose-supervisor.ts";
18
19
  import { readEnvFileValues } from "../env-file.ts";
19
20
  import { readExposeState } from "../expose-state.ts";
20
21
  import { writeHubPort } from "../hub-control.ts";
22
+ import type { EnsureHubUnitOpts } from "../hub-unit.ts";
23
+ import { type ModuleOp, ModuleOpHttpError } from "../module-ops-client.ts";
21
24
  import type { CommandResult, Runner } from "../tailscale/run.ts";
22
25
 
23
26
  // Default seeded hub port used by tests with `skipHub: true`. The cloudflared
@@ -283,23 +286,22 @@ describe("exposeCloudflareUp", () => {
283
286
  }
284
287
  });
285
288
 
286
- test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
289
+ test("persists the public hub origin to vault/.env + restarts vault via the supervisor (Cloudflare 401 fix)", async () => {
287
290
  // The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
288
291
  // unlike the Tailscale path, which auto-restarts vault and so flows the
289
- // public origin into vault/.env via lifecycle's persistVaultHubOrigin
290
- // never touched vault's .env or restarted it. The launchd/systemd daemon
291
- // kept booting vault with NO PARACHUTE_HUB_ORIGIN vault fell back to
292
- // loopback as its expected issuer → every hub-minted token (iss=public)
293
- // failed the iss check → 401. This asserts the durable .env write + the
294
- // running-vault restart that mirrors the Tailscale path.
292
+ // public origin into vault/.env never touched vault's .env or restarted
293
+ // it. The launchd/systemd daemon kept booting vault with NO
294
+ // PARACHUTE_HUB_ORIGIN vault fell back to loopback as its expected issuer →
295
+ // every hub-minted token (iss=public) failed the iss check → 401.
296
+ //
297
+ // Phase 5b: the restart goes through the running Supervisor
298
+ // (`driveModuleOp("vault", "restart")`) — the helper also persists the
299
+ // durable .env. This asserts the durable .env write + the supervised restart
300
+ // (no longer a pidfile-gated detached restart; the supervisor decides
301
+ // liveness, and a not-supervised vault surfaces as a tolerated 404 — see the
302
+ // Phase 4 dual-dispatch suite).
295
303
  const env = makeEnv();
296
304
  try {
297
- // Seed vault as "running" so the restart branch fires. PID lives at
298
- // <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
299
- const vaultRun = join(env.configDir, "vault", "run");
300
- require("node:fs").mkdirSync(vaultRun, { recursive: true });
301
- writeFileSync(join(vaultRun, "vault.pid"), "99001");
302
-
303
305
  const uuid = "ffffffff-0000-0000-0000-000000000006";
304
306
  const { runner } = queueRunner([
305
307
  { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
@@ -312,14 +314,12 @@ describe("exposeCloudflareUp", () => {
312
314
  { code: 0, stdout: "", stderr: "" },
313
315
  ]);
314
316
  const { spawner } = fakeSpawner(42300);
315
- const restarted: string[] = [];
317
+ const sup = makeCfSupervisorStub();
316
318
 
317
319
  const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
318
320
  runner,
319
321
  spawner,
320
- // `alive` reports the seeded vault pid as running so processState() ===
321
- // "running" and the restart branch executes.
322
- alive: (pid) => pid === 99001,
322
+ alive: () => false,
323
323
  kill: () => {},
324
324
  log: () => {},
325
325
  manifestPath: env.manifestPath,
@@ -330,10 +330,7 @@ describe("exposeCloudflareUp", () => {
330
330
  cloudflaredHome: env.cloudflaredHome,
331
331
  configDir: env.configDir,
332
332
  skipHub: true,
333
- restartService: async (short) => {
334
- restarted.push(short);
335
- return 0;
336
- },
333
+ supervisor: sup.opts,
337
334
  });
338
335
 
339
336
  expect(code).toBe(0);
@@ -342,57 +339,8 @@ describe("exposeCloudflareUp", () => {
342
339
  expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
343
340
  "https://gitcoin-parachute.unforced.dev",
344
341
  );
345
- // Live half: the running vault is restarted to re-read the new origin.
346
- expect(restarted).toEqual(["vault"]);
347
- } finally {
348
- env.cleanup();
349
- }
350
- });
351
-
352
- test("persists vault/.env but does NOT restart when vault isn't running", async () => {
353
- // No vault pidfile → processState() !== "running" → no restart, but the
354
- // durable .env write still happens so the next daemon boot is correct.
355
- const env = makeEnv();
356
- try {
357
- const uuid = "ffffffff-0000-0000-0000-000000000007";
358
- const { runner } = queueRunner([
359
- { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
360
- { code: 0, stdout: "[]", stderr: "" },
361
- {
362
- code: 0,
363
- stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
364
- stderr: "",
365
- },
366
- { code: 0, stdout: "", stderr: "" },
367
- ]);
368
- const { spawner } = fakeSpawner(42301);
369
- const restarted: string[] = [];
370
-
371
- const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
372
- runner,
373
- spawner,
374
- alive: () => false,
375
- kill: () => {},
376
- log: () => {},
377
- manifestPath: env.manifestPath,
378
- statePath: env.statePath,
379
- exposeStatePath: env.exposeStatePath,
380
- configPath: env.configPath,
381
- logPath: env.logPath,
382
- cloudflaredHome: env.cloudflaredHome,
383
- configDir: env.configDir,
384
- skipHub: true,
385
- restartService: async (short) => {
386
- restarted.push(short);
387
- return 0;
388
- },
389
- });
390
-
391
- expect(code).toBe(0);
392
- expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
393
- "https://gitcoin-parachute.unforced.dev",
394
- );
395
- expect(restarted).toEqual([]);
342
+ // Live half: vault is restarted via the supervisor to re-read the new origin.
343
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
396
344
  } finally {
397
345
  env.cleanup();
398
346
  }
@@ -2017,3 +1965,146 @@ describe("reboot-persistent connector service wiring", () => {
2017
1965
  }
2018
1966
  });
2019
1967
  });
1968
+
1969
+ // ---------------------------------------------------------------------------
1970
+ // Phase 4 dual-dispatch (design §4.3): unit-managed → "ensure the hub" ensures
1971
+ // the UNIT (not a detached spawn) and the post-route vault restart drives the
1972
+ // running Supervisor over the loopback module-ops API (firing the operator-
1973
+ // token self-heal). The cloudflared CONNECTOR unit is unchanged. The no-unit
1974
+ // arm keeps today's `persistVaultHubOrigin` + `restartService` behavior.
1975
+ // ---------------------------------------------------------------------------
1976
+
1977
+ interface CfExposeSupervisorStub {
1978
+ opts: ExposeSupervisorOpts;
1979
+ ensureCalls: number;
1980
+ driveCalls: Array<{ short: string; op: ModuleOp }>;
1981
+ selfHealCalls: Array<{ issuer: string }>;
1982
+ }
1983
+
1984
+ function makeCfSupervisorStub(opts?: {
1985
+ ensureOutcome?: "already-up" | "started" | "no-manager";
1986
+ driveThrows?: () => unknown;
1987
+ }): CfExposeSupervisorStub {
1988
+ const stub: CfExposeSupervisorStub = {
1989
+ ensureCalls: 0,
1990
+ driveCalls: [],
1991
+ selfHealCalls: [],
1992
+ opts: {
1993
+ openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
1994
+ ensureHubUnit: async (o: EnsureHubUnitOpts) => {
1995
+ stub.ensureCalls++;
1996
+ return { outcome: opts?.ensureOutcome ?? "already-up", port: o.port ?? 1939, messages: [] };
1997
+ },
1998
+ driveModuleOp: async (short, op) => {
1999
+ stub.driveCalls.push({ short, op });
2000
+ if (opts?.driveThrows) throw opts.driveThrows();
2001
+ return { status: 200, body: { short, state: { status: "running" } } };
2002
+ },
2003
+ selfHealOperatorTokenIssuer: async (_db, o) => {
2004
+ stub.selfHealCalls.push({ issuer: o.issuer });
2005
+ return { kind: "fresh" };
2006
+ },
2007
+ },
2008
+ };
2009
+ return stub;
2010
+ }
2011
+
2012
+ describe("Phase 4 cloudflare expose dual-dispatch — unit-managed", () => {
2013
+ test("unit-managed → ensureHubUnit (not detached) + supervised vault restart + operator-token self-heal", async () => {
2014
+ const env = makeEnv();
2015
+ try {
2016
+ const uuid = "ffffffff-0000-0000-0000-0000000000a4";
2017
+ const { runner } = queueRunner([
2018
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
2019
+ { code: 0, stdout: "[]", stderr: "" }, // tunnel list
2020
+ {
2021
+ code: 0,
2022
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
2023
+ stderr: "",
2024
+ }, // tunnel create
2025
+ { code: 0, stdout: "", stderr: "" }, // route dns
2026
+ ]);
2027
+ const { spawner } = fakeSpawner(42400);
2028
+ const sup = makeCfSupervisorStub();
2029
+ const logs: string[] = [];
2030
+
2031
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
2032
+ runner,
2033
+ spawner,
2034
+ alive: () => false,
2035
+ kill: () => {},
2036
+ log: (l) => logs.push(l),
2037
+ manifestPath: env.manifestPath,
2038
+ statePath: env.statePath,
2039
+ exposeStatePath: env.exposeStatePath,
2040
+ configPath: env.configPath,
2041
+ logPath: env.logPath,
2042
+ cloudflaredHome: env.cloudflaredHome,
2043
+ configDir: env.configDir,
2044
+ // No skipHub → the hub-unit ensure runs (via the stub, no real hub).
2045
+ // Inert connector install so the cloudflared connector path is unchanged.
2046
+ installService: () => ({ outcome: "fallback", messages: [] }),
2047
+ supervisor: sup.opts,
2048
+ });
2049
+
2050
+ expect(code).toBe(0);
2051
+ // Ensured the hub UNIT (the stub), never a detached spawn.
2052
+ expect(sup.ensureCalls).toBe(1);
2053
+ // Vault restart drove the running Supervisor.
2054
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
2055
+ // Operator-token issuer self-heal fired toward the public origin.
2056
+ expect(sup.selfHealCalls).toHaveLength(1);
2057
+ expect(sup.selfHealCalls[0]?.issuer).toBe("https://gitcoin-parachute.unforced.dev");
2058
+ // Durable .env still written (the helper persists it for vault).
2059
+ expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
2060
+ "https://gitcoin-parachute.unforced.dev",
2061
+ );
2062
+ expect(logs.join("\n")).toMatch(/hub unit up/);
2063
+ } finally {
2064
+ env.cleanup();
2065
+ }
2066
+ });
2067
+
2068
+ test("unit-managed: a not_supervised vault (404) is not a failure", async () => {
2069
+ const env = makeEnv();
2070
+ try {
2071
+ const uuid = "ffffffff-0000-0000-0000-0000000000a5";
2072
+ const { runner } = queueRunner([
2073
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
2074
+ { code: 0, stdout: "[]", stderr: "" },
2075
+ {
2076
+ code: 0,
2077
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
2078
+ stderr: "",
2079
+ },
2080
+ { code: 0, stdout: "", stderr: "" },
2081
+ ]);
2082
+ const { spawner } = fakeSpawner(42401);
2083
+ const sup = makeCfSupervisorStub({
2084
+ driveThrows: () => new ModuleOpHttpError(404, "not_supervised", "vault is not supervised"),
2085
+ });
2086
+
2087
+ const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
2088
+ runner,
2089
+ spawner,
2090
+ alive: () => false,
2091
+ kill: () => {},
2092
+ log: () => {},
2093
+ manifestPath: env.manifestPath,
2094
+ statePath: env.statePath,
2095
+ exposeStatePath: env.exposeStatePath,
2096
+ configPath: env.configPath,
2097
+ logPath: env.logPath,
2098
+ cloudflaredHome: env.cloudflaredHome,
2099
+ configDir: env.configDir,
2100
+ installService: () => ({ outcome: "fallback", messages: [] }),
2101
+ supervisor: sup.opts,
2102
+ });
2103
+
2104
+ expect(code).toBe(0);
2105
+ expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
2106
+ } finally {
2107
+ env.cleanup();
2108
+ }
2109
+ });
2110
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { CloudflaredState } from "../cloudflare/state.ts";
3
+ import type { ExposeCloudflareOpts } from "../commands/expose-cloudflare.ts";
3
4
  import {
4
5
  type ExposePublicOffAutoOpts,
5
6
  runExposePublicOffAutoDetect,
@@ -50,6 +51,7 @@ interface Harness {
50
51
  prompts: string[];
51
52
  tailscaleCalls: number;
52
53
  cloudflareCalls: number;
54
+ cloudflareOpts: ExposeCloudflareOpts[];
53
55
  }
54
56
 
55
57
  function makeHarness(
@@ -70,6 +72,7 @@ function makeHarness(
70
72
  prompts: [],
71
73
  tailscaleCalls: 0,
72
74
  cloudflareCalls: 0,
75
+ cloudflareOpts: [],
73
76
  };
74
77
  const answers = [...(input.promptAnswers ?? [])];
75
78
  let i = 0;
@@ -88,8 +91,9 @@ function makeHarness(
88
91
  harness.tailscaleCalls++;
89
92
  return input.tsExitCode ?? 0;
90
93
  },
91
- exposeCloudflareOffImpl: async () => {
94
+ exposeCloudflareOffImpl: async (cfOpts) => {
92
95
  harness.cloudflareCalls++;
96
+ harness.cloudflareOpts.push(cfOpts);
93
97
  return input.cfExitCode ?? 0;
94
98
  },
95
99
  };
@@ -273,3 +277,24 @@ describe("runExposePublicOffAutoDetect — both live (non-TTY)", () => {
273
277
  expect(harness.logs).toContain("(non-TTY: tearing down both.)");
274
278
  });
275
279
  });
280
+
281
+ describe("runExposePublicOffAutoDetect — supervisor threading (Phase 4 consistency)", () => {
282
+ test("cloudflareOffOpts.supervisor reaches the cloudflare teardown leg", async () => {
283
+ // cli.ts threads `cloudflareOffOpts: { supervisor: {} }` into the
284
+ // auto-detect off path so the Phase 4 supervisor resolution is consistent
285
+ // across both providers (matching the explicit `--cloudflare off` branch).
286
+ // Assert the supervisor block survives the spread-with-tunnelName wrapper
287
+ // and arrives at the leaf cloudflare-off impl.
288
+ const { harness, opts } = makeHarness({ cfState: cloudflaredState() });
289
+ const code = await runExposePublicOffAutoDetect({
290
+ ...opts,
291
+ cloudflareOffOpts: { supervisor: {} },
292
+ });
293
+ expect(code).toBe(0);
294
+ expect(harness.cloudflareCalls).toBe(1);
295
+ expect(harness.cloudflareOpts).toHaveLength(1);
296
+ expect(harness.cloudflareOpts[0]?.supervisor).toEqual({});
297
+ // The per-record wrapper still stamps the tunnelName onto the leaf opts.
298
+ expect(harness.cloudflareOpts[0]?.tunnelName).toBe("vault-tunnel");
299
+ });
300
+ });