@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
|
@@ -1,28 +1,46 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, openSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
|
+
type LifecycleOpts,
|
|
6
7
|
defaultAlive,
|
|
7
8
|
defaultKill,
|
|
8
|
-
defaultSpawner,
|
|
9
9
|
logs,
|
|
10
10
|
restart,
|
|
11
11
|
start,
|
|
12
12
|
stop,
|
|
13
13
|
} from "../commands/lifecycle.ts";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
-
import { validateAccessToken } from "../jwt-sign.ts";
|
|
14
|
+
import type { HubUnitManagerOpResult } from "../hub-unit.ts";
|
|
15
|
+
import type { MigrateOfferOpts, MigrateOfferResult } from "../migrate-offer.ts";
|
|
18
16
|
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
17
|
+
type ModuleOp,
|
|
18
|
+
ModuleOpHttpError,
|
|
19
|
+
type ModuleOpResult,
|
|
20
|
+
NoOperatorTokenError,
|
|
21
|
+
} from "../module-ops-client.ts";
|
|
22
|
+
import { ensureLogPath, writePid } from "../process-state.ts";
|
|
23
|
+
import { upsertService } from "../services-manifest.ts";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Phase 5b: the supervised path is the ONLY runtime. The detached spawners were
|
|
27
|
+
// retired, so these suites exercise (a) the supervisor-path dispatch (hub UNIT
|
|
28
|
+
// installed → drive the running supervisor / platform manager), (b) the no-unit
|
|
29
|
+
// path (§7.5 auto-offer / actionable error — NEVER a detached spawn), and (c)
|
|
30
|
+
// the group-aware kill/alive primitives that survive for `logs` + future use.
|
|
31
|
+
//
|
|
32
|
+
// Coverage that MOVED with the retirement (no longer asserted here):
|
|
33
|
+
// - per-module spawn / env injection / PORT override / cwd / startCmd
|
|
34
|
+
// resolution / missing-dependency preflight → now the supervisor's job,
|
|
35
|
+
// asserted in `supervisor.test.ts` + `api-modules-ops.test.ts`.
|
|
36
|
+
// - hub#194 settle + hub#487 port-readiness → supervisor post-spawn readiness,
|
|
37
|
+
// asserted in `supervisor.test.ts`.
|
|
38
|
+
// - process-GROUP spawn (`detached: true`) → the supervisor's group-spawn,
|
|
39
|
+
// asserted in `supervisor.test.ts` (`defaultKillGroup` + the real round-trip).
|
|
40
|
+
// - `start|stop|restart hub` via `ensureHubRunning`/`stopHub` → now the
|
|
41
|
+
// platform-manager path (`ensureHubUnit`/`stopHubUnit`/`restartHubUnit`),
|
|
42
|
+
// asserted in the dual-dispatch suites below.
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
26
44
|
|
|
27
45
|
interface Harness {
|
|
28
46
|
configDir: string;
|
|
@@ -52,1477 +70,598 @@ function seedVault(manifestPath: string): void {
|
|
|
52
70
|
);
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
version: "0.0.1",
|
|
63
|
-
},
|
|
64
|
-
manifestPath,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface ThirdPartySeed {
|
|
69
|
-
installDir: string;
|
|
70
|
-
manifestName?: string;
|
|
71
|
-
startCmd?: readonly string[];
|
|
72
|
-
port?: number;
|
|
73
|
+
interface SupervisorStub {
|
|
74
|
+
opts: NonNullable<LifecycleOpts["supervisor"]>;
|
|
75
|
+
driveCalls: Array<{ short: string; op: ModuleOp }>;
|
|
76
|
+
ensureCalls: Array<{ port?: number }>;
|
|
77
|
+
stopHubCalls: number;
|
|
78
|
+
restartHubCalls: number;
|
|
79
|
+
healthProbes: number;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
83
|
+
* Build a `supervisor` seam that forces the unit-installed arm and records the
|
|
84
|
+
* supervisor / manager calls. `driveResponder` lets a test return a result or
|
|
85
|
+
* throw a module-ops error per (short, op). The default responder returns a
|
|
86
|
+
* benign sync-op result. `health` controls `probeHubHealth`.
|
|
80
87
|
*/
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const calls: Array<{
|
|
128
|
-
cmd: readonly string[];
|
|
129
|
-
logFile: string;
|
|
130
|
-
env?: Record<string, string>;
|
|
131
|
-
cwd?: string;
|
|
132
|
-
}> = [];
|
|
133
|
-
let i = 0;
|
|
134
|
-
return {
|
|
135
|
-
calls,
|
|
136
|
-
spawn(cmd, logFile, opts) {
|
|
137
|
-
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
138
|
-
return pidSequence[i++] ?? 99999;
|
|
88
|
+
function makeSupervisorStub(opts?: {
|
|
89
|
+
health?: boolean;
|
|
90
|
+
ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
|
|
91
|
+
ensureMessages?: string[];
|
|
92
|
+
driveResponder?: (short: string, op: ModuleOp) => ModuleOpResult | Promise<ModuleOpResult>;
|
|
93
|
+
stopHubResult?: HubUnitManagerOpResult;
|
|
94
|
+
restartHubResult?: HubUnitManagerOpResult;
|
|
95
|
+
}): SupervisorStub {
|
|
96
|
+
const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
|
|
97
|
+
const ensureCalls: Array<{ port?: number }> = [];
|
|
98
|
+
const stub: SupervisorStub = {
|
|
99
|
+
driveCalls,
|
|
100
|
+
ensureCalls,
|
|
101
|
+
stopHubCalls: 0,
|
|
102
|
+
restartHubCalls: 0,
|
|
103
|
+
healthProbes: 0,
|
|
104
|
+
opts: {
|
|
105
|
+
unitInstalled: true,
|
|
106
|
+
// openDb is never exercised by the stub driveModuleOp, but the dispatch
|
|
107
|
+
// opens+closes it around the call — hand back a no-op closer.
|
|
108
|
+
openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
|
|
109
|
+
driveModuleOp: async (short, op) => {
|
|
110
|
+
driveCalls.push({ short, op });
|
|
111
|
+
if (opts?.driveResponder) return await opts.driveResponder(short, op);
|
|
112
|
+
return { status: 200, body: { short, state: { status: "running" } } };
|
|
113
|
+
},
|
|
114
|
+
ensureHubUnit: async (o) => {
|
|
115
|
+
ensureCalls.push({ port: o.port });
|
|
116
|
+
return {
|
|
117
|
+
outcome: opts?.ensureOutcome ?? "already-up",
|
|
118
|
+
port: o.port ?? 1939,
|
|
119
|
+
messages: opts?.ensureMessages ?? [],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
stopHubUnit: () => {
|
|
123
|
+
stub.stopHubCalls++;
|
|
124
|
+
return opts?.stopHubResult ?? { outcome: "ok", messages: [] };
|
|
125
|
+
},
|
|
126
|
+
restartHubUnit: () => {
|
|
127
|
+
stub.restartHubCalls++;
|
|
128
|
+
return opts?.restartHubResult ?? { outcome: "ok", messages: [] };
|
|
129
|
+
},
|
|
130
|
+
probeHubHealth: async () => {
|
|
131
|
+
stub.healthProbes++;
|
|
132
|
+
return opts?.health ?? true;
|
|
133
|
+
},
|
|
139
134
|
},
|
|
140
135
|
};
|
|
136
|
+
return stub;
|
|
141
137
|
}
|
|
142
138
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const logs: string[] = [];
|
|
148
|
-
const code = await start(undefined, {
|
|
149
|
-
configDir: h.configDir,
|
|
150
|
-
manifestPath: h.manifestPath,
|
|
151
|
-
log: (l) => logs.push(l),
|
|
152
|
-
});
|
|
153
|
-
expect(code).toBe(1);
|
|
154
|
-
expect(logs.join("\n")).toMatch(/No services installed/);
|
|
155
|
-
} finally {
|
|
156
|
-
h.cleanup();
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("errors cleanly when targeting an uninstalled service", async () => {
|
|
161
|
-
const h = makeHarness();
|
|
162
|
-
try {
|
|
163
|
-
seedVault(h.manifestPath);
|
|
164
|
-
const logs: string[] = [];
|
|
165
|
-
const code = await start("notes", {
|
|
166
|
-
configDir: h.configDir,
|
|
167
|
-
manifestPath: h.manifestPath,
|
|
168
|
-
log: (l) => logs.push(l),
|
|
169
|
-
});
|
|
170
|
-
expect(code).toBe(1);
|
|
171
|
-
expect(logs.join("\n")).toMatch(/notes isn't installed/);
|
|
172
|
-
} finally {
|
|
173
|
-
h.cleanup();
|
|
174
|
-
}
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("spawns vault with parachute-vault serve, writes PID", async () => {
|
|
178
|
-
const h = makeHarness();
|
|
179
|
-
try {
|
|
180
|
-
seedVault(h.manifestPath);
|
|
181
|
-
const spawner = makeSpawner([4242]);
|
|
182
|
-
const logs: string[] = [];
|
|
183
|
-
const code = await start("vault", {
|
|
184
|
-
configDir: h.configDir,
|
|
185
|
-
manifestPath: h.manifestPath,
|
|
186
|
-
spawner,
|
|
187
|
-
log: (l) => logs.push(l),
|
|
188
|
-
});
|
|
189
|
-
expect(code).toBe(0);
|
|
190
|
-
expect(spawner.calls).toHaveLength(1);
|
|
191
|
-
expect(spawner.calls[0]?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
192
|
-
expect(spawner.calls[0]?.logFile).toBe(logPath("vault", h.configDir));
|
|
193
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
194
|
-
expect(logs.join("\n")).toMatch(/vault started \(pid 4242\)/);
|
|
195
|
-
} finally {
|
|
196
|
-
h.cleanup();
|
|
197
|
-
}
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("missing startCmd binary → friendly missing-dependency message + no spawn", async () => {
|
|
201
|
-
const h = makeHarness();
|
|
202
|
-
try {
|
|
203
|
-
seedVault(h.manifestPath);
|
|
204
|
-
const spawner = makeSpawner([4242]);
|
|
205
|
-
const logs: string[] = [];
|
|
206
|
-
const code = await start("vault", {
|
|
207
|
-
configDir: h.configDir,
|
|
208
|
-
manifestPath: h.manifestPath,
|
|
209
|
-
spawner,
|
|
210
|
-
// Force the preflight's missing-binary branch: parachute-vault not on PATH.
|
|
211
|
-
which: () => null,
|
|
212
|
-
log: (l) => logs.push(l),
|
|
213
|
-
});
|
|
214
|
-
expect(code).toBe(1);
|
|
215
|
-
// Preflight fired before the spawn — the stub spawner is never called.
|
|
216
|
-
expect(spawner.calls).toHaveLength(0);
|
|
217
|
-
const out = logs.join("\n");
|
|
218
|
-
expect(out).toMatch(/vault failed to start/);
|
|
219
|
-
// The friendly install block names the binary + its install path.
|
|
220
|
-
expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
|
|
221
|
-
expect(out).toContain("parachute install vault");
|
|
222
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
223
|
-
} finally {
|
|
224
|
-
h.cleanup();
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("missing startCmd binary persists lastStartError so a later status surfaces it", async () => {
|
|
229
|
-
const h = makeHarness();
|
|
230
|
-
try {
|
|
231
|
-
seedVault(h.manifestPath);
|
|
232
|
-
await start("vault", {
|
|
233
|
-
configDir: h.configDir,
|
|
234
|
-
manifestPath: h.manifestPath,
|
|
235
|
-
spawner: makeSpawner([4242]),
|
|
236
|
-
which: () => null,
|
|
237
|
-
log: () => {},
|
|
238
|
-
});
|
|
239
|
-
const entry = readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault");
|
|
240
|
-
expect(entry?.lastStartError?.error_type).toBe("missing_dependency");
|
|
241
|
-
expect(entry?.lastStartError?.binary).toBe("parachute-vault");
|
|
242
|
-
expect(entry?.lastStartError?.at).toBeDefined();
|
|
243
|
-
} finally {
|
|
244
|
-
h.cleanup();
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
test("a successful start clears a previously-recorded lastStartError", async () => {
|
|
249
|
-
const h = makeHarness();
|
|
250
|
-
try {
|
|
251
|
-
seedVault(h.manifestPath);
|
|
252
|
-
// First start fails (binary missing) → records the error.
|
|
253
|
-
await start("vault", {
|
|
254
|
-
configDir: h.configDir,
|
|
255
|
-
manifestPath: h.manifestPath,
|
|
256
|
-
spawner: makeSpawner([1]),
|
|
257
|
-
which: () => null,
|
|
258
|
-
log: () => {},
|
|
259
|
-
});
|
|
260
|
-
expect(
|
|
261
|
-
readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
|
|
262
|
-
?.lastStartError,
|
|
263
|
-
).toBeDefined();
|
|
264
|
-
// Second start succeeds (binary present via the permissive default which
|
|
265
|
-
// — stub spawner path) → clears the recorded error.
|
|
266
|
-
await start("vault", {
|
|
267
|
-
configDir: h.configDir,
|
|
268
|
-
manifestPath: h.manifestPath,
|
|
269
|
-
spawner: makeSpawner([4242]),
|
|
270
|
-
log: () => {},
|
|
271
|
-
});
|
|
272
|
-
expect(
|
|
273
|
-
readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
|
|
274
|
-
?.lastStartError,
|
|
275
|
-
).toBeUndefined();
|
|
276
|
-
} finally {
|
|
277
|
-
h.cleanup();
|
|
278
|
-
}
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("notes start command includes configured port and notes-serve shim path", async () => {
|
|
282
|
-
const h = makeHarness();
|
|
283
|
-
try {
|
|
284
|
-
seedNotes(h.manifestPath);
|
|
285
|
-
const spawner = makeSpawner([5151]);
|
|
286
|
-
const code = await start("notes", {
|
|
287
|
-
configDir: h.configDir,
|
|
288
|
-
manifestPath: h.manifestPath,
|
|
289
|
-
spawner,
|
|
290
|
-
log: () => {},
|
|
291
|
-
});
|
|
292
|
-
expect(code).toBe(0);
|
|
293
|
-
const cmd = spawner.calls[0]?.cmd ?? [];
|
|
294
|
-
expect(cmd[0]).toBe("bun");
|
|
295
|
-
expect(cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
|
|
296
|
-
const portIdx = cmd.indexOf("--port");
|
|
297
|
-
expect(portIdx).toBeGreaterThan(-1);
|
|
298
|
-
expect(cmd[portIdx + 1]).toBe("5173");
|
|
299
|
-
const mountIdx = cmd.indexOf("--mount");
|
|
300
|
-
expect(mountIdx).toBeGreaterThan(-1);
|
|
301
|
-
expect(cmd[mountIdx + 1]).toBe("/notes");
|
|
302
|
-
} finally {
|
|
303
|
-
h.cleanup();
|
|
304
|
-
}
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("no-op when already running", async () => {
|
|
308
|
-
const h = makeHarness();
|
|
309
|
-
try {
|
|
310
|
-
seedVault(h.manifestPath);
|
|
311
|
-
writePid("vault", 4242, h.configDir);
|
|
312
|
-
const spawner = makeSpawner([9999]);
|
|
313
|
-
const logs: string[] = [];
|
|
314
|
-
const code = await start("vault", {
|
|
315
|
-
configDir: h.configDir,
|
|
316
|
-
manifestPath: h.manifestPath,
|
|
317
|
-
spawner,
|
|
318
|
-
alive: () => true,
|
|
319
|
-
log: (l) => logs.push(l),
|
|
320
|
-
});
|
|
321
|
-
expect(code).toBe(0);
|
|
322
|
-
expect(spawner.calls).toHaveLength(0);
|
|
323
|
-
expect(logs.join("\n")).toMatch(/already running \(pid 4242\)/);
|
|
324
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
325
|
-
} finally {
|
|
326
|
-
h.cleanup();
|
|
327
|
-
}
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
test("clears stale PID file before spawning fresh", async () => {
|
|
331
|
-
const h = makeHarness();
|
|
332
|
-
try {
|
|
333
|
-
seedVault(h.manifestPath);
|
|
334
|
-
writePid("vault", 4242, h.configDir);
|
|
335
|
-
const spawner = makeSpawner([7777]);
|
|
336
|
-
const code = await start("vault", {
|
|
337
|
-
configDir: h.configDir,
|
|
338
|
-
manifestPath: h.manifestPath,
|
|
339
|
-
spawner,
|
|
340
|
-
// Stale 4242 is dead; the freshly spawned 7777 is alive — the
|
|
341
|
-
// post-spawn settle (hub#194) calls alive(pid) on the new pid,
|
|
342
|
-
// so we differentiate per-pid rather than blanket-false.
|
|
343
|
-
alive: (pid) => pid === 7777,
|
|
344
|
-
sleep: async () => {},
|
|
345
|
-
log: () => {},
|
|
346
|
-
});
|
|
347
|
-
expect(code).toBe(0);
|
|
348
|
-
expect(spawner.calls).toHaveLength(1);
|
|
349
|
-
expect(readPid("vault", h.configDir)).toBe(7777);
|
|
350
|
-
} finally {
|
|
351
|
-
h.cleanup();
|
|
352
|
-
}
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
test("start (no svc) targets every installed + known service", async () => {
|
|
356
|
-
const h = makeHarness();
|
|
357
|
-
try {
|
|
358
|
-
seedVault(h.manifestPath);
|
|
359
|
-
seedNotes(h.manifestPath);
|
|
360
|
-
const spawner = makeSpawner([4242, 5151]);
|
|
361
|
-
const code = await start(undefined, {
|
|
362
|
-
configDir: h.configDir,
|
|
363
|
-
manifestPath: h.manifestPath,
|
|
364
|
-
spawner,
|
|
365
|
-
log: () => {},
|
|
366
|
-
});
|
|
367
|
-
expect(code).toBe(0);
|
|
368
|
-
expect(spawner.calls).toHaveLength(2);
|
|
369
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
370
|
-
expect(readPid("notes", h.configDir)).toBe(5151);
|
|
371
|
-
} finally {
|
|
372
|
-
h.cleanup();
|
|
373
|
-
}
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
test("legacy parachute-lens manifest entry still starts under the notes spec", async () => {
|
|
377
|
-
// Users who installed during the brief Notes→Lens window (Apr 19–22)
|
|
378
|
-
// will still have `parachute-lens` in services.json until their notes
|
|
379
|
-
// package next boots and rewrites the row. Without the manifest alias,
|
|
380
|
-
// shortNameForManifest returns undefined, resolveTargets skips the
|
|
381
|
-
// entry, and they get "No manageable services" with no hint.
|
|
382
|
-
const h = makeHarness();
|
|
383
|
-
try {
|
|
384
|
-
upsertService(
|
|
385
|
-
{
|
|
386
|
-
name: "parachute-lens",
|
|
387
|
-
port: 5173,
|
|
388
|
-
paths: ["/lens"],
|
|
389
|
-
health: "/lens/health",
|
|
390
|
-
version: "0.0.1",
|
|
391
|
-
},
|
|
392
|
-
h.manifestPath,
|
|
393
|
-
);
|
|
394
|
-
const spawner = makeSpawner([5151]);
|
|
395
|
-
const code = await start(undefined, {
|
|
396
|
-
configDir: h.configDir,
|
|
397
|
-
manifestPath: h.manifestPath,
|
|
398
|
-
spawner,
|
|
399
|
-
log: () => {},
|
|
400
|
-
});
|
|
401
|
-
expect(code).toBe(0);
|
|
402
|
-
expect(spawner.calls).toHaveLength(1);
|
|
403
|
-
expect(spawner.calls[0]?.cmd.some((a) => a.endsWith("notes-serve.ts"))).toBe(true);
|
|
404
|
-
expect(readPid("notes", h.configDir)).toBe(5151);
|
|
405
|
-
} finally {
|
|
406
|
-
h.cleanup();
|
|
407
|
-
}
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
test("passes PARACHUTE_HUB_ORIGIN from expose-state when set", async () => {
|
|
411
|
-
const h = makeHarness();
|
|
412
|
-
try {
|
|
413
|
-
seedVault(h.manifestPath);
|
|
414
|
-
writeFileSync(
|
|
415
|
-
join(h.configDir, "expose-state.json"),
|
|
416
|
-
JSON.stringify({
|
|
417
|
-
version: 1,
|
|
418
|
-
layer: "tailnet",
|
|
419
|
-
mode: "path",
|
|
420
|
-
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
421
|
-
port: 443,
|
|
422
|
-
funnel: false,
|
|
423
|
-
entries: [],
|
|
424
|
-
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
425
|
-
}),
|
|
426
|
-
);
|
|
427
|
-
const spawner = makeSpawner([4242]);
|
|
428
|
-
const code = await start("vault", {
|
|
429
|
-
configDir: h.configDir,
|
|
430
|
-
manifestPath: h.manifestPath,
|
|
431
|
-
spawner,
|
|
432
|
-
log: () => {},
|
|
433
|
-
});
|
|
434
|
-
expect(code).toBe(0);
|
|
435
|
-
// PORT is always set by `parachute start` (hub#356) from the
|
|
436
|
-
// services.json entry. PARACHUTE_HUB_ORIGIN comes from expose-state.
|
|
437
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
438
|
-
PORT: "1940",
|
|
439
|
-
PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
|
|
440
|
-
});
|
|
441
|
-
// OAuth issuer-mismatch fix: the spawn-env injection above is ephemeral
|
|
442
|
-
// (lost on the next launchd / systemd boot). `start vault` ALSO persists
|
|
443
|
-
// the public origin into vault/.env so the out-of-band daemon validates
|
|
444
|
-
// hub-minted JWTs' `iss` against it. Without this, every reconnect after
|
|
445
|
-
// a reboot / crash-restart 401s.
|
|
446
|
-
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
447
|
-
"https://parachute.taildf9ce2.ts.net",
|
|
448
|
-
);
|
|
449
|
-
} finally {
|
|
450
|
-
h.cleanup();
|
|
451
|
-
}
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
test("self-heals a stale-loopback vault/.env from a cloudflare expose-state on restart", async () => {
|
|
455
|
-
// Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
|
|
456
|
-
// loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
|
|
457
|
-
// wrote loopback). expose-state.json carries the real public origin. A
|
|
458
|
-
// plain `parachute start vault` must rewrite vault/.env to the public
|
|
459
|
-
// origin so the daemon stops 401ing hub tokens — the self-heal half of the
|
|
460
|
-
// Cloudflare 401 fix.
|
|
461
|
-
const h = makeHarness();
|
|
462
|
-
try {
|
|
463
|
-
seedVault(h.manifestPath);
|
|
464
|
-
writeFileSync(
|
|
465
|
-
join(h.configDir, "expose-state.json"),
|
|
466
|
-
JSON.stringify({
|
|
467
|
-
version: 1,
|
|
468
|
-
layer: "public",
|
|
469
|
-
mode: "subdomain",
|
|
470
|
-
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
471
|
-
port: 1939,
|
|
472
|
-
funnel: false,
|
|
473
|
-
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
474
|
-
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
475
|
-
}),
|
|
476
|
-
);
|
|
477
|
-
// Pre-seed vault/.env with a stale loopback value (the broken state).
|
|
478
|
-
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
479
|
-
writeFileSync(
|
|
480
|
-
join(h.configDir, "vault", ".env"),
|
|
481
|
-
"PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
|
|
482
|
-
);
|
|
483
|
-
const spawner = makeSpawner([4242]);
|
|
484
|
-
const code = await start("vault", {
|
|
485
|
-
configDir: h.configDir,
|
|
486
|
-
manifestPath: h.manifestPath,
|
|
487
|
-
spawner,
|
|
488
|
-
log: () => {},
|
|
489
|
-
});
|
|
490
|
-
expect(code).toBe(0);
|
|
491
|
-
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
492
|
-
"https://gitcoin-parachute.unforced.dev",
|
|
493
|
-
);
|
|
494
|
-
} finally {
|
|
495
|
-
h.cleanup();
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
test("does NOT persist a loopback origin into vault/.env (would shadow a later exposure)", async () => {
|
|
500
|
-
const h = makeHarness();
|
|
501
|
-
try {
|
|
502
|
-
seedVault(h.manifestPath);
|
|
503
|
-
writeHubPort(1939, h.configDir);
|
|
504
|
-
const spawner = makeSpawner([4242]);
|
|
505
|
-
const code = await start("vault", {
|
|
506
|
-
configDir: h.configDir,
|
|
507
|
-
manifestPath: h.manifestPath,
|
|
508
|
-
spawner,
|
|
509
|
-
log: () => {},
|
|
510
|
-
});
|
|
511
|
-
expect(code).toBe(0);
|
|
512
|
-
// Loopback is fine to inject into the ephemeral spawn env (local dev),
|
|
513
|
-
// but persisting it would brick the daemon path once exposure comes up:
|
|
514
|
-
// the baked loopback would shadow the real origin. So vault/.env stays
|
|
515
|
-
// absent of the key on a loopback-only start.
|
|
516
|
-
expect(existsSync(join(h.configDir, "vault", ".env"))).toBe(false);
|
|
517
|
-
} finally {
|
|
518
|
-
h.cleanup();
|
|
519
|
-
}
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
test("falls back to loopback origin from hub.port when not exposed", async () => {
|
|
523
|
-
const h = makeHarness();
|
|
524
|
-
try {
|
|
525
|
-
seedVault(h.manifestPath);
|
|
526
|
-
writeHubPort(1939, h.configDir);
|
|
527
|
-
const spawner = makeSpawner([4242]);
|
|
528
|
-
const code = await start("vault", {
|
|
529
|
-
configDir: h.configDir,
|
|
530
|
-
manifestPath: h.manifestPath,
|
|
531
|
-
spawner,
|
|
532
|
-
log: () => {},
|
|
533
|
-
});
|
|
534
|
-
expect(code).toBe(0);
|
|
535
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
536
|
-
PORT: "1940",
|
|
537
|
-
PARACHUTE_HUB_ORIGIN: "http://127.0.0.1:1939",
|
|
538
|
-
});
|
|
539
|
-
} finally {
|
|
540
|
-
h.cleanup();
|
|
541
|
-
}
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
test("--hub-origin override wins over expose-state", async () => {
|
|
545
|
-
const h = makeHarness();
|
|
546
|
-
try {
|
|
547
|
-
seedVault(h.manifestPath);
|
|
548
|
-
writeFileSync(
|
|
549
|
-
join(h.configDir, "expose-state.json"),
|
|
550
|
-
JSON.stringify({
|
|
551
|
-
version: 1,
|
|
552
|
-
layer: "tailnet",
|
|
553
|
-
mode: "path",
|
|
554
|
-
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
555
|
-
port: 443,
|
|
556
|
-
funnel: false,
|
|
557
|
-
entries: [],
|
|
558
|
-
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
559
|
-
}),
|
|
560
|
-
);
|
|
561
|
-
const spawner = makeSpawner([4242]);
|
|
562
|
-
const code = await start("vault", {
|
|
563
|
-
configDir: h.configDir,
|
|
564
|
-
manifestPath: h.manifestPath,
|
|
565
|
-
spawner,
|
|
566
|
-
hubOrigin: "https://override.example.com/",
|
|
567
|
-
log: () => {},
|
|
568
|
-
});
|
|
569
|
-
expect(code).toBe(0);
|
|
570
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
571
|
-
PORT: "1940",
|
|
572
|
-
PARACHUTE_HUB_ORIGIN: "https://override.example.com",
|
|
573
|
-
});
|
|
574
|
-
} finally {
|
|
575
|
-
h.cleanup();
|
|
576
|
-
}
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
test("omits env when no override, no exposure, no hub port", async () => {
|
|
580
|
-
const h = makeHarness();
|
|
581
|
-
try {
|
|
582
|
-
seedVault(h.manifestPath);
|
|
583
|
-
const spawner = makeSpawner([4242]);
|
|
584
|
-
const code = await start("vault", {
|
|
585
|
-
configDir: h.configDir,
|
|
586
|
-
manifestPath: h.manifestPath,
|
|
587
|
-
spawner,
|
|
588
|
-
log: () => {},
|
|
589
|
-
});
|
|
590
|
-
expect(code).toBe(0);
|
|
591
|
-
// PORT is always set (hub#356) — even with no override, no exposure,
|
|
592
|
-
// and no hub.port file, the spawn env carries the canonical PORT
|
|
593
|
-
// from services.json. Test renamed from "omits env" to reflect
|
|
594
|
-
// the new minimum-env shape.
|
|
595
|
-
expect(spawner.calls[0]?.env).toEqual({ PORT: "1940" });
|
|
596
|
-
} finally {
|
|
597
|
-
h.cleanup();
|
|
598
|
-
}
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
test("merges <configDir>/<svc>/.env into the spawn env", async () => {
|
|
602
|
-
// Scribe's API key prompt writes GROQ_API_KEY into ~/.parachute/scribe/.env.
|
|
603
|
-
// Scribe itself doesn't auto-load .env, so `parachute start scribe` has to
|
|
604
|
-
// forward the values into the child env or the API key won't take effect.
|
|
605
|
-
const h = makeHarness();
|
|
606
|
-
try {
|
|
607
|
-
upsertService(
|
|
608
|
-
{
|
|
609
|
-
name: "parachute-scribe",
|
|
610
|
-
port: 1943,
|
|
611
|
-
paths: ["/scribe"],
|
|
612
|
-
health: "/scribe/health",
|
|
613
|
-
version: "0.1.0",
|
|
614
|
-
},
|
|
615
|
-
h.manifestPath,
|
|
616
|
-
);
|
|
617
|
-
ensureLogPath("scribe", h.configDir);
|
|
618
|
-
writeFileSync(
|
|
619
|
-
join(h.configDir, "scribe", ".env"),
|
|
620
|
-
'GROQ_API_KEY=gsk_real_value\nQUOTED="quoted_val"\n',
|
|
621
|
-
);
|
|
622
|
-
const spawner = makeSpawner([7777]);
|
|
623
|
-
const code = await start("scribe", {
|
|
624
|
-
configDir: h.configDir,
|
|
625
|
-
manifestPath: h.manifestPath,
|
|
626
|
-
spawner,
|
|
627
|
-
log: () => {},
|
|
628
|
-
});
|
|
629
|
-
expect(code).toBe(0);
|
|
630
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
631
|
-
PORT: "1943",
|
|
632
|
-
GROQ_API_KEY: "gsk_real_value",
|
|
633
|
-
QUOTED: "quoted_val",
|
|
634
|
-
});
|
|
635
|
-
} finally {
|
|
636
|
-
h.cleanup();
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
test("hub-origin override wins over conflicting key in service .env", async () => {
|
|
641
|
-
// Defense: `start --hub-origin <url>` is the authoritative source for
|
|
642
|
-
// PARACHUTE_HUB_ORIGIN. If a service .env happens to have the same key
|
|
643
|
-
// (e.g. an old hand-edit), the live override should still apply.
|
|
644
|
-
const h = makeHarness();
|
|
645
|
-
try {
|
|
646
|
-
seedVault(h.manifestPath);
|
|
647
|
-
ensureLogPath("vault", h.configDir);
|
|
648
|
-
writeFileSync(
|
|
649
|
-
join(h.configDir, "vault", ".env"),
|
|
650
|
-
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=http://stale.local\n",
|
|
651
|
-
);
|
|
652
|
-
const spawner = makeSpawner([4242]);
|
|
653
|
-
const code = await start("vault", {
|
|
654
|
-
configDir: h.configDir,
|
|
655
|
-
manifestPath: h.manifestPath,
|
|
656
|
-
spawner,
|
|
657
|
-
hubOrigin: "https://live.example.com",
|
|
658
|
-
log: () => {},
|
|
659
|
-
});
|
|
660
|
-
expect(code).toBe(0);
|
|
661
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
662
|
-
PORT: "1940",
|
|
663
|
-
SCRIBE_AUTH_TOKEN: "secret",
|
|
664
|
-
PARACHUTE_HUB_ORIGIN: "https://live.example.com",
|
|
665
|
-
});
|
|
666
|
-
} finally {
|
|
667
|
-
h.cleanup();
|
|
668
|
-
}
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
test("third-party module starts via installDir module.json with cwd", async () => {
|
|
672
|
-
// hub#83: services.json rows that carry installDir resolve their spec
|
|
673
|
-
// from `<installDir>/.parachute/module.json` at lifecycle time. Spawn
|
|
674
|
-
// gets cwd=installDir so manifest-declared relative paths work.
|
|
675
|
-
const h = makeHarness();
|
|
676
|
-
try {
|
|
677
|
-
const installDir = join(h.configDir, "_pkg-someapp");
|
|
678
|
-
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
679
|
-
installDir,
|
|
680
|
-
startCmd: ["bun", "web/server/src/server.ts"],
|
|
681
|
-
port: 1944,
|
|
682
|
-
});
|
|
683
|
-
const spawner = makeSpawner([8080]);
|
|
684
|
-
const code = await start("someapp", {
|
|
685
|
-
configDir: h.configDir,
|
|
686
|
-
manifestPath: h.manifestPath,
|
|
687
|
-
spawner,
|
|
688
|
-
log: () => {},
|
|
689
|
-
});
|
|
690
|
-
expect(code).toBe(0);
|
|
691
|
-
expect(spawner.calls).toHaveLength(1);
|
|
692
|
-
expect(spawner.calls[0]?.cmd).toEqual(["bun", "web/server/src/server.ts"]);
|
|
693
|
-
expect(spawner.calls[0]?.cwd).toBe(installDir);
|
|
694
|
-
expect(readPid("someapp", h.configDir)).toBe(8080);
|
|
695
|
-
} finally {
|
|
696
|
-
h.cleanup();
|
|
697
|
-
}
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
test("start: installDir-less third-party row surfaces an actionable error", async () => {
|
|
701
|
-
// A services.json row whose name isn't first-party AND has no installDir
|
|
702
|
-
// can't yield a startCmd. Pre-fix this hit the generic "unknown service"
|
|
703
|
-
// path (misleading — the row exists, just with stale shape). Post-fix
|
|
704
|
-
// resolveTargets returns the entry with spec=undefined and start prints
|
|
705
|
-
// an actionable message that points at the real fix (re-install or
|
|
706
|
-
// upgrade-the-module).
|
|
707
|
-
const h = makeHarness();
|
|
708
|
-
try {
|
|
709
|
-
upsertService(
|
|
710
|
-
{
|
|
711
|
-
name: "mystery",
|
|
712
|
-
port: 1944,
|
|
713
|
-
paths: ["/mystery"],
|
|
714
|
-
health: "/mystery/health",
|
|
715
|
-
version: "0.0.1",
|
|
716
|
-
},
|
|
717
|
-
h.manifestPath,
|
|
718
|
-
);
|
|
719
|
-
const lines: string[] = [];
|
|
720
|
-
const code = await start("mystery", {
|
|
721
|
-
configDir: h.configDir,
|
|
722
|
-
manifestPath: h.manifestPath,
|
|
723
|
-
log: (l) => lines.push(l),
|
|
724
|
-
});
|
|
725
|
-
expect(code).toBe(1);
|
|
726
|
-
const out = lines.join("\n");
|
|
727
|
-
expect(out).toMatch(/services\.json entry has no installDir/);
|
|
728
|
-
expect(out).toMatch(/parachute install <path-to-mystery>/);
|
|
729
|
-
expect(out).not.toMatch(/unknown service/);
|
|
730
|
-
} finally {
|
|
731
|
-
h.cleanup();
|
|
732
|
-
}
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
test("start: name absent from services.json still errors as unknown service", async () => {
|
|
736
|
-
// The genuinely-unknown path: no first-party fallback, no row in
|
|
737
|
-
// services.json. Distinguish from the above (row exists but lacks
|
|
738
|
-
// installDir) so the error message is right-shaped for each.
|
|
739
|
-
const h = makeHarness();
|
|
740
|
-
try {
|
|
741
|
-
seedVault(h.manifestPath);
|
|
742
|
-
const lines: string[] = [];
|
|
743
|
-
const code = await start("ghost", {
|
|
744
|
-
configDir: h.configDir,
|
|
745
|
-
manifestPath: h.manifestPath,
|
|
746
|
-
log: (l) => lines.push(l),
|
|
747
|
-
});
|
|
748
|
-
expect(code).toBe(1);
|
|
749
|
-
expect(lines.join("\n")).toMatch(/unknown service "ghost"/);
|
|
750
|
-
} finally {
|
|
751
|
-
h.cleanup();
|
|
752
|
-
}
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
test("start (no svc) sweeps both first-party and third-party rows", async () => {
|
|
756
|
-
const h = makeHarness();
|
|
757
|
-
try {
|
|
758
|
-
seedVault(h.manifestPath);
|
|
759
|
-
const installDir = join(h.configDir, "_pkg-someapp");
|
|
760
|
-
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
761
|
-
installDir,
|
|
762
|
-
startCmd: ["bun", "server.ts"],
|
|
763
|
-
port: 1944,
|
|
764
|
-
});
|
|
765
|
-
const spawner = makeSpawner([4242, 8080]);
|
|
766
|
-
const code = await start(undefined, {
|
|
767
|
-
configDir: h.configDir,
|
|
768
|
-
manifestPath: h.manifestPath,
|
|
769
|
-
spawner,
|
|
770
|
-
log: () => {},
|
|
771
|
-
});
|
|
772
|
-
expect(code).toBe(0);
|
|
773
|
-
expect(spawner.calls).toHaveLength(2);
|
|
774
|
-
const cmds = spawner.calls.map((c) => c.cmd);
|
|
775
|
-
expect(cmds).toContainEqual(["parachute-vault", "serve"]);
|
|
776
|
-
expect(cmds).toContainEqual(["bun", "server.ts"]);
|
|
777
|
-
} finally {
|
|
778
|
-
h.cleanup();
|
|
779
|
-
}
|
|
780
|
-
});
|
|
781
|
-
|
|
782
|
-
test("third-party with malformed module.json fails clearly", async () => {
|
|
783
|
-
const h = makeHarness();
|
|
784
|
-
try {
|
|
785
|
-
const installDir = join(h.configDir, "_pkg-broken");
|
|
786
|
-
mkdirSync(join(installDir, ".parachute"), { recursive: true });
|
|
787
|
-
writeFileSync(join(installDir, ".parachute", "module.json"), "{ not valid json");
|
|
788
|
-
upsertService(
|
|
789
|
-
{
|
|
790
|
-
name: "broken",
|
|
791
|
-
port: 1944,
|
|
792
|
-
paths: ["/broken"],
|
|
793
|
-
health: "/broken/health",
|
|
794
|
-
version: "0.0.1",
|
|
795
|
-
installDir,
|
|
796
|
-
},
|
|
797
|
-
h.manifestPath,
|
|
798
|
-
);
|
|
799
|
-
const lines: string[] = [];
|
|
800
|
-
const code = await start("broken", {
|
|
801
|
-
configDir: h.configDir,
|
|
802
|
-
manifestPath: h.manifestPath,
|
|
803
|
-
log: (l) => lines.push(l),
|
|
804
|
-
});
|
|
805
|
-
expect(code).toBe(1);
|
|
806
|
-
expect(lines.join("\n")).toMatch(/broken: invalid module\.json/);
|
|
807
|
-
} finally {
|
|
808
|
-
h.cleanup();
|
|
809
|
-
}
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
test("hub#194: reports failure when child dies before the settle window", async () => {
|
|
813
|
-
// The bug: `parachute start notes` reported `✓ notes started (pid X)`
|
|
814
|
-
// but notes-serve crashed milliseconds later on a Bun.resolveSync
|
|
815
|
-
// failure, leaving tailnet `/notes/` 502'ing. Fix: after spawn, sleep
|
|
816
|
-
// ~250ms then re-check alive(pid). If dead, clear pidfile, log
|
|
817
|
-
// failure, return non-zero. This regression test pins the post-fix
|
|
818
|
-
// shape with a stub alive that always reports dead and a fast settle.
|
|
819
|
-
const h = makeHarness();
|
|
820
|
-
try {
|
|
821
|
-
seedVault(h.manifestPath);
|
|
822
|
-
const spawner = makeSpawner([4242]);
|
|
823
|
-
const lines: string[] = [];
|
|
824
|
-
const code = await start("vault", {
|
|
825
|
-
configDir: h.configDir,
|
|
826
|
-
manifestPath: h.manifestPath,
|
|
827
|
-
spawner,
|
|
828
|
-
alive: () => false, // child dies immediately after spawn
|
|
829
|
-
sleep: async () => {}, // skip the real wait in tests
|
|
830
|
-
startSettleMs: 1, // any non-zero value engages the check
|
|
831
|
-
log: (l) => lines.push(l),
|
|
832
|
-
});
|
|
833
|
-
expect(code).toBe(1);
|
|
834
|
-
expect(spawner.calls).toHaveLength(1);
|
|
835
|
-
// pidfile is cleared so a follow-up `start` doesn't report
|
|
836
|
-
// already-running against a corpse.
|
|
837
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
838
|
-
const out = lines.join("\n");
|
|
839
|
-
expect(out).toMatch(/✗ vault failed to start/);
|
|
840
|
-
expect(out).toMatch(/exited within 1ms/);
|
|
841
|
-
expect(out).toMatch(/Tail the log/);
|
|
842
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
843
|
-
} finally {
|
|
844
|
-
h.cleanup();
|
|
845
|
-
}
|
|
846
|
-
});
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Supervisor-path dispatch (design §3.3): a hub UNIT is installed → the verbs
|
|
141
|
+
// drive the running supervisor (per-module ops) / platform manager (hub verbs).
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
847
143
|
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
// regress. Stub alive returns true so the post-spawn check passes,
|
|
851
|
-
// and we still see the `✓ ... started` line.
|
|
144
|
+
describe("start — supervisor path", () => {
|
|
145
|
+
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(start)", async () => {
|
|
852
146
|
const h = makeHarness();
|
|
853
147
|
try {
|
|
854
|
-
|
|
855
|
-
const
|
|
856
|
-
const lines: string[] = [];
|
|
148
|
+
const sup = makeSupervisorStub();
|
|
149
|
+
const log: string[] = [];
|
|
857
150
|
const code = await start("vault", {
|
|
858
151
|
configDir: h.configDir,
|
|
859
152
|
manifestPath: h.manifestPath,
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
sleep: async () => {},
|
|
863
|
-
startSettleMs: 1,
|
|
864
|
-
log: (l) => lines.push(l),
|
|
153
|
+
log: (l) => log.push(l),
|
|
154
|
+
supervisor: sup.opts,
|
|
865
155
|
});
|
|
866
156
|
expect(code).toBe(0);
|
|
867
|
-
expect(
|
|
868
|
-
expect(
|
|
157
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
158
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
|
|
159
|
+
expect(log.join("\n")).toMatch(/✓ vault started/);
|
|
869
160
|
} finally {
|
|
870
161
|
h.cleanup();
|
|
871
162
|
}
|
|
872
163
|
});
|
|
873
164
|
|
|
874
|
-
test("
|
|
875
|
-
// Defense — don't regress the test-default policy. With a stub
|
|
876
|
-
// spawner and no `alive` override, the resolved settle is 0 (see
|
|
877
|
-
// resolve() in lifecycle.ts), so the post-spawn check is bypassed
|
|
878
|
-
// entirely and even an `alive: () => false` doesn't matter.
|
|
165
|
+
test("no svc, unit-installed → ensureHubUnit only (boots all modules), no driveModuleOp", async () => {
|
|
879
166
|
const h = makeHarness();
|
|
880
167
|
try {
|
|
881
|
-
|
|
882
|
-
const
|
|
883
|
-
const code = await start("vault", {
|
|
168
|
+
const sup = makeSupervisorStub();
|
|
169
|
+
const code = await start(undefined, {
|
|
884
170
|
configDir: h.configDir,
|
|
885
171
|
manifestPath: h.manifestPath,
|
|
886
|
-
spawner,
|
|
887
|
-
startSettleMs: 0,
|
|
888
|
-
// intentionally omit alive — defaultAlive against a fake pid
|
|
889
|
-
// would normally report dead, but startSettleMs: 0 skips the
|
|
890
|
-
// call entirely.
|
|
891
172
|
log: () => {},
|
|
173
|
+
supervisor: sup.opts,
|
|
892
174
|
});
|
|
893
175
|
expect(code).toBe(0);
|
|
894
|
-
expect(
|
|
176
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
177
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
895
178
|
} finally {
|
|
896
179
|
h.cleanup();
|
|
897
180
|
}
|
|
898
181
|
});
|
|
899
182
|
|
|
900
|
-
test("
|
|
183
|
+
test("module svc, NoOperatorTokenError → actionable message surfaced (not raw-thrown)", async () => {
|
|
901
184
|
const h = makeHarness();
|
|
902
185
|
try {
|
|
903
|
-
const
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
});
|
|
908
|
-
const lines: string[] = [];
|
|
909
|
-
const code = await start("noop", {
|
|
910
|
-
configDir: h.configDir,
|
|
911
|
-
manifestPath: h.manifestPath,
|
|
912
|
-
log: (l) => lines.push(l),
|
|
186
|
+
const sup = makeSupervisorStub({
|
|
187
|
+
driveResponder: () => {
|
|
188
|
+
throw new NoOperatorTokenError();
|
|
189
|
+
},
|
|
913
190
|
});
|
|
914
|
-
|
|
915
|
-
expect(lines.join("\n")).toMatch(/lifecycle not yet supported/);
|
|
916
|
-
} finally {
|
|
917
|
-
h.cleanup();
|
|
918
|
-
}
|
|
919
|
-
});
|
|
920
|
-
|
|
921
|
-
// hub#487 — readiness gating beyond the bare liveness settle. Aaron hit this
|
|
922
|
-
// on a fresh EC2 box: `parachute start vault` printed "✓ vault started" while
|
|
923
|
-
// the process died ~instantly on EADDRINUSE (an orphan held 1940), and
|
|
924
|
-
// `parachute status` then showed it inactive.
|
|
925
|
-
|
|
926
|
-
/**
|
|
927
|
-
* A stub spawner that also seeds the service's log file with `content`, so
|
|
928
|
-
* the readiness-failure path's log-tail + EADDRINUSE detection can read a
|
|
929
|
-
* realistic boot error. Mirrors how the real spawner appends stdout/stderr
|
|
930
|
-
* to the logfile.
|
|
931
|
-
*/
|
|
932
|
-
function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
|
|
933
|
-
const calls: SpawnerStub["calls"] = [];
|
|
934
|
-
return {
|
|
935
|
-
calls,
|
|
936
|
-
spawn(cmd, logFile, opts) {
|
|
937
|
-
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
938
|
-
// The start path calls ensureLogPath() before spawn, so logFile's
|
|
939
|
-
// parent dir already exists — just write the simulated boot output.
|
|
940
|
-
writeFileSync(logFile, content);
|
|
941
|
-
return pid;
|
|
942
|
-
},
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
|
|
947
|
-
const h = makeHarness();
|
|
948
|
-
try {
|
|
949
|
-
seedVault(h.manifestPath);
|
|
950
|
-
const spawner = makeSpawnerWithLog(
|
|
951
|
-
4242,
|
|
952
|
-
"booting vault…\nerror: listen EADDRINUSE: address already in use 0.0.0.0:1940\n",
|
|
953
|
-
);
|
|
954
|
-
const lines: string[] = [];
|
|
191
|
+
const log: string[] = [];
|
|
955
192
|
const code = await start("vault", {
|
|
956
193
|
configDir: h.configDir,
|
|
957
194
|
manifestPath: h.manifestPath,
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
sleep: async () => {},
|
|
961
|
-
startSettleMs: 1,
|
|
962
|
-
log: (l) => lines.push(l),
|
|
195
|
+
log: (l) => log.push(l),
|
|
196
|
+
supervisor: sup.opts,
|
|
963
197
|
});
|
|
964
198
|
expect(code).toBe(1);
|
|
965
|
-
expect(
|
|
966
|
-
|
|
967
|
-
expect(out).toMatch(/port 1940 is already in use/);
|
|
968
|
-
expect(out).toMatch(/lsof -ti:1940/);
|
|
969
|
-
// The real boot error is surfaced inline so the operator doesn't have to
|
|
970
|
-
// go tail the log themselves.
|
|
971
|
-
expect(out).toMatch(/EADDRINUSE/);
|
|
972
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
199
|
+
expect(log.join("\n")).toMatch(/no operator token/);
|
|
200
|
+
expect(log.join("\n")).toMatch(/parachute auth rotate-operator/);
|
|
973
201
|
} finally {
|
|
974
202
|
h.cleanup();
|
|
975
203
|
}
|
|
976
204
|
});
|
|
977
205
|
|
|
978
|
-
test("
|
|
206
|
+
test("module svc, 400 not_installed → actionable install hint", async () => {
|
|
979
207
|
const h = makeHarness();
|
|
980
208
|
try {
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
209
|
+
const sup = makeSupervisorStub({
|
|
210
|
+
driveResponder: () => {
|
|
211
|
+
throw new ModuleOpHttpError(400, "not_installed", "vault is not installed");
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const log: string[] = [];
|
|
985
215
|
const code = await start("vault", {
|
|
986
216
|
configDir: h.configDir,
|
|
987
217
|
manifestPath: h.manifestPath,
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
// slow-EADDRINUSE / crash-after-boot shape.
|
|
991
|
-
alive: () => {
|
|
992
|
-
aliveCalls++;
|
|
993
|
-
return aliveCalls <= 1;
|
|
994
|
-
},
|
|
995
|
-
sleep: async () => {},
|
|
996
|
-
startSettleMs: 1,
|
|
997
|
-
startReadyMs: 50,
|
|
998
|
-
startReadyPollMs: 1,
|
|
999
|
-
portListening: async () => false, // never binds
|
|
1000
|
-
log: (l) => lines.push(l),
|
|
218
|
+
log: (l) => log.push(l),
|
|
219
|
+
supervisor: sup.opts,
|
|
1001
220
|
});
|
|
1002
221
|
expect(code).toBe(1);
|
|
1003
|
-
expect(
|
|
1004
|
-
|
|
1005
|
-
expect(out).toMatch(/✗ vault failed to start/);
|
|
1006
|
-
expect(out).toMatch(/exited during startup/);
|
|
1007
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
222
|
+
expect(log.join("\n")).toMatch(/not installed/);
|
|
223
|
+
expect(log.join("\n")).toMatch(/parachute install vault/);
|
|
1008
224
|
} finally {
|
|
1009
225
|
h.cleanup();
|
|
1010
226
|
}
|
|
1011
227
|
});
|
|
228
|
+
});
|
|
1012
229
|
|
|
1013
|
-
|
|
230
|
+
describe("stop — supervisor path", () => {
|
|
231
|
+
test("module svc, hub UP → driveModuleOp(stop), no ensureHubUnit", async () => {
|
|
1014
232
|
const h = makeHarness();
|
|
1015
233
|
try {
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
const
|
|
1019
|
-
const code = await start("vault", {
|
|
234
|
+
const sup = makeSupervisorStub({ health: true });
|
|
235
|
+
const log: string[] = [];
|
|
236
|
+
const code = await stop("vault", {
|
|
1020
237
|
configDir: h.configDir,
|
|
1021
238
|
manifestPath: h.manifestPath,
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
sleep: async () => {},
|
|
1025
|
-
startSettleMs: 1,
|
|
1026
|
-
startReadyMs: 10,
|
|
1027
|
-
startReadyPollMs: 1,
|
|
1028
|
-
portListening: async () => false, // slow boot — not listening yet
|
|
1029
|
-
log: (l) => lines.push(l),
|
|
239
|
+
log: (l) => log.push(l),
|
|
240
|
+
supervisor: sup.opts,
|
|
1030
241
|
});
|
|
1031
|
-
// A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
|
|
1032
242
|
expect(code).toBe(0);
|
|
1033
|
-
expect(
|
|
1034
|
-
|
|
1035
|
-
expect(
|
|
1036
|
-
expect(
|
|
243
|
+
expect(sup.healthProbes).toBe(1);
|
|
244
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
|
|
245
|
+
expect(sup.ensureCalls).toHaveLength(0); // never start the hub just to stop a module
|
|
246
|
+
expect(log.join("\n")).toMatch(/✓ vault stopped/);
|
|
1037
247
|
} finally {
|
|
1038
248
|
h.cleanup();
|
|
1039
249
|
}
|
|
1040
250
|
});
|
|
1041
251
|
|
|
1042
|
-
test("hub
|
|
252
|
+
test("module svc, hub DOWN → success WITHOUT starting the hub or driving stop", async () => {
|
|
1043
253
|
const h = makeHarness();
|
|
1044
254
|
try {
|
|
1045
|
-
|
|
1046
|
-
const
|
|
1047
|
-
const
|
|
1048
|
-
let probeCalls = 0;
|
|
1049
|
-
const code = await start("vault", {
|
|
255
|
+
const sup = makeSupervisorStub({ health: false });
|
|
256
|
+
const log: string[] = [];
|
|
257
|
+
const code = await stop("vault", {
|
|
1050
258
|
configDir: h.configDir,
|
|
1051
259
|
manifestPath: h.manifestPath,
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
sleep: async () => {},
|
|
1055
|
-
startSettleMs: 1,
|
|
1056
|
-
startReadyMs: 50,
|
|
1057
|
-
startReadyPollMs: 1,
|
|
1058
|
-
// Not listening on the first poll, bound on the second — exercises the
|
|
1059
|
-
// poll loop rather than an instant true.
|
|
1060
|
-
portListening: async () => {
|
|
1061
|
-
probeCalls++;
|
|
1062
|
-
return probeCalls >= 2;
|
|
1063
|
-
},
|
|
1064
|
-
log: (l) => lines.push(l),
|
|
260
|
+
log: (l) => log.push(l),
|
|
261
|
+
supervisor: sup.opts,
|
|
1065
262
|
});
|
|
1066
263
|
expect(code).toBe(0);
|
|
1067
|
-
expect(
|
|
1068
|
-
expect(
|
|
264
|
+
expect(sup.healthProbes).toBe(1);
|
|
265
|
+
expect(sup.driveCalls).toHaveLength(0); // nothing to stop — module already down
|
|
266
|
+
expect(sup.ensureCalls).toHaveLength(0); // did NOT ensureHubUnit
|
|
267
|
+
expect(log.join("\n")).toMatch(/already stopped/);
|
|
1069
268
|
} finally {
|
|
1070
269
|
h.cleanup();
|
|
1071
270
|
}
|
|
1072
271
|
});
|
|
1073
|
-
});
|
|
1074
272
|
|
|
1075
|
-
|
|
1076
|
-
test("no-op when nothing is running", async () => {
|
|
273
|
+
test("stop hub → platform manager (stopHubUnit), never a PID signal", async () => {
|
|
1077
274
|
const h = makeHarness();
|
|
1078
275
|
try {
|
|
1079
|
-
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1082
|
-
const code = await stop("vault", {
|
|
276
|
+
const sup = makeSupervisorStub();
|
|
277
|
+
const log: string[] = [];
|
|
278
|
+
const code = await stop("hub", {
|
|
1083
279
|
configDir: h.configDir,
|
|
1084
280
|
manifestPath: h.manifestPath,
|
|
1085
|
-
|
|
1086
|
-
|
|
281
|
+
log: (l) => log.push(l),
|
|
282
|
+
supervisor: sup.opts,
|
|
1087
283
|
});
|
|
1088
284
|
expect(code).toBe(0);
|
|
1089
|
-
expect(
|
|
1090
|
-
expect(
|
|
285
|
+
expect(sup.stopHubCalls).toBe(1);
|
|
286
|
+
expect(sup.healthProbes).toBe(0);
|
|
287
|
+
expect(log.join("\n")).toMatch(/✓ hub stopped/);
|
|
1091
288
|
} finally {
|
|
1092
289
|
h.cleanup();
|
|
1093
290
|
}
|
|
1094
291
|
});
|
|
1095
292
|
|
|
1096
|
-
test("
|
|
293
|
+
test("no svc, unit-installed → stop the hub unit (manager)", async () => {
|
|
1097
294
|
const h = makeHarness();
|
|
1098
295
|
try {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
const killed: Array<[number, string | number]> = [];
|
|
1102
|
-
const code = await stop("vault", {
|
|
296
|
+
const sup = makeSupervisorStub();
|
|
297
|
+
const code = await stop(undefined, {
|
|
1103
298
|
configDir: h.configDir,
|
|
1104
299
|
manifestPath: h.manifestPath,
|
|
1105
|
-
kill: (pid, sig) => killed.push([pid, sig]),
|
|
1106
|
-
alive: () => false,
|
|
1107
300
|
log: () => {},
|
|
301
|
+
supervisor: sup.opts,
|
|
1108
302
|
});
|
|
1109
303
|
expect(code).toBe(0);
|
|
1110
|
-
expect(
|
|
1111
|
-
expect(
|
|
304
|
+
expect(sup.stopHubCalls).toBe(1);
|
|
305
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
1112
306
|
} finally {
|
|
1113
307
|
h.cleanup();
|
|
1114
308
|
}
|
|
1115
309
|
});
|
|
310
|
+
});
|
|
1116
311
|
|
|
1117
|
-
|
|
312
|
+
describe("restart — supervisor path", () => {
|
|
313
|
+
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(restart)", async () => {
|
|
1118
314
|
const h = makeHarness();
|
|
1119
315
|
try {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
const
|
|
1123
|
-
let aliveCall = 0;
|
|
1124
|
-
const code = await stop("vault", {
|
|
316
|
+
const sup = makeSupervisorStub();
|
|
317
|
+
const log: string[] = [];
|
|
318
|
+
const code = await restart("vault", {
|
|
1125
319
|
configDir: h.configDir,
|
|
1126
320
|
manifestPath: h.manifestPath,
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
aliveCall++;
|
|
1130
|
-
return aliveCall === 1;
|
|
1131
|
-
},
|
|
1132
|
-
sleep: async () => {},
|
|
1133
|
-
log: () => {},
|
|
321
|
+
log: (l) => log.push(l),
|
|
322
|
+
supervisor: sup.opts,
|
|
1134
323
|
});
|
|
1135
324
|
expect(code).toBe(0);
|
|
1136
|
-
expect(
|
|
1137
|
-
expect(
|
|
325
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
326
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
327
|
+
expect(log.join("\n")).toMatch(/✓ vault restarted/);
|
|
1138
328
|
} finally {
|
|
1139
329
|
h.cleanup();
|
|
1140
330
|
}
|
|
1141
331
|
});
|
|
1142
332
|
|
|
1143
|
-
test("
|
|
333
|
+
test("404 not_supervised on restart → fall through to driveModuleOp(start)", async () => {
|
|
1144
334
|
const h = makeHarness();
|
|
1145
335
|
try {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
336
|
+
const sup = makeSupervisorStub({
|
|
337
|
+
driveResponder: (_short, op) => {
|
|
338
|
+
if (op === "restart") {
|
|
339
|
+
throw new ModuleOpHttpError(404, "not_supervised", "vault is not currently supervised");
|
|
340
|
+
}
|
|
341
|
+
return { status: 200, body: { short: "vault", state: { status: "running" } } };
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
const log: string[] = [];
|
|
345
|
+
const code = await restart("vault", {
|
|
1151
346
|
configDir: h.configDir,
|
|
1152
347
|
manifestPath: h.manifestPath,
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
sleep: async () => {},
|
|
1156
|
-
now: () => {
|
|
1157
|
-
// Jump past the kill-wait window so the polling loop exits fast.
|
|
1158
|
-
t += 20_000;
|
|
1159
|
-
return t;
|
|
1160
|
-
},
|
|
1161
|
-
killWaitMs: 10_000,
|
|
1162
|
-
log: () => {},
|
|
348
|
+
log: (l) => log.push(l),
|
|
349
|
+
supervisor: sup.opts,
|
|
1163
350
|
});
|
|
1164
351
|
expect(code).toBe(0);
|
|
1165
|
-
|
|
1166
|
-
expect(
|
|
1167
|
-
|
|
352
|
+
// restart was attempted, then start as the 404-fallthrough (§6.2).
|
|
353
|
+
expect(sup.driveCalls).toEqual([
|
|
354
|
+
{ short: "vault", op: "restart" },
|
|
355
|
+
{ short: "vault", op: "start" },
|
|
356
|
+
]);
|
|
357
|
+
expect(log.join("\n")).toMatch(/✓ vault started/);
|
|
1168
358
|
} finally {
|
|
1169
359
|
h.cleanup();
|
|
1170
360
|
}
|
|
1171
361
|
});
|
|
1172
362
|
|
|
1173
|
-
test("
|
|
1174
|
-
// Graceful-degradation path: an installed-but-stale third-party row
|
|
1175
|
-
// (no installDir field — pre-installDir-contract self-registration)
|
|
1176
|
-
// should still be stoppable. stop only needs the short name to find
|
|
1177
|
-
// the pidfile; spec resolution isn't on the critical path for stop.
|
|
363
|
+
test("restart hub → platform manager (restartHubUnit), never a PID signal", async () => {
|
|
1178
364
|
const h = makeHarness();
|
|
1179
365
|
try {
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
port: 1944,
|
|
1184
|
-
paths: ["/mystery"],
|
|
1185
|
-
health: "/mystery/health",
|
|
1186
|
-
version: "0.0.1",
|
|
1187
|
-
},
|
|
1188
|
-
h.manifestPath,
|
|
1189
|
-
);
|
|
1190
|
-
writePid("mystery", 4242, h.configDir);
|
|
1191
|
-
const killed: Array<[number, string | number]> = [];
|
|
1192
|
-
let aliveCall = 0;
|
|
1193
|
-
const code = await stop("mystery", {
|
|
366
|
+
const sup = makeSupervisorStub();
|
|
367
|
+
const log: string[] = [];
|
|
368
|
+
const code = await restart("hub", {
|
|
1194
369
|
configDir: h.configDir,
|
|
1195
370
|
manifestPath: h.manifestPath,
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
aliveCall++;
|
|
1199
|
-
return aliveCall === 1;
|
|
1200
|
-
},
|
|
1201
|
-
sleep: async () => {},
|
|
1202
|
-
log: () => {},
|
|
371
|
+
log: (l) => log.push(l),
|
|
372
|
+
supervisor: sup.opts,
|
|
1203
373
|
});
|
|
1204
374
|
expect(code).toBe(0);
|
|
1205
|
-
expect(
|
|
1206
|
-
expect(
|
|
375
|
+
expect(sup.restartHubCalls).toBe(1);
|
|
376
|
+
expect(sup.driveCalls).toHaveLength(0); // NOT a per-module fan-out
|
|
377
|
+
expect(log.join("\n")).toMatch(/✓ hub restarted/);
|
|
1207
378
|
} finally {
|
|
1208
379
|
h.cleanup();
|
|
1209
380
|
}
|
|
1210
381
|
});
|
|
1211
|
-
});
|
|
1212
382
|
|
|
1213
|
-
|
|
1214
|
-
test("stops then starts in sequence", async () => {
|
|
383
|
+
test("no svc, unit-installed → restart the hub unit (manager), not a fan-out", async () => {
|
|
1215
384
|
const h = makeHarness();
|
|
1216
385
|
try {
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
const spawner = makeSpawner([7777]);
|
|
1220
|
-
const killed: Array<[number, string | number]> = [];
|
|
1221
|
-
const code = await restart("vault", {
|
|
386
|
+
const sup = makeSupervisorStub();
|
|
387
|
+
const code = await restart(undefined, {
|
|
1222
388
|
configDir: h.configDir,
|
|
1223
389
|
manifestPath: h.manifestPath,
|
|
1224
|
-
spawner,
|
|
1225
|
-
kill: (pid, sig) => killed.push([pid, sig]),
|
|
1226
|
-
// Stale 4242 is dead (stop's stale-pid path skips the kill);
|
|
1227
|
-
// freshly spawned 7777 is alive past the post-spawn settle
|
|
1228
|
-
// (hub#194). Per-pid differentiation rather than blanket-false.
|
|
1229
|
-
alive: (pid) => pid === 7777,
|
|
1230
|
-
sleep: async () => {},
|
|
1231
390
|
log: () => {},
|
|
391
|
+
supervisor: sup.opts,
|
|
1232
392
|
});
|
|
1233
393
|
expect(code).toBe(0);
|
|
1234
|
-
expect(
|
|
1235
|
-
expect(
|
|
1236
|
-
expect(readPid("vault", h.configDir)).toBe(7777);
|
|
394
|
+
expect(sup.restartHubCalls).toBe(1);
|
|
395
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
1237
396
|
} finally {
|
|
1238
397
|
h.cleanup();
|
|
1239
398
|
}
|
|
1240
399
|
});
|
|
1241
400
|
});
|
|
1242
401
|
|
|
1243
|
-
|
|
1244
|
-
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// §7.5 no-unit path: a box with NO hub unit gets the auto-offer (when enabled)
|
|
404
|
+
// or the actionable "run `parachute migrate --to-supervised`" error — NEVER a
|
|
405
|
+
// detached spawn (the spawners are retired in Phase 5b). Reworked from the
|
|
406
|
+
// former "fall through to the detached arm" tests: the intent (what happens on
|
|
407
|
+
// a no-unit box) is preserved, but the outcome inverted to single-runtime.
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe("§7.5 no-unit path in start/stop/restart", () => {
|
|
411
|
+
/** A migrate-offer stub recording whether it was called + what it returns. */
|
|
412
|
+
function makeOfferStub(outcome: MigrateOfferResult["outcome"]): {
|
|
413
|
+
offer: (opts: MigrateOfferOpts) => Promise<MigrateOfferResult>;
|
|
414
|
+
calls: number;
|
|
415
|
+
} {
|
|
416
|
+
const state = { calls: 0 };
|
|
417
|
+
return {
|
|
418
|
+
get calls() {
|
|
419
|
+
return state.calls;
|
|
420
|
+
},
|
|
421
|
+
offer: async () => {
|
|
422
|
+
state.calls++;
|
|
423
|
+
return { outcome };
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
test("no unit + offer disabled (omitted) → actionable migrate error, exit 1, no spawn", async () => {
|
|
1245
429
|
const h = makeHarness();
|
|
1246
430
|
try {
|
|
1247
|
-
|
|
1248
|
-
const
|
|
431
|
+
seedVault(h.manifestPath);
|
|
432
|
+
const log: string[] = [];
|
|
433
|
+
// No `supervisor` block → unitInstalled defaults to false; no migrateOffer
|
|
434
|
+
// → the offer hook stays OFF. There is no detached fallback anymore, so the
|
|
435
|
+
// verb surfaces the actionable command and exits non-zero.
|
|
436
|
+
const code = await start("vault", {
|
|
1249
437
|
configDir: h.configDir,
|
|
1250
|
-
|
|
438
|
+
manifestPath: h.manifestPath,
|
|
439
|
+
log: (l) => log.push(l),
|
|
1251
440
|
});
|
|
1252
|
-
expect(code).toBe(
|
|
1253
|
-
expect(
|
|
441
|
+
expect(code).toBe(1);
|
|
442
|
+
expect(log.join("\n")).toMatch(/No supervised hub unit is installed/);
|
|
443
|
+
expect(log.join("\n")).toMatch(/parachute migrate --to-supervised/);
|
|
1254
444
|
} finally {
|
|
1255
445
|
h.cleanup();
|
|
1256
446
|
}
|
|
1257
447
|
});
|
|
1258
448
|
|
|
1259
|
-
test("
|
|
449
|
+
test("start: accept+migrate → dispatches through the supervisor (no detached spawn)", async () => {
|
|
1260
450
|
const h = makeHarness();
|
|
1261
451
|
try {
|
|
1262
|
-
|
|
1263
|
-
const
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
452
|
+
seedVault(h.manifestPath);
|
|
453
|
+
const offerStub = makeOfferStub("migrated");
|
|
454
|
+
const sup = makeSupervisorStub();
|
|
455
|
+
// Start on the no-unit arm (unitInstalled:false), with the offer enabled
|
|
456
|
+
// and the supervisor stub ready for the post-migrate dispatch.
|
|
457
|
+
const code = await start("vault", {
|
|
1267
458
|
configDir: h.configDir,
|
|
1268
|
-
|
|
1269
|
-
log: (
|
|
459
|
+
manifestPath: h.manifestPath,
|
|
460
|
+
log: () => {},
|
|
461
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
462
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1270
463
|
});
|
|
1271
464
|
expect(code).toBe(0);
|
|
1272
|
-
expect(
|
|
465
|
+
expect(offerStub.calls).toBe(1);
|
|
466
|
+
// The migrate flipped the box to supervised → the verb drove the supervisor.
|
|
467
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
|
|
1273
468
|
} finally {
|
|
1274
469
|
h.cleanup();
|
|
1275
470
|
}
|
|
1276
471
|
});
|
|
1277
472
|
|
|
1278
|
-
test("
|
|
473
|
+
test("start: declined → actionable-error path, exit 1 (no spawn)", async () => {
|
|
1279
474
|
const h = makeHarness();
|
|
1280
475
|
try {
|
|
1281
|
-
|
|
1282
|
-
const
|
|
476
|
+
seedVault(h.manifestPath);
|
|
477
|
+
const offerStub = makeOfferStub("declined");
|
|
478
|
+
const code = await start("vault", {
|
|
1283
479
|
configDir: h.configDir,
|
|
1284
|
-
|
|
480
|
+
manifestPath: h.manifestPath,
|
|
481
|
+
log: () => {},
|
|
482
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1285
483
|
});
|
|
484
|
+
// Declined → no migrate, no detached spawn (retired) → non-zero exit. The
|
|
485
|
+
// offer itself surfaced its own decline guidance, so the verb just bails.
|
|
1286
486
|
expect(code).toBe(1);
|
|
1287
|
-
expect(
|
|
487
|
+
expect(offerStub.calls).toBe(1);
|
|
1288
488
|
} finally {
|
|
1289
489
|
h.cleanup();
|
|
1290
490
|
}
|
|
1291
491
|
});
|
|
1292
492
|
|
|
1293
|
-
test("
|
|
493
|
+
test("start: migrate-failed → actionable-error path, exit 1 (fail-safe, no spawn)", async () => {
|
|
1294
494
|
const h = makeHarness();
|
|
1295
495
|
try {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
startCmd: ["bun", "server.ts"],
|
|
1300
|
-
});
|
|
1301
|
-
const p = ensureLogPath("someapp", h.configDir);
|
|
1302
|
-
writeFileSync(p, "someapp line 1\nsomeapp line 2\n");
|
|
1303
|
-
const lines: string[] = [];
|
|
1304
|
-
const code = await logs("someapp", {
|
|
496
|
+
seedVault(h.manifestPath);
|
|
497
|
+
const offerStub = makeOfferStub("migrate-failed");
|
|
498
|
+
const code = await start("vault", {
|
|
1305
499
|
configDir: h.configDir,
|
|
1306
500
|
manifestPath: h.manifestPath,
|
|
1307
|
-
log: (
|
|
501
|
+
log: () => {},
|
|
502
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1308
503
|
});
|
|
1309
|
-
|
|
1310
|
-
|
|
504
|
+
// A failed cutover leaves the box un-migrated → the verb bails non-zero
|
|
505
|
+
// (rather than dispatching into a supervisor that isn't up). No spawn.
|
|
506
|
+
expect(code).toBe(1);
|
|
507
|
+
expect(offerStub.calls).toBe(1);
|
|
1311
508
|
} finally {
|
|
1312
509
|
h.cleanup();
|
|
1313
510
|
}
|
|
1314
511
|
});
|
|
1315
512
|
|
|
1316
|
-
test("
|
|
1317
|
-
// Graceful-degradation path: log file is keyed by short name, written by
|
|
1318
|
-
// start. installDir is irrelevant for tailing — the entry just needs to
|
|
1319
|
-
// exist in services.json.
|
|
513
|
+
test("stop: accept+migrate → dispatches through the supervisor", async () => {
|
|
1320
514
|
const h = makeHarness();
|
|
1321
515
|
try {
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
paths: ["/mystery"],
|
|
1327
|
-
health: "/mystery/health",
|
|
1328
|
-
version: "0.0.1",
|
|
1329
|
-
},
|
|
1330
|
-
h.manifestPath,
|
|
1331
|
-
);
|
|
1332
|
-
const p = ensureLogPath("mystery", h.configDir);
|
|
1333
|
-
writeFileSync(p, "mystery line 1\nmystery line 2\n");
|
|
1334
|
-
const lines: string[] = [];
|
|
1335
|
-
const code = await logs("mystery", {
|
|
516
|
+
seedVault(h.manifestPath);
|
|
517
|
+
const offerStub = makeOfferStub("migrated");
|
|
518
|
+
const sup = makeSupervisorStub();
|
|
519
|
+
const code = await stop("vault", {
|
|
1336
520
|
configDir: h.configDir,
|
|
1337
521
|
manifestPath: h.manifestPath,
|
|
1338
|
-
log: (
|
|
522
|
+
log: () => {},
|
|
523
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
524
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1339
525
|
});
|
|
1340
526
|
expect(code).toBe(0);
|
|
1341
|
-
expect(
|
|
527
|
+
expect(offerStub.calls).toBe(1);
|
|
528
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
|
|
1342
529
|
} finally {
|
|
1343
530
|
h.cleanup();
|
|
1344
531
|
}
|
|
1345
532
|
});
|
|
1346
533
|
|
|
1347
|
-
test("
|
|
1348
|
-
// Aaron's #335 reproducer shape: parachute-app daemon was running
|
|
1349
|
-
// (curl proxied 200s, pidfile alive) but `parachute logs app` printed
|
|
1350
|
-
// `parachute start app to begin` — telling the operator to start a
|
|
1351
|
-
// service that was already up. The fix: when the log file is missing
|
|
1352
|
-
// but a live pidfile exists, surface the running pid + the path we
|
|
1353
|
-
// expected instead of the misleading start-hint.
|
|
534
|
+
test("restart: accept+migrate → dispatches through the supervisor", async () => {
|
|
1354
535
|
const h = makeHarness();
|
|
1355
536
|
try {
|
|
1356
537
|
seedVault(h.manifestPath);
|
|
1357
|
-
|
|
1358
|
-
const
|
|
1359
|
-
const code = await
|
|
538
|
+
const offerStub = makeOfferStub("migrated");
|
|
539
|
+
const sup = makeSupervisorStub();
|
|
540
|
+
const code = await restart("vault", {
|
|
1360
541
|
configDir: h.configDir,
|
|
1361
542
|
manifestPath: h.manifestPath,
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
543
|
+
log: () => {},
|
|
544
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
545
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1365
546
|
});
|
|
1366
547
|
expect(code).toBe(0);
|
|
1367
|
-
|
|
1368
|
-
expect(
|
|
1369
|
-
expect(out).toMatch(/no log file/);
|
|
1370
|
-
expect(out).not.toMatch(/parachute start vault/);
|
|
548
|
+
expect(offerStub.calls).toBe(1);
|
|
549
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
1371
550
|
} finally {
|
|
1372
551
|
h.cleanup();
|
|
1373
552
|
}
|
|
1374
553
|
});
|
|
1375
554
|
|
|
1376
|
-
test("
|
|
1377
|
-
// The other half of the disambiguation: pidfile exists but the process
|
|
1378
|
-
// is gone (stale pidfile, or cleanly shut down). That's effectively
|
|
1379
|
-
// "not running," so the original `parachute start` hint is still the
|
|
1380
|
-
// right message.
|
|
555
|
+
test("offer is NOT made on the supervisor arm (unit already installed)", async () => {
|
|
1381
556
|
const h = makeHarness();
|
|
1382
557
|
try {
|
|
1383
558
|
seedVault(h.manifestPath);
|
|
1384
|
-
|
|
1385
|
-
const
|
|
1386
|
-
const code = await
|
|
559
|
+
const offerStub = makeOfferStub("migrated");
|
|
560
|
+
const sup = makeSupervisorStub(); // unitInstalled: true
|
|
561
|
+
const code = await start("vault", {
|
|
1387
562
|
configDir: h.configDir,
|
|
1388
563
|
manifestPath: h.manifestPath,
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
564
|
+
log: () => {},
|
|
565
|
+
supervisor: sup.opts,
|
|
566
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1392
567
|
});
|
|
1393
568
|
expect(code).toBe(0);
|
|
1394
|
-
|
|
569
|
+
// Supervisor arm taken directly → the offer hook (no-unit only) never ran.
|
|
570
|
+
expect(offerStub.calls).toBe(0);
|
|
571
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
|
|
1395
572
|
} finally {
|
|
1396
573
|
h.cleanup();
|
|
1397
574
|
}
|
|
1398
575
|
});
|
|
1399
576
|
|
|
1400
|
-
test("
|
|
1401
|
-
// The happy path Aaron's title calls out: when the log file exists,
|
|
1402
|
-
// we tail it — independent of whether the pidfile is present. A
|
|
1403
|
-
// running daemon's logs are useful; a stopped daemon's prior logs are
|
|
1404
|
-
// useful too (post-mortem). Pidfile state only changes the message
|
|
1405
|
-
// when the file is missing.
|
|
577
|
+
test("restart: declined → offer fires EXACTLY ONCE (MUST-FIX 3), exit 1", async () => {
|
|
1406
578
|
const h = makeHarness();
|
|
1407
579
|
try {
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
//
|
|
1411
|
-
|
|
1412
|
-
const
|
|
580
|
+
seedVault(h.manifestPath);
|
|
581
|
+
// The operator DECLINES the offer. `restart` makes a single offer via the
|
|
582
|
+
// shared `requireSupervisedOrOffer` gate (no inner stop+start re-offer
|
|
583
|
+
// anymore — the detached stop-then-start arm is gone).
|
|
584
|
+
const offerStub = makeOfferStub("declined");
|
|
585
|
+
const code = await restart("vault", {
|
|
1413
586
|
configDir: h.configDir,
|
|
1414
587
|
manifestPath: h.manifestPath,
|
|
1415
|
-
|
|
1416
|
-
|
|
588
|
+
log: () => {},
|
|
589
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
1417
590
|
});
|
|
1418
|
-
expect(code).toBe(
|
|
1419
|
-
|
|
591
|
+
expect(code).toBe(1);
|
|
592
|
+
// EXACTLY ONE offer.
|
|
593
|
+
expect(offerStub.calls).toBe(1);
|
|
1420
594
|
} finally {
|
|
1421
595
|
h.cleanup();
|
|
1422
596
|
}
|
|
1423
597
|
});
|
|
1424
598
|
});
|
|
1425
599
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
process.kill(-pid, "SIGKILL");
|
|
1446
|
-
} catch {}
|
|
1447
|
-
}
|
|
1448
|
-
} finally {
|
|
1449
|
-
h.cleanup();
|
|
1450
|
-
}
|
|
1451
|
-
});
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// Group-aware kill / liveness primitives (hub#88). The detached MODULE spawner
|
|
602
|
+
// that created these process groups is retired (the supervisor's group-spawn +
|
|
603
|
+
// `defaultKillGroup` carry that role now, asserted in `supervisor.test.ts`), but
|
|
604
|
+
// `defaultKill` / `defaultAlive` survive as exported primitives — `logs` uses
|
|
605
|
+
// `defaultAlive`, and the supervisor's reaper mirrors `defaultKill`'s group/
|
|
606
|
+
// bare-pid fallback. These tests spawn a detached fixture process directly (not
|
|
607
|
+
// via the retired spawner) to keep that behavior under test.
|
|
608
|
+
// ---------------------------------------------------------------------------
|
|
609
|
+
|
|
610
|
+
/** Spawn a detached fixture child (its own process group) for the kill/alive tests. */
|
|
611
|
+
function spawnDetached(cmd: string[]): { pid: number; logFile: string } {
|
|
612
|
+
const dir = mkdtempSync(join(tmpdir(), "pcli-grp-"));
|
|
613
|
+
const logFile = ensureLogPath("test", dir);
|
|
614
|
+
const fd = openSync(logFile, "a");
|
|
615
|
+
const proc = Bun.spawn(cmd, { stdio: ["ignore", fd, fd], detached: true, env: process.env });
|
|
616
|
+
proc.unref();
|
|
617
|
+
return { pid: proc.pid, logFile };
|
|
618
|
+
}
|
|
1452
619
|
|
|
1453
|
-
|
|
1454
|
-
// (sleep) that keeps a resource — here, just stays alive. SIGKILL on the
|
|
1455
|
-
// wrapper PID alone leaves the grandchild running. With detached spawn +
|
|
1456
|
-
// group-kill, both go down. We assert by checking the grandchild's PID
|
|
1457
|
-
// is no longer kill-able after `defaultKill`.
|
|
620
|
+
describe("group-aware kill / liveness (hub#88)", () => {
|
|
1458
621
|
test("defaultKill takes down the wrapper and its grandchildren together", async () => {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
expect(grandchildPid).toBeGreaterThan(0);
|
|
1474
|
-
expect(grandchildPid).not.toBe(wrapperPid);
|
|
1475
|
-
// Both should be alive before kill.
|
|
1476
|
-
expect(() => process.kill(grandchildPid, 0)).not.toThrow();
|
|
622
|
+
// Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild PID via
|
|
623
|
+
// the log file, then `wait` so the wrapper sticks around (mirrors `pnpm exec
|
|
624
|
+
// tsx`'s shape). SIGKILL on the GROUP reaps both.
|
|
625
|
+
const { pid: wrapperPid, logFile } = spawnDetached([
|
|
626
|
+
"sh",
|
|
627
|
+
"-c",
|
|
628
|
+
"sleep 30 & echo $! >&2; wait",
|
|
629
|
+
]);
|
|
630
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
631
|
+
const logText = await Bun.file(logFile).text();
|
|
632
|
+
const grandchildPid = Number.parseInt(logText.trim().split("\n").pop() ?? "", 10);
|
|
633
|
+
expect(grandchildPid).toBeGreaterThan(0);
|
|
634
|
+
expect(grandchildPid).not.toBe(wrapperPid);
|
|
635
|
+
expect(() => process.kill(grandchildPid, 0)).not.toThrow();
|
|
1477
636
|
|
|
1478
|
-
|
|
637
|
+
defaultKill(wrapperPid, "SIGKILL");
|
|
1479
638
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
} catch {
|
|
1487
|
-
grandchildStillAlive = false;
|
|
1488
|
-
}
|
|
1489
|
-
expect(grandchildStillAlive).toBe(false);
|
|
1490
|
-
} finally {
|
|
1491
|
-
h.cleanup();
|
|
639
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
640
|
+
let grandchildStillAlive = true;
|
|
641
|
+
try {
|
|
642
|
+
process.kill(grandchildPid, 0);
|
|
643
|
+
} catch {
|
|
644
|
+
grandchildStillAlive = false;
|
|
1492
645
|
}
|
|
646
|
+
expect(grandchildStillAlive).toBe(false);
|
|
1493
647
|
});
|
|
1494
648
|
|
|
1495
|
-
// defaultAlive's post-fix semantics: returns true while any group member
|
|
1496
|
-
// is alive (the wrapper stays in the group as long as it's running),
|
|
1497
|
-
// false after the group drains.
|
|
1498
649
|
test("defaultAlive reports group liveness for detached children", async () => {
|
|
1499
|
-
const
|
|
650
|
+
const { pid } = spawnDetached(["sh", "-c", "sleep 2"]);
|
|
1500
651
|
try {
|
|
1501
|
-
|
|
1502
|
-
const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2"], logFile);
|
|
1503
|
-
try {
|
|
1504
|
-
expect(defaultAlive(pid)).toBe(true);
|
|
1505
|
-
} finally {
|
|
1506
|
-
try {
|
|
1507
|
-
process.kill(-pid, "SIGKILL");
|
|
1508
|
-
} catch {}
|
|
1509
|
-
}
|
|
1510
|
-
// Wait for the kill to drain the group, then re-check.
|
|
1511
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1512
|
-
expect(defaultAlive(pid)).toBe(false);
|
|
652
|
+
expect(defaultAlive(pid)).toBe(true);
|
|
1513
653
|
} finally {
|
|
1514
|
-
|
|
654
|
+
try {
|
|
655
|
+
process.kill(-pid, "SIGKILL");
|
|
656
|
+
} catch {}
|
|
1515
657
|
}
|
|
658
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
659
|
+
expect(defaultAlive(pid)).toBe(false);
|
|
1516
660
|
});
|
|
1517
661
|
|
|
1518
|
-
// Legacy pidfile compatibility: a pre-detached pidfile holds a positive
|
|
1519
|
-
// PID whose pgid is the parent shell, not the pid itself. defaultAlive
|
|
1520
|
-
// must fall back to a bare-pid check so the next `stop` actually runs;
|
|
1521
|
-
// defaultKill must fall back to a bare-pid signal so it can be reaped.
|
|
1522
662
|
test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
// ESRCH and we should fall back.
|
|
663
|
+
// A non-detached child shares the test runner's pgid, so kill(-pid, 0) will
|
|
664
|
+
// ESRCH and both must fall back to a bare-pid path.
|
|
1526
665
|
const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
1527
666
|
const pid = proc.pid;
|
|
1528
667
|
try {
|
|
@@ -1538,289 +677,120 @@ describe("process-group lifecycle (hub#88)", () => {
|
|
|
1538
677
|
});
|
|
1539
678
|
});
|
|
1540
679
|
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
* straight to hub-control.ts. These tests inject `ensureRunning`/`stop`
|
|
1546
|
-
* stubs so we don't actually fork bun.
|
|
1547
|
-
*/
|
|
1548
|
-
describe("parachute start|stop|restart hub", () => {
|
|
1549
|
-
test("start hub: dispatches to ensureHubRunning, propagates configDir + issuer", async () => {
|
|
1550
|
-
const h = makeHarness();
|
|
1551
|
-
try {
|
|
1552
|
-
const log: string[] = [];
|
|
1553
|
-
const ensureCalls: Array<{ configDir?: string; issuer?: string }> = [];
|
|
1554
|
-
const code = await start("hub", {
|
|
1555
|
-
configDir: h.configDir,
|
|
1556
|
-
manifestPath: h.manifestPath,
|
|
1557
|
-
hubOrigin: "https://hub.example.com",
|
|
1558
|
-
hub: {
|
|
1559
|
-
ensureRunning: async (opts) => {
|
|
1560
|
-
ensureCalls.push({ configDir: opts.configDir, issuer: opts.issuer });
|
|
1561
|
-
return { pid: 4711, port: 1939, started: true };
|
|
1562
|
-
},
|
|
1563
|
-
},
|
|
1564
|
-
log: (l) => log.push(l),
|
|
1565
|
-
});
|
|
1566
|
-
expect(code).toBe(0);
|
|
1567
|
-
expect(ensureCalls).toHaveLength(1);
|
|
1568
|
-
expect(ensureCalls[0]).toEqual({
|
|
1569
|
-
configDir: h.configDir,
|
|
1570
|
-
issuer: "https://hub.example.com",
|
|
1571
|
-
});
|
|
1572
|
-
} finally {
|
|
1573
|
-
h.cleanup();
|
|
1574
|
-
}
|
|
1575
|
-
});
|
|
1576
|
-
|
|
1577
|
-
test("start hub: reports already-running cleanly when ensureHubRunning returns started=false", async () => {
|
|
1578
|
-
const h = makeHarness();
|
|
1579
|
-
try {
|
|
1580
|
-
const log: string[] = [];
|
|
1581
|
-
const code = await start("hub", {
|
|
1582
|
-
configDir: h.configDir,
|
|
1583
|
-
manifestPath: h.manifestPath,
|
|
1584
|
-
hub: {
|
|
1585
|
-
ensureRunning: async () => ({ pid: 8888, port: 1939, started: false }),
|
|
1586
|
-
},
|
|
1587
|
-
log: (l) => log.push(l),
|
|
1588
|
-
});
|
|
1589
|
-
expect(code).toBe(0);
|
|
1590
|
-
expect(log.join("\n")).toMatch(/hub already running \(pid 8888\) on port 1939/);
|
|
1591
|
-
} finally {
|
|
1592
|
-
h.cleanup();
|
|
1593
|
-
}
|
|
1594
|
-
});
|
|
1595
|
-
|
|
1596
|
-
test("start hub: surfaces ensureHubRunning errors as exit 1", async () => {
|
|
1597
|
-
const h = makeHarness();
|
|
1598
|
-
try {
|
|
1599
|
-
const log: string[] = [];
|
|
1600
|
-
const code = await start("hub", {
|
|
1601
|
-
configDir: h.configDir,
|
|
1602
|
-
manifestPath: h.manifestPath,
|
|
1603
|
-
hub: {
|
|
1604
|
-
ensureRunning: async () => {
|
|
1605
|
-
throw new Error("hub: port 1939 unavailable");
|
|
1606
|
-
},
|
|
1607
|
-
},
|
|
1608
|
-
log: (l) => log.push(l),
|
|
1609
|
-
});
|
|
1610
|
-
expect(code).toBe(1);
|
|
1611
|
-
expect(log.join("\n")).toMatch(/hub failed to start.*port 1939 unavailable/);
|
|
1612
|
-
} finally {
|
|
1613
|
-
h.cleanup();
|
|
1614
|
-
}
|
|
1615
|
-
});
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
// `parachute logs <svc>` — unchanged by Phase 5b. Reads the per-service logfile
|
|
682
|
+
// keyed by short name (the readers §7.5 keeps). Includes the internal `hub`.
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
1616
684
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
// (and to make it throw without failing start); a separate test drives the
|
|
1620
|
-
// REAL self-heal against an on-disk operator token + hub.db.
|
|
1621
|
-
test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
|
|
685
|
+
describe("parachute logs", () => {
|
|
686
|
+
test("hint when no log file exists", async () => {
|
|
1622
687
|
const h = makeHarness();
|
|
1623
688
|
try {
|
|
689
|
+
seedVault(h.manifestPath);
|
|
1624
690
|
const log: string[] = [];
|
|
1625
|
-
const
|
|
1626
|
-
const code = await start("hub", {
|
|
691
|
+
const code = await logs("vault", {
|
|
1627
692
|
configDir: h.configDir,
|
|
1628
693
|
manifestPath: h.manifestPath,
|
|
1629
|
-
hubOrigin: "https://hub.example.com",
|
|
1630
|
-
hub: {
|
|
1631
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1632
|
-
selfHealOperatorToken: async (args) => {
|
|
1633
|
-
calls.push({ issuer: args.issuer, configDir: args.configDir });
|
|
1634
|
-
return {
|
|
1635
|
-
kind: "rotated",
|
|
1636
|
-
path: "/x/operator.token",
|
|
1637
|
-
scopeSet: "admin",
|
|
1638
|
-
expiresAt: "z",
|
|
1639
|
-
};
|
|
1640
|
-
},
|
|
1641
|
-
},
|
|
1642
694
|
log: (l) => log.push(l),
|
|
1643
695
|
});
|
|
1644
696
|
expect(code).toBe(0);
|
|
1645
|
-
expect(
|
|
1646
|
-
// Rotation emits an operator-facing line.
|
|
1647
|
-
expect(log.join("\n")).toMatch(
|
|
1648
|
-
/refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
|
|
1649
|
-
);
|
|
1650
|
-
} finally {
|
|
1651
|
-
h.cleanup();
|
|
1652
|
-
}
|
|
1653
|
-
});
|
|
1654
|
-
|
|
1655
|
-
test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
|
|
1656
|
-
const h = makeHarness();
|
|
1657
|
-
try {
|
|
1658
|
-
let called = false;
|
|
1659
|
-
// No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
|
|
1660
|
-
// yields undefined, so the self-heal seam must NOT be called.
|
|
1661
|
-
const code = await start("hub", {
|
|
1662
|
-
configDir: h.configDir,
|
|
1663
|
-
manifestPath: h.manifestPath,
|
|
1664
|
-
hub: {
|
|
1665
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1666
|
-
selfHealOperatorToken: async () => {
|
|
1667
|
-
called = true;
|
|
1668
|
-
return { kind: "absent" };
|
|
1669
|
-
},
|
|
1670
|
-
},
|
|
1671
|
-
log: () => {},
|
|
1672
|
-
});
|
|
1673
|
-
expect(code).toBe(0);
|
|
1674
|
-
expect(called).toBe(false);
|
|
697
|
+
expect(log.join("\n")).toMatch(/no logs yet for vault/);
|
|
1675
698
|
} finally {
|
|
1676
699
|
h.cleanup();
|
|
1677
700
|
}
|
|
1678
701
|
});
|
|
1679
702
|
|
|
1680
|
-
test("
|
|
703
|
+
test("prints last N lines in one-shot mode", async () => {
|
|
1681
704
|
const h = makeHarness();
|
|
1682
705
|
try {
|
|
706
|
+
seedVault(h.manifestPath);
|
|
707
|
+
const path = ensureLogPath("vault", h.configDir);
|
|
708
|
+
writeFileSync(path, "line one\nline two\nline three\n");
|
|
1683
709
|
const log: string[] = [];
|
|
1684
|
-
const code = await
|
|
710
|
+
const code = await logs("vault", {
|
|
1685
711
|
configDir: h.configDir,
|
|
1686
712
|
manifestPath: h.manifestPath,
|
|
1687
|
-
|
|
1688
|
-
hub: {
|
|
1689
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1690
|
-
selfHealOperatorToken: async () => {
|
|
1691
|
-
throw new Error("hub.db is locked");
|
|
1692
|
-
},
|
|
1693
|
-
},
|
|
713
|
+
lines: 2,
|
|
1694
714
|
log: (l) => log.push(l),
|
|
1695
715
|
});
|
|
1696
716
|
expect(code).toBe(0);
|
|
1697
|
-
|
|
1698
|
-
expect(log.join("\n")).toMatch(
|
|
1699
|
-
/operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
|
|
1700
|
-
);
|
|
717
|
+
expect(log).toEqual(["line two", "line three"]);
|
|
1701
718
|
} finally {
|
|
1702
719
|
h.cleanup();
|
|
1703
720
|
}
|
|
1704
721
|
});
|
|
1705
722
|
|
|
1706
|
-
test("
|
|
723
|
+
test("unknown service errors cleanly", async () => {
|
|
1707
724
|
const h = makeHarness();
|
|
1708
725
|
try {
|
|
1709
|
-
|
|
1710
|
-
// hub.db / operator.token, then drive the production self-heal seam.
|
|
1711
|
-
const db = openHubDb(hubDbPath(h.configDir));
|
|
1712
|
-
try {
|
|
1713
|
-
rotateSigningKey(db);
|
|
1714
|
-
await issueOperatorToken(db, "user-abc", {
|
|
1715
|
-
dir: h.configDir,
|
|
1716
|
-
issuer: "http://127.0.0.1:1939",
|
|
1717
|
-
scopeSet: "start",
|
|
1718
|
-
});
|
|
1719
|
-
} finally {
|
|
1720
|
-
db.close();
|
|
1721
|
-
}
|
|
1722
|
-
|
|
726
|
+
seedVault(h.manifestPath);
|
|
1723
727
|
const log: string[] = [];
|
|
1724
|
-
const code = await
|
|
728
|
+
const code = await logs("nope", {
|
|
1725
729
|
configDir: h.configDir,
|
|
1726
730
|
manifestPath: h.manifestPath,
|
|
1727
|
-
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
1728
|
-
// No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
|
|
1729
|
-
// (opens hub.db at <configDir>/hub.db).
|
|
1730
|
-
hub: {
|
|
1731
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1732
|
-
},
|
|
1733
731
|
log: (l) => log.push(l),
|
|
1734
732
|
});
|
|
1735
|
-
expect(code).toBe(
|
|
1736
|
-
expect(log.join("\n")).toMatch(
|
|
1737
|
-
/refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
|
|
1738
|
-
);
|
|
1739
|
-
|
|
1740
|
-
// The on-disk token now validates under the new issuer, scope-set preserved.
|
|
1741
|
-
const verifyDb = openHubDb(hubDbPath(h.configDir));
|
|
1742
|
-
try {
|
|
1743
|
-
const onDisk = await readOperatorTokenFile(h.configDir);
|
|
1744
|
-
expect(onDisk).not.toBeNull();
|
|
1745
|
-
const validated = await validateAccessToken(
|
|
1746
|
-
verifyDb,
|
|
1747
|
-
onDisk as string,
|
|
1748
|
-
"https://gitcoin-parachute.unforced.dev",
|
|
1749
|
-
);
|
|
1750
|
-
expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
|
|
1751
|
-
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
1752
|
-
} finally {
|
|
1753
|
-
verifyDb.close();
|
|
1754
|
-
}
|
|
733
|
+
expect(code).toBe(1);
|
|
734
|
+
expect(log.join("\n")).toMatch(/unknown service "nope"/);
|
|
1755
735
|
} finally {
|
|
1756
736
|
h.cleanup();
|
|
1757
737
|
}
|
|
1758
738
|
});
|
|
1759
739
|
|
|
1760
|
-
test("
|
|
740
|
+
test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
|
|
1761
741
|
const h = makeHarness();
|
|
1762
742
|
try {
|
|
743
|
+
seedVault(h.manifestPath);
|
|
744
|
+
// A pidfile reader still resolves: seed a live pid (this process) so the
|
|
745
|
+
// running-but-no-logfile diagnostic fires.
|
|
746
|
+
writePid("vault", process.pid, h.configDir);
|
|
1763
747
|
const log: string[] = [];
|
|
1764
|
-
const
|
|
1765
|
-
const code = await stop("hub", {
|
|
748
|
+
const code = await logs("vault", {
|
|
1766
749
|
configDir: h.configDir,
|
|
1767
750
|
manifestPath: h.manifestPath,
|
|
1768
|
-
|
|
1769
|
-
stop: async (opts) => {
|
|
1770
|
-
stopCalls.push({ configDir: opts.configDir });
|
|
1771
|
-
return true;
|
|
1772
|
-
},
|
|
1773
|
-
},
|
|
751
|
+
alive: () => true,
|
|
1774
752
|
log: (l) => log.push(l),
|
|
1775
753
|
});
|
|
1776
754
|
expect(code).toBe(0);
|
|
1777
|
-
expect(
|
|
1778
|
-
expect(stopCalls[0]?.configDir).toBe(h.configDir);
|
|
1779
|
-
expect(log.join("\n")).toMatch(/✓ hub stopped/);
|
|
755
|
+
expect(log.join("\n")).toMatch(/is running \(pid .*\) but no log file/);
|
|
1780
756
|
} finally {
|
|
1781
757
|
h.cleanup();
|
|
1782
758
|
}
|
|
1783
759
|
});
|
|
1784
760
|
|
|
1785
|
-
test("
|
|
761
|
+
test("stale pidfile + missing log file: falls through to start hint", async () => {
|
|
1786
762
|
const h = makeHarness();
|
|
1787
763
|
try {
|
|
764
|
+
seedVault(h.manifestPath);
|
|
765
|
+
writePid("vault", 999999, h.configDir);
|
|
1788
766
|
const log: string[] = [];
|
|
1789
|
-
const code = await
|
|
767
|
+
const code = await logs("vault", {
|
|
1790
768
|
configDir: h.configDir,
|
|
1791
769
|
manifestPath: h.manifestPath,
|
|
1792
|
-
|
|
770
|
+
alive: () => false,
|
|
1793
771
|
log: (l) => log.push(l),
|
|
1794
772
|
});
|
|
1795
773
|
expect(code).toBe(0);
|
|
1796
|
-
expect(log.join("\n")).toMatch(/
|
|
774
|
+
expect(log.join("\n")).toMatch(/no logs yet for vault/);
|
|
1797
775
|
} finally {
|
|
1798
776
|
h.cleanup();
|
|
1799
777
|
}
|
|
1800
778
|
});
|
|
1801
779
|
|
|
1802
|
-
test("
|
|
780
|
+
test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
|
|
1803
781
|
const h = makeHarness();
|
|
1804
782
|
try {
|
|
783
|
+
seedVault(h.manifestPath);
|
|
784
|
+
const path = ensureLogPath("vault", h.configDir);
|
|
785
|
+
writeFileSync(path, "boot line\n");
|
|
1805
786
|
const log: string[] = [];
|
|
1806
|
-
const
|
|
1807
|
-
const code = await restart("hub", {
|
|
787
|
+
const code = await logs("vault", {
|
|
1808
788
|
configDir: h.configDir,
|
|
1809
789
|
manifestPath: h.manifestPath,
|
|
1810
|
-
hub: {
|
|
1811
|
-
stop: async () => {
|
|
1812
|
-
order.push("stop");
|
|
1813
|
-
return true;
|
|
1814
|
-
},
|
|
1815
|
-
ensureRunning: async () => {
|
|
1816
|
-
order.push("start");
|
|
1817
|
-
return { pid: 5151, port: 1939, started: true };
|
|
1818
|
-
},
|
|
1819
|
-
},
|
|
1820
790
|
log: (l) => log.push(l),
|
|
1821
791
|
});
|
|
1822
792
|
expect(code).toBe(0);
|
|
1823
|
-
expect(
|
|
793
|
+
expect(log).toEqual(["boot line"]);
|
|
1824
794
|
} finally {
|
|
1825
795
|
h.cleanup();
|
|
1826
796
|
}
|
|
@@ -1829,8 +799,6 @@ describe("parachute start|stop|restart hub", () => {
|
|
|
1829
799
|
test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
|
|
1830
800
|
const h = makeHarness();
|
|
1831
801
|
try {
|
|
1832
|
-
// No log file yet — exercise the "no logs yet" branch, which still
|
|
1833
|
-
// returns 0. Goal of this test is just the unknown-service guard.
|
|
1834
802
|
const log: string[] = [];
|
|
1835
803
|
const code = await logs("hub", {
|
|
1836
804
|
configDir: h.configDir,
|