@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
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { CutoverResult } from "../commands/migrate-cutover.ts";
|
|
6
|
+
import { hasPriorDetachedInstall, offerMigrateToSupervised } from "../migrate-offer.ts";
|
|
7
|
+
import { writePid } from "../process-state.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sandboxed §7.5 auto-detect-and-offer tests. Every offer runs against a fresh
|
|
11
|
+
* tmp `PARACHUTE_HOME` with stubbed unit-detection / cutover / prompt / TTY —
|
|
12
|
+
* no real prompt, no real cutover, no touching `~/.parachute`. The only real fs
|
|
13
|
+
* is seeding pidfiles + services.json (via `writePid`) so the DETECTOR reads
|
|
14
|
+
* genuine on-disk detached-era state.
|
|
15
|
+
*/
|
|
16
|
+
interface Harness {
|
|
17
|
+
configDir: string;
|
|
18
|
+
manifestPath: string;
|
|
19
|
+
cleanup: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function makeHarness(): Harness {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-offer-"));
|
|
24
|
+
return {
|
|
25
|
+
configDir: dir,
|
|
26
|
+
manifestPath: join(dir, "services.json"),
|
|
27
|
+
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function seedManifest(manifestPath: string, services: Array<{ name: string; port: number }>): void {
|
|
32
|
+
const full = services.map((s) => ({
|
|
33
|
+
name: s.name,
|
|
34
|
+
port: s.port,
|
|
35
|
+
paths: [`/${s.name}`],
|
|
36
|
+
health: "/health",
|
|
37
|
+
version: "1.0.0",
|
|
38
|
+
}));
|
|
39
|
+
writeFileSync(manifestPath, JSON.stringify({ services: full }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function seedHubPidfile(configDir: string, pid = 12345): void {
|
|
43
|
+
mkdirSync(join(configDir, "hub", "run"), { recursive: true });
|
|
44
|
+
writePid("hub", pid, configDir);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const migratedResult: CutoverResult = { outcome: "migrated", port: 1939, messages: ["✓ migrated"] };
|
|
48
|
+
|
|
49
|
+
describe("hasPriorDetachedInstall (the §7.5 (b) detector)", () => {
|
|
50
|
+
test("true when a hub pidfile exists", () => {
|
|
51
|
+
const h = makeHarness();
|
|
52
|
+
try {
|
|
53
|
+
seedHubPidfile(h.configDir);
|
|
54
|
+
expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(true);
|
|
55
|
+
} finally {
|
|
56
|
+
h.cleanup();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("true when a module pidfile exists", () => {
|
|
61
|
+
const h = makeHarness();
|
|
62
|
+
try {
|
|
63
|
+
seedManifest(h.manifestPath, [{ name: "parachute-vault", port: 1940 }]);
|
|
64
|
+
mkdirSync(join(h.configDir, "vault", "run"), { recursive: true });
|
|
65
|
+
writePid("vault", 999, h.configDir);
|
|
66
|
+
expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(true);
|
|
67
|
+
} finally {
|
|
68
|
+
h.cleanup();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("FALSE on a clean/supervised box — services.json alone is NOT enough", () => {
|
|
73
|
+
const h = makeHarness();
|
|
74
|
+
try {
|
|
75
|
+
// A configured box with services.json but NO pidfiles (the supervised
|
|
76
|
+
// shape — children tracked in-process). Must not false-positive.
|
|
77
|
+
seedManifest(h.manifestPath, [{ name: "parachute-vault", port: 1940 }]);
|
|
78
|
+
expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(false);
|
|
79
|
+
} finally {
|
|
80
|
+
h.cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("FALSE on a brand-new box (no services.json, no pidfiles)", () => {
|
|
85
|
+
const h = makeHarness();
|
|
86
|
+
try {
|
|
87
|
+
expect(hasPriorDetachedInstall(h.configDir, h.manifestPath)).toBe(false);
|
|
88
|
+
} finally {
|
|
89
|
+
h.cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("offerMigrateToSupervised — when NOT to offer", () => {
|
|
95
|
+
test("no offer when a hub unit IS installed (already supervised)", async () => {
|
|
96
|
+
const h = makeHarness();
|
|
97
|
+
try {
|
|
98
|
+
seedHubPidfile(h.configDir);
|
|
99
|
+
let cutoverCalled = false;
|
|
100
|
+
const result = await offerMigrateToSupervised({
|
|
101
|
+
configDir: h.configDir,
|
|
102
|
+
manifestPath: h.manifestPath,
|
|
103
|
+
log: () => {},
|
|
104
|
+
isHubUnitInstalled: () => true, // a unit exists
|
|
105
|
+
hasPriorDetached: () => true,
|
|
106
|
+
cutover: async () => {
|
|
107
|
+
cutoverCalled = true;
|
|
108
|
+
return migratedResult;
|
|
109
|
+
},
|
|
110
|
+
prompt: async () => "y",
|
|
111
|
+
isTty: true,
|
|
112
|
+
});
|
|
113
|
+
expect(result.outcome).toBe("no-offer");
|
|
114
|
+
expect(cutoverCalled).toBe(false);
|
|
115
|
+
} finally {
|
|
116
|
+
h.cleanup();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("no offer when there is no prior-detached evidence (clean box)", async () => {
|
|
121
|
+
const h = makeHarness();
|
|
122
|
+
try {
|
|
123
|
+
const result = await offerMigrateToSupervised({
|
|
124
|
+
configDir: h.configDir,
|
|
125
|
+
manifestPath: h.manifestPath,
|
|
126
|
+
log: () => {},
|
|
127
|
+
isHubUnitInstalled: () => false,
|
|
128
|
+
hasPriorDetached: () => false, // clean box
|
|
129
|
+
prompt: async () => {
|
|
130
|
+
throw new Error("prompt must not be called on a clean box");
|
|
131
|
+
},
|
|
132
|
+
isTty: true,
|
|
133
|
+
});
|
|
134
|
+
expect(result.outcome).toBe("no-offer");
|
|
135
|
+
} finally {
|
|
136
|
+
h.cleanup();
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("offerMigrateToSupervised — interactive (TTY)", () => {
|
|
142
|
+
test("accept → runs the cutover, reports migrated", async () => {
|
|
143
|
+
const h = makeHarness();
|
|
144
|
+
try {
|
|
145
|
+
let cutoverCalled = false;
|
|
146
|
+
const result = await offerMigrateToSupervised({
|
|
147
|
+
configDir: h.configDir,
|
|
148
|
+
manifestPath: h.manifestPath,
|
|
149
|
+
log: () => {},
|
|
150
|
+
isHubUnitInstalled: () => false,
|
|
151
|
+
hasPriorDetached: () => true,
|
|
152
|
+
cutover: async () => {
|
|
153
|
+
cutoverCalled = true;
|
|
154
|
+
return migratedResult;
|
|
155
|
+
},
|
|
156
|
+
prompt: async () => "y",
|
|
157
|
+
isTty: true,
|
|
158
|
+
});
|
|
159
|
+
expect(cutoverCalled).toBe(true);
|
|
160
|
+
expect(result.outcome).toBe("migrated");
|
|
161
|
+
} finally {
|
|
162
|
+
h.cleanup();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("decline → does NOT run the cutover", async () => {
|
|
167
|
+
const h = makeHarness();
|
|
168
|
+
try {
|
|
169
|
+
let cutoverCalled = false;
|
|
170
|
+
const log: string[] = [];
|
|
171
|
+
const result = await offerMigrateToSupervised({
|
|
172
|
+
configDir: h.configDir,
|
|
173
|
+
manifestPath: h.manifestPath,
|
|
174
|
+
log: (l) => log.push(l),
|
|
175
|
+
isHubUnitInstalled: () => false,
|
|
176
|
+
hasPriorDetached: () => true,
|
|
177
|
+
cutover: async () => {
|
|
178
|
+
cutoverCalled = true;
|
|
179
|
+
return migratedResult;
|
|
180
|
+
},
|
|
181
|
+
prompt: async () => "n",
|
|
182
|
+
isTty: true,
|
|
183
|
+
});
|
|
184
|
+
expect(cutoverCalled).toBe(false);
|
|
185
|
+
expect(result.outcome).toBe("declined");
|
|
186
|
+
expect(log.join("\n")).toContain("parachute migrate --to-supervised");
|
|
187
|
+
} finally {
|
|
188
|
+
h.cleanup();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("accept but cutover fails → migrate-failed (NOT migrated)", async () => {
|
|
193
|
+
const h = makeHarness();
|
|
194
|
+
try {
|
|
195
|
+
const result = await offerMigrateToSupervised({
|
|
196
|
+
configDir: h.configDir,
|
|
197
|
+
manifestPath: h.manifestPath,
|
|
198
|
+
log: () => {},
|
|
199
|
+
isHubUnitInstalled: () => false,
|
|
200
|
+
hasPriorDetached: () => true,
|
|
201
|
+
cutover: async () => ({ outcome: "port-stuck", port: 1939, messages: ["stuck"] }),
|
|
202
|
+
prompt: async () => "yes",
|
|
203
|
+
isTty: true,
|
|
204
|
+
});
|
|
205
|
+
expect(result.outcome).toBe("migrate-failed");
|
|
206
|
+
} finally {
|
|
207
|
+
h.cleanup();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("offerMigrateToSupervised — non-interactive (no TTY)", () => {
|
|
213
|
+
test("PRINTS the exact command and NEVER runs the cutover", async () => {
|
|
214
|
+
const h = makeHarness();
|
|
215
|
+
try {
|
|
216
|
+
let cutoverCalled = false;
|
|
217
|
+
const log: string[] = [];
|
|
218
|
+
const result = await offerMigrateToSupervised({
|
|
219
|
+
configDir: h.configDir,
|
|
220
|
+
manifestPath: h.manifestPath,
|
|
221
|
+
log: (l) => log.push(l),
|
|
222
|
+
isHubUnitInstalled: () => false,
|
|
223
|
+
hasPriorDetached: () => true,
|
|
224
|
+
cutover: async () => {
|
|
225
|
+
cutoverCalled = true;
|
|
226
|
+
return migratedResult;
|
|
227
|
+
},
|
|
228
|
+
prompt: async () => {
|
|
229
|
+
throw new Error("prompt must not be called in a non-TTY context");
|
|
230
|
+
},
|
|
231
|
+
isTty: false,
|
|
232
|
+
});
|
|
233
|
+
expect(result.outcome).toBe("printed");
|
|
234
|
+
expect(cutoverCalled).toBe(false);
|
|
235
|
+
expect(log.join("\n")).toContain("parachute migrate --to-supervised");
|
|
236
|
+
} finally {
|
|
237
|
+
h.cleanup();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -439,6 +439,62 @@ describe("migrate — interactive + flag behavior", () => {
|
|
|
439
439
|
}
|
|
440
440
|
});
|
|
441
441
|
|
|
442
|
+
test("§7.3 refuses while a UNIT-MANAGED hub runs (no pidfile, detected via manager)", async () => {
|
|
443
|
+
const h = makeHarness();
|
|
444
|
+
try {
|
|
445
|
+
seedSafelist(h.configDir);
|
|
446
|
+
// NO hub pidfile — this is a supervised/unit-managed hub. Before the §7.3
|
|
447
|
+
// fix the refuse-while-running guard FAILED OPEN here and migrate would
|
|
448
|
+
// archive ~/.parachute out from under the live hub.
|
|
449
|
+
touch(join(h.configDir, "daily.db"), "X");
|
|
450
|
+
|
|
451
|
+
const logs: string[] = [];
|
|
452
|
+
const code = await migrate({
|
|
453
|
+
configDir: h.configDir,
|
|
454
|
+
now: () => APRIL_19,
|
|
455
|
+
log: (l) => logs.push(l),
|
|
456
|
+
prompt: async () => {
|
|
457
|
+
throw new Error("prompt must not be called");
|
|
458
|
+
},
|
|
459
|
+
yes: true,
|
|
460
|
+
isTty: true,
|
|
461
|
+
// No pidfile alive; the manager reports the hub unit active.
|
|
462
|
+
alive: () => false,
|
|
463
|
+
hubUnitState: () => ({ state: "active" }),
|
|
464
|
+
});
|
|
465
|
+
expect(code).toBe(1);
|
|
466
|
+
expect(logs.join("\n")).toMatch(/services are currently running/i);
|
|
467
|
+
// No archive happened — the guard held.
|
|
468
|
+
expect(existsSync(join(h.configDir, ".archive-2026-04-19"))).toBe(false);
|
|
469
|
+
expect(existsSync(join(h.configDir, "daily.db"))).toBe(true);
|
|
470
|
+
} finally {
|
|
471
|
+
h.cleanup();
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("§7.3 archive PROCEEDS when the manager reports the hub inactive (no false-positive)", async () => {
|
|
476
|
+
const h = makeHarness();
|
|
477
|
+
try {
|
|
478
|
+
seedSafelist(h.configDir);
|
|
479
|
+
touch(join(h.configDir, "daily.db"), "X");
|
|
480
|
+
|
|
481
|
+
const code = await migrate({
|
|
482
|
+
configDir: h.configDir,
|
|
483
|
+
now: () => APRIL_19,
|
|
484
|
+
log: () => {},
|
|
485
|
+
yes: true,
|
|
486
|
+
isTty: true,
|
|
487
|
+
alive: () => false,
|
|
488
|
+
hubUnitState: () => ({ state: "inactive" }),
|
|
489
|
+
});
|
|
490
|
+
expect(code).toBe(0);
|
|
491
|
+
// The sweep ran — daily.db is archived.
|
|
492
|
+
expect(existsSync(join(h.configDir, ".archive-2026-04-19", "daily.db"))).toBe(true);
|
|
493
|
+
} finally {
|
|
494
|
+
h.cleanup();
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
442
498
|
test("refuses non-TTY without --yes (CI / pipe safety)", async () => {
|
|
443
499
|
const h = makeHarness();
|
|
444
500
|
try {
|
|
@@ -713,7 +769,83 @@ describe("listRunningServices", () => {
|
|
|
713
769
|
h.configDir,
|
|
714
770
|
join(h.configDir, "services.json"),
|
|
715
771
|
() => false,
|
|
772
|
+
// Explicit no-unit stub so this stays deterministic even on a dev box
|
|
773
|
+
// that happens to have a hub unit installed (the §7.3 manager check).
|
|
774
|
+
() => ({ state: "no-unit" }),
|
|
775
|
+
);
|
|
776
|
+
expect(running).toEqual([]);
|
|
777
|
+
} finally {
|
|
778
|
+
h.cleanup();
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
test("§7.3 archive-guard: a unit-managed hub (no pidfile) is detected RUNNING via the manager", () => {
|
|
783
|
+
const h = makeHarness();
|
|
784
|
+
try {
|
|
785
|
+
seedSafelist(h.configDir);
|
|
786
|
+
// NO hub pidfile (unit-managed hubs don't write one) → the pidfile check
|
|
787
|
+
// (alive => false) reports the hub as not-running. Before the fix this
|
|
788
|
+
// FAILED OPEN. The platform-manager check sees `active` and holds.
|
|
789
|
+
const running = listRunningServices(
|
|
790
|
+
h.configDir,
|
|
791
|
+
join(h.configDir, "services.json"),
|
|
792
|
+
() => false,
|
|
793
|
+
() => ({ state: "active" }),
|
|
794
|
+
);
|
|
795
|
+
expect(running).toContain("hub");
|
|
796
|
+
} finally {
|
|
797
|
+
h.cleanup();
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test("§7.3 archive-guard: `activating` also reads as running", () => {
|
|
802
|
+
const h = makeHarness();
|
|
803
|
+
try {
|
|
804
|
+
seedSafelist(h.configDir);
|
|
805
|
+
const running = listRunningServices(
|
|
806
|
+
h.configDir,
|
|
807
|
+
join(h.configDir, "services.json"),
|
|
808
|
+
() => false,
|
|
809
|
+
() => ({ state: "activating" }),
|
|
810
|
+
);
|
|
811
|
+
expect(running).toContain("hub");
|
|
812
|
+
} finally {
|
|
813
|
+
h.cleanup();
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("§7.3 archive-guard: `failed` / `inactive` / `no-manager` are NOT running", () => {
|
|
818
|
+
const h = makeHarness();
|
|
819
|
+
try {
|
|
820
|
+
seedSafelist(h.configDir);
|
|
821
|
+
for (const state of ["failed", "inactive", "no-manager", "no-unit", "unknown"] as const) {
|
|
822
|
+
const running = listRunningServices(
|
|
823
|
+
h.configDir,
|
|
824
|
+
join(h.configDir, "services.json"),
|
|
825
|
+
() => false,
|
|
826
|
+
() => ({ state }),
|
|
827
|
+
);
|
|
828
|
+
expect(running).not.toContain("hub");
|
|
829
|
+
}
|
|
830
|
+
} finally {
|
|
831
|
+
h.cleanup();
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("§7.3 archive-guard: a manager-query that throws never crashes the guard (fails closed-ish)", () => {
|
|
836
|
+
const h = makeHarness();
|
|
837
|
+
try {
|
|
838
|
+
seedSafelist(h.configDir);
|
|
839
|
+
const running = listRunningServices(
|
|
840
|
+
h.configDir,
|
|
841
|
+
join(h.configDir, "services.json"),
|
|
842
|
+
() => false,
|
|
843
|
+
() => {
|
|
844
|
+
throw new Error("systemctl exploded");
|
|
845
|
+
},
|
|
716
846
|
);
|
|
847
|
+
// The throw is swallowed; the pidfile check (no pid) governs → not running.
|
|
848
|
+
// (The guard must not crash; it just gets no extra signal from the manager.)
|
|
717
849
|
expect(running).toEqual([]);
|
|
718
850
|
} finally {
|
|
719
851
|
h.cleanup();
|