@openparachute/hub 0.6.3-rc.1 → 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__/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__/lifecycle.test.ts +431 -1886
- 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__/status-supervisor.test.ts +12 -77
- package/src/__tests__/status.test.ts +157 -708
- 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/cli.ts +85 -10
- 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/lifecycle.ts +184 -873
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/status.ts +35 -282
- 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 +28 -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 +20 -2
- package/src/migrate-offer.ts +186 -0
- package/src/process-state.ts +19 -3
- package/src/supervisor.ts +29 -24
- 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();
|
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
import { upsertService } from "../services-manifest.ts";
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Phase
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `supervisor` seams — no real launchd/systemd/
|
|
19
|
-
*
|
|
20
|
-
*
|
|
15
|
+
* Phase 5b (design §6.4): `status` reads the hub row from the platform manager +
|
|
16
|
+
* `/health` and the module rows from the running supervisor (`GET /api/modules`)
|
|
17
|
+
* — the ONLY runtime now that the detached pidfile arm is retired. Everything
|
|
18
|
+
* below is driven through the `supervisor` seams — no real launchd/systemd/
|
|
19
|
+
* socket/HTTP/db call. The manifest-derived rendering (URLs, version, persisted
|
|
20
|
+
* start-error) is covered in `status.test.ts` (also supervised-arm now).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
function makeTempPath(): { path: string; cleanup: () => void; configDir: string } {
|
|
@@ -90,7 +90,6 @@ function supervisorOpts(configDir: string, path: string, o: SupervisorArmOpts) {
|
|
|
90
90
|
installSourceDeps: STUB_INSTALL_SOURCE,
|
|
91
91
|
hubSrcDir: "/nonexistent/hub/src",
|
|
92
92
|
supervisor: {
|
|
93
|
-
unitInstalled: true,
|
|
94
93
|
hubUnitDeps: FAKE_HUB_UNIT_DEPS,
|
|
95
94
|
queryHubUnitState: () => o.managerState,
|
|
96
95
|
probeHubHealth: async () => o.hubHealthy,
|
|
@@ -224,7 +223,6 @@ describe("status — Phase 3c supervisor arm: hub row", () => {
|
|
|
224
223
|
}),
|
|
225
224
|
// Replace the query with one that throws — status must not crash.
|
|
226
225
|
supervisor: {
|
|
227
|
-
unitInstalled: true,
|
|
228
226
|
hubUnitDeps: FAKE_HUB_UNIT_DEPS,
|
|
229
227
|
queryHubUnitState: () => {
|
|
230
228
|
throw new Error("systemctl exploded");
|
|
@@ -498,72 +496,9 @@ describe("status — Phase 3c supervisor arm: module rows", () => {
|
|
|
498
496
|
});
|
|
499
497
|
});
|
|
500
498
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
path,
|
|
508
|
-
);
|
|
509
|
-
// No `supervisor` block at all → resolveStatusSupervisor returns
|
|
510
|
-
// unitInstalled:false → the detached arm. The probe (fetchImpl) runs,
|
|
511
|
-
// proving we took the pidfile/probe path, not the supervisor path.
|
|
512
|
-
let probed = false;
|
|
513
|
-
const lines: string[] = [];
|
|
514
|
-
const code = await status({
|
|
515
|
-
manifestPath: path,
|
|
516
|
-
configDir,
|
|
517
|
-
installSourceDeps: STUB_INSTALL_SOURCE,
|
|
518
|
-
hubSrcDir: "/nonexistent/hub/src",
|
|
519
|
-
fetchImpl: async () => {
|
|
520
|
-
probed = true;
|
|
521
|
-
return new Response(null, { status: 200 });
|
|
522
|
-
},
|
|
523
|
-
print: (l) => lines.push(l),
|
|
524
|
-
});
|
|
525
|
-
expect(code).toBe(0);
|
|
526
|
-
// The detached arm probes the service's /health endpoint.
|
|
527
|
-
expect(probed).toBe(true);
|
|
528
|
-
expect(lines.some((l) => l.includes("parachute-vault"))).toBe(true);
|
|
529
|
-
} finally {
|
|
530
|
-
cleanup();
|
|
531
|
-
}
|
|
532
|
-
});
|
|
533
|
-
|
|
534
|
-
test("supervisor block with unitInstalled:false → detached arm (probe runs)", async () => {
|
|
535
|
-
const { path, configDir, cleanup } = makeTempPath();
|
|
536
|
-
try {
|
|
537
|
-
upsertService(
|
|
538
|
-
{ name: "parachute-vault", port: 1940, paths: ["/"], health: "/health", version: "0.6.2" },
|
|
539
|
-
path,
|
|
540
|
-
);
|
|
541
|
-
let probed = false;
|
|
542
|
-
let queried = false;
|
|
543
|
-
const code = await status({
|
|
544
|
-
manifestPath: path,
|
|
545
|
-
configDir,
|
|
546
|
-
installSourceDeps: STUB_INSTALL_SOURCE,
|
|
547
|
-
hubSrcDir: "/nonexistent/hub/src",
|
|
548
|
-
fetchImpl: async () => {
|
|
549
|
-
probed = true;
|
|
550
|
-
return new Response(null, { status: 200 });
|
|
551
|
-
},
|
|
552
|
-
supervisor: {
|
|
553
|
-
unitInstalled: false,
|
|
554
|
-
queryHubUnitState: () => {
|
|
555
|
-
queried = true;
|
|
556
|
-
return { state: "active" };
|
|
557
|
-
},
|
|
558
|
-
},
|
|
559
|
-
print: () => {},
|
|
560
|
-
});
|
|
561
|
-
expect(code).toBe(0);
|
|
562
|
-
// Detached arm: the per-service probe runs and the manager is NOT queried.
|
|
563
|
-
expect(probed).toBe(true);
|
|
564
|
-
expect(queried).toBe(false);
|
|
565
|
-
} finally {
|
|
566
|
-
cleanup();
|
|
567
|
-
}
|
|
568
|
-
});
|
|
569
|
-
});
|
|
499
|
+
// The "Phase 3c discriminant" block (no-supervisor / unitInstalled:false →
|
|
500
|
+
// detached pidfile-probe arm) was removed in Phase 5b: the detached arm is
|
|
501
|
+
// retired, so there is no discriminant — `status` always reads the platform
|
|
502
|
+
// manager + supervisor. The supervisor-path readout is exercised throughout the
|
|
503
|
+
// suites above; a box with no hub unit degrades gracefully (manager `no-unit` /
|
|
504
|
+
// `/health` down → inactive rows), which the hub-row + module-row suites cover.
|