@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
@@ -103,8 +103,11 @@ describe("init", () => {
103
103
  expect(code).toBe(0);
104
104
  expect(calls).toEqual(["ensureHub"]);
105
105
  const joined = logs.join("\n");
106
- expect(joined).toContain("Hub not runningstarting it now");
107
- expect(joined).toContain("Hub started (pid 5555, port 1939)");
106
+ // A genuinely-started unit reports the port only no `pid 0` sentinel,
107
+ // no misleading "starting it now" preamble.
108
+ expect(joined).toContain("Hub unit started (port 1939)");
109
+ expect(joined).not.toContain("pid 0");
110
+ expect(joined).not.toContain("starting it now");
108
111
  expect(joined).toContain("http://127.0.0.1:1939/admin/");
109
112
  expect(joined).toContain("finish setup in the admin wizard");
110
113
  } finally {
@@ -112,6 +115,46 @@ describe("init", () => {
112
115
  }
113
116
  });
114
117
 
118
+ test("unit-managed re-run against a live hub logs 'already running', not 'pid 0'", async () => {
119
+ // A unit-managed hub writes no pidfile, so `processState(HUB_SVC)` reports
120
+ // not-running on every re-run — but `ensureHub` probes /health and returns
121
+ // started:false when the hub is already up. The log must be honest: no
122
+ // "starting it now", no "Hub unit started", no `pid 0` sentinel.
123
+ const h = makeHarness();
124
+ try {
125
+ writeHubPort(1939, h.configDir);
126
+ const calls: string[] = [];
127
+ const logs: string[] = [];
128
+ const code = await init({
129
+ configDir: h.configDir,
130
+ manifestPath: h.manifestPath,
131
+ log: (l) => logs.push(l),
132
+ // No pidfile (unit-managed) → processState reports not-running.
133
+ alive: () => false,
134
+ ensureHub: async () => {
135
+ calls.push("ensureHub");
136
+ // Hub already answered /health — ensureHubUnit's already-up arm.
137
+ return { pid: 0, port: 1939, started: false };
138
+ },
139
+ readExposeStateFn: () => undefined,
140
+ isTty: false,
141
+ platform: "darwin",
142
+ installVaultModuleImpl: noopVaultInstall,
143
+ });
144
+ expect(code).toBe(0);
145
+ // ensureHub IS called (processState can't see a unit-managed hub) but
146
+ // reports started:false.
147
+ expect(calls).toEqual(["ensureHub"]);
148
+ const joined = logs.join("\n");
149
+ expect(joined).toContain("Hub already running (port 1939)");
150
+ expect(joined).not.toContain("pid 0");
151
+ expect(joined).not.toContain("starting it now");
152
+ expect(joined).not.toContain("Hub unit started");
153
+ } finally {
154
+ h.cleanup();
155
+ }
156
+ });
157
+
115
158
  test("idempotent: skips ensureHub and confirms 'looks good' when hub up + vault configured", async () => {
116
159
  const h = makeHarness();
117
160
  try {
@@ -879,6 +922,180 @@ describe("init exposure chain", () => {
879
922
  }
880
923
  });
881
924
 
925
+ // -------------------------------------------------------------------------
926
+ // Phase 3a cutover (design §3.3 init row, §3.1, §4.1/§4.2): init installs +
927
+ // starts the hub UNIT (not a detached spawn) and guarantees an operator
928
+ // token. The `ensureHub` + `guaranteeOperatorToken` seams stay injectable;
929
+ // these tests drive them as stubs (and exercise the REAL operator-token
930
+ // guarantee against a seeded hub DB).
931
+ // -------------------------------------------------------------------------
932
+
933
+ test("calls guaranteeOperatorToken after the hub is up, then falls through to the wizard", async () => {
934
+ const h = makeHarness();
935
+ try {
936
+ const order: string[] = [];
937
+ const code = await init({
938
+ configDir: h.configDir,
939
+ manifestPath: h.manifestPath,
940
+ log: () => {},
941
+ alive: () => false,
942
+ ensureHub: async () => {
943
+ order.push("ensureHub");
944
+ writeHubPort(1939, h.configDir);
945
+ return { pid: 0, port: 1939, started: true };
946
+ },
947
+ guaranteeOperatorToken: async (ctx) => {
948
+ order.push("guaranteeOperatorToken");
949
+ // The hub is up before the token guarantee runs (§3.2 step 4 — read
950
+ // the token AFTER readiness so we don't race the start-hub iss
951
+ // self-heal).
952
+ expect(ctx.hubPort).toBe(1939);
953
+ return "present";
954
+ },
955
+ readExposeStateFn: () => undefined,
956
+ isTty: false,
957
+ platform: "linux",
958
+ installVaultModuleImpl: noopVaultInstall,
959
+ });
960
+ expect(code).toBe(0);
961
+ // hub-up first, then token guarantee — in that order.
962
+ expect(order).toEqual(["ensureHub", "guaranteeOperatorToken"]);
963
+ } finally {
964
+ h.cleanup();
965
+ }
966
+ });
967
+
968
+ test("real guarantee: MINTS the operator token when absent + a hub user exists", async () => {
969
+ const h = makeHarness();
970
+ try {
971
+ const { openHubDb, hubDbPath } = await import("../hub-db.ts");
972
+ const { createUser } = await import("../users.ts");
973
+ const { readOperatorTokenFile } = await import("../operator-token.ts");
974
+ // Seed a first-admin so the guarantee has someone to mint for.
975
+ const db = openHubDb(hubDbPath(h.configDir));
976
+ await createUser(db, "owner", "owner-password-123");
977
+ db.close();
978
+
979
+ // No operator.token yet.
980
+ expect(await readOperatorTokenFile(h.configDir)).toBeNull();
981
+
982
+ const code = await init({
983
+ configDir: h.configDir,
984
+ manifestPath: h.manifestPath,
985
+ log: () => {},
986
+ alive: () => false,
987
+ ensureHub: async () => {
988
+ writeHubPort(1939, h.configDir);
989
+ return { pid: 0, port: 1939, started: true };
990
+ },
991
+ // No guaranteeOperatorToken seam → exercises the REAL default.
992
+ readExposeStateFn: () => undefined,
993
+ isTty: false,
994
+ platform: "linux",
995
+ installVaultModuleImpl: noopVaultInstall,
996
+ });
997
+ expect(code).toBe(0);
998
+ // The default minted + wrote the token.
999
+ expect(await readOperatorTokenFile(h.configDir)).not.toBeNull();
1000
+ } finally {
1001
+ h.cleanup();
1002
+ }
1003
+ });
1004
+
1005
+ test("real guarantee: does NOT double-mint when a token already exists", async () => {
1006
+ const h = makeHarness();
1007
+ try {
1008
+ const { openHubDb, hubDbPath } = await import("../hub-db.ts");
1009
+ const { createUser } = await import("../users.ts");
1010
+ const { writeOperatorTokenFile, readOperatorTokenFile } = await import(
1011
+ "../operator-token.ts"
1012
+ );
1013
+ const db = openHubDb(hubDbPath(h.configDir));
1014
+ await createUser(db, "owner", "owner-password-123");
1015
+ db.close();
1016
+ // Plant a sentinel token on disk.
1017
+ await writeOperatorTokenFile("SENTINEL.PRE-EXISTING.TOKEN", h.configDir);
1018
+
1019
+ const code = await init({
1020
+ configDir: h.configDir,
1021
+ manifestPath: h.manifestPath,
1022
+ log: () => {},
1023
+ alive: () => false,
1024
+ ensureHub: async () => {
1025
+ writeHubPort(1939, h.configDir);
1026
+ return { pid: 0, port: 1939, started: true };
1027
+ },
1028
+ readExposeStateFn: () => undefined,
1029
+ isTty: false,
1030
+ platform: "linux",
1031
+ installVaultModuleImpl: noopVaultInstall,
1032
+ });
1033
+ expect(code).toBe(0);
1034
+ // Untouched — the guarantee left the pre-existing token in place.
1035
+ expect(await readOperatorTokenFile(h.configDir)).toBe("SENTINEL.PRE-EXISTING.TOKEN");
1036
+ } finally {
1037
+ h.cleanup();
1038
+ }
1039
+ });
1040
+
1041
+ test("real guarantee: no hub user yet → no token minted, init still succeeds (no-user, not an error)", async () => {
1042
+ const h = makeHarness();
1043
+ try {
1044
+ const { readOperatorTokenFile } = await import("../operator-token.ts");
1045
+ // No user seeded — the common fresh-box case where init runs before the
1046
+ // wizard creates first-admin.
1047
+ const code = await init({
1048
+ configDir: h.configDir,
1049
+ manifestPath: h.manifestPath,
1050
+ log: () => {},
1051
+ alive: () => false,
1052
+ ensureHub: async () => {
1053
+ writeHubPort(1939, h.configDir);
1054
+ return { pid: 0, port: 1939, started: true };
1055
+ },
1056
+ readExposeStateFn: () => undefined,
1057
+ isTty: false,
1058
+ platform: "linux",
1059
+ installVaultModuleImpl: noopVaultInstall,
1060
+ });
1061
+ expect(code).toBe(0);
1062
+ // No token (the wizard mints it when the admin is created).
1063
+ expect(await readOperatorTokenFile(h.configDir)).toBeNull();
1064
+ } finally {
1065
+ h.cleanup();
1066
+ }
1067
+ });
1068
+
1069
+ test("hub-unit bringup failure (e.g. no service manager) → init exits 1 with the actionable message", async () => {
1070
+ const h = makeHarness();
1071
+ try {
1072
+ const logs: string[] = [];
1073
+ const code = await init({
1074
+ configDir: h.configDir,
1075
+ manifestPath: h.manifestPath,
1076
+ log: (l) => logs.push(l),
1077
+ alive: () => false,
1078
+ // Mirror what the production default throws when there's no manager.
1079
+ ensureHub: async () => {
1080
+ throw new Error(
1081
+ "no service manager (systemd/launchd) found — run `parachute serve` in the foreground, or use a platform that provides one",
1082
+ );
1083
+ },
1084
+ guaranteeOperatorToken: async () => "no-user",
1085
+ readExposeStateFn: () => undefined,
1086
+ isTty: false,
1087
+ platform: "linux",
1088
+ installVaultModuleImpl: noopVaultInstall,
1089
+ });
1090
+ expect(code).toBe(1);
1091
+ const joined = logs.join("\n");
1092
+ expect(joined).toContain("no service manager (systemd/launchd) found");
1093
+ expect(joined).toContain("parachute logs hub");
1094
+ } finally {
1095
+ h.cleanup();
1096
+ }
1097
+ });
1098
+
882
1099
  test("after exposure runs, the admin URL re-reads expose state for the FQDN", async () => {
883
1100
  const h = makeHarness();
884
1101
  try {