@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
|
@@ -1,36 +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
6
|
type LifecycleOpts,
|
|
7
7
|
defaultAlive,
|
|
8
8
|
defaultKill,
|
|
9
|
-
defaultSpawner,
|
|
10
9
|
logs,
|
|
11
10
|
restart,
|
|
12
11
|
start,
|
|
13
12
|
stop,
|
|
14
13
|
} from "../commands/lifecycle.ts";
|
|
15
|
-
import { readEnvFileValues } from "../env-file.ts";
|
|
16
|
-
import { writeHubPort } from "../hub-control.ts";
|
|
17
|
-
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
18
14
|
import type { HubUnitManagerOpResult } from "../hub-unit.ts";
|
|
19
|
-
import {
|
|
15
|
+
import type { MigrateOfferOpts, MigrateOfferResult } from "../migrate-offer.ts";
|
|
20
16
|
import {
|
|
21
17
|
type ModuleOp,
|
|
22
18
|
ModuleOpHttpError,
|
|
23
19
|
type ModuleOpResult,
|
|
24
20
|
NoOperatorTokenError,
|
|
25
21
|
} from "../module-ops-client.ts";
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
34
44
|
|
|
35
45
|
interface Harness {
|
|
36
46
|
configDir: string;
|
|
@@ -60,2226 +70,761 @@ function seedVault(manifestPath: string): void {
|
|
|
60
70
|
);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
version: "0.0.1",
|
|
71
|
-
},
|
|
72
|
-
manifestPath,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface ThirdPartySeed {
|
|
77
|
-
installDir: string;
|
|
78
|
-
manifestName?: string;
|
|
79
|
-
startCmd?: readonly string[];
|
|
80
|
-
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;
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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`.
|
|
88
87
|
*/
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const calls: Array<{
|
|
136
|
-
cmd: readonly string[];
|
|
137
|
-
logFile: string;
|
|
138
|
-
env?: Record<string, string>;
|
|
139
|
-
cwd?: string;
|
|
140
|
-
}> = [];
|
|
141
|
-
let i = 0;
|
|
142
|
-
return {
|
|
143
|
-
calls,
|
|
144
|
-
spawn(cmd, logFile, opts) {
|
|
145
|
-
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
146
|
-
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
|
+
},
|
|
147
134
|
},
|
|
148
135
|
};
|
|
136
|
+
return stub;
|
|
149
137
|
}
|
|
150
138
|
|
|
151
|
-
|
|
152
|
-
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe("start — supervisor path", () => {
|
|
145
|
+
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(start)", async () => {
|
|
153
146
|
const h = makeHarness();
|
|
154
147
|
try {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
148
|
+
const sup = makeSupervisorStub();
|
|
149
|
+
const log: string[] = [];
|
|
150
|
+
const code = await start("vault", {
|
|
157
151
|
configDir: h.configDir,
|
|
158
152
|
manifestPath: h.manifestPath,
|
|
159
|
-
log: (l) =>
|
|
153
|
+
log: (l) => log.push(l),
|
|
154
|
+
supervisor: sup.opts,
|
|
160
155
|
});
|
|
161
|
-
expect(code).toBe(
|
|
162
|
-
expect(
|
|
156
|
+
expect(code).toBe(0);
|
|
157
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
158
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
|
|
159
|
+
expect(log.join("\n")).toMatch(/✓ vault started/);
|
|
163
160
|
} finally {
|
|
164
161
|
h.cleanup();
|
|
165
162
|
}
|
|
166
163
|
});
|
|
167
164
|
|
|
168
|
-
test("
|
|
165
|
+
test("no svc, unit-installed → ensureHubUnit only (boots all modules), no driveModuleOp", async () => {
|
|
169
166
|
const h = makeHarness();
|
|
170
167
|
try {
|
|
171
|
-
|
|
172
|
-
const
|
|
173
|
-
const code = await start("notes", {
|
|
168
|
+
const sup = makeSupervisorStub();
|
|
169
|
+
const code = await start(undefined, {
|
|
174
170
|
configDir: h.configDir,
|
|
175
171
|
manifestPath: h.manifestPath,
|
|
176
|
-
log: (
|
|
172
|
+
log: () => {},
|
|
173
|
+
supervisor: sup.opts,
|
|
177
174
|
});
|
|
178
|
-
expect(code).toBe(
|
|
179
|
-
expect(
|
|
175
|
+
expect(code).toBe(0);
|
|
176
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
177
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
180
178
|
} finally {
|
|
181
179
|
h.cleanup();
|
|
182
180
|
}
|
|
183
181
|
});
|
|
184
182
|
|
|
185
|
-
test("
|
|
183
|
+
test("module svc, NoOperatorTokenError → actionable message surfaced (not raw-thrown)", async () => {
|
|
186
184
|
const h = makeHarness();
|
|
187
185
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
const sup = makeSupervisorStub({
|
|
187
|
+
driveResponder: () => {
|
|
188
|
+
throw new NoOperatorTokenError();
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
const log: string[] = [];
|
|
191
192
|
const code = await start("vault", {
|
|
192
193
|
configDir: h.configDir,
|
|
193
194
|
manifestPath: h.manifestPath,
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
log: (l) => log.push(l),
|
|
196
|
+
supervisor: sup.opts,
|
|
196
197
|
});
|
|
197
|
-
expect(code).toBe(
|
|
198
|
-
expect(
|
|
199
|
-
expect(
|
|
200
|
-
expect(spawner.calls[0]?.logFile).toBe(logPath("vault", h.configDir));
|
|
201
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
202
|
-
expect(logs.join("\n")).toMatch(/vault started \(pid 4242\)/);
|
|
198
|
+
expect(code).toBe(1);
|
|
199
|
+
expect(log.join("\n")).toMatch(/no operator token/);
|
|
200
|
+
expect(log.join("\n")).toMatch(/parachute auth rotate-operator/);
|
|
203
201
|
} finally {
|
|
204
202
|
h.cleanup();
|
|
205
203
|
}
|
|
206
204
|
});
|
|
207
205
|
|
|
208
|
-
test("
|
|
206
|
+
test("module svc, 400 not_installed → actionable install hint", async () => {
|
|
209
207
|
const h = makeHarness();
|
|
210
208
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
209
|
+
const sup = makeSupervisorStub({
|
|
210
|
+
driveResponder: () => {
|
|
211
|
+
throw new ModuleOpHttpError(400, "not_installed", "vault is not installed");
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
const log: string[] = [];
|
|
214
215
|
const code = await start("vault", {
|
|
215
216
|
configDir: h.configDir,
|
|
216
217
|
manifestPath: h.manifestPath,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
which: () => null,
|
|
220
|
-
log: (l) => logs.push(l),
|
|
218
|
+
log: (l) => log.push(l),
|
|
219
|
+
supervisor: sup.opts,
|
|
221
220
|
});
|
|
222
221
|
expect(code).toBe(1);
|
|
223
|
-
|
|
224
|
-
expect(
|
|
225
|
-
const out = logs.join("\n");
|
|
226
|
-
expect(out).toMatch(/vault failed to start/);
|
|
227
|
-
// The friendly install block names the binary + its install path.
|
|
228
|
-
expect(out).toContain("parachute-vault is required to run the Vault module Hub supervises");
|
|
229
|
-
expect(out).toContain("parachute install vault");
|
|
230
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
222
|
+
expect(log.join("\n")).toMatch(/not installed/);
|
|
223
|
+
expect(log.join("\n")).toMatch(/parachute install vault/);
|
|
231
224
|
} finally {
|
|
232
225
|
h.cleanup();
|
|
233
226
|
}
|
|
234
227
|
});
|
|
228
|
+
});
|
|
235
229
|
|
|
236
|
-
|
|
230
|
+
describe("stop — supervisor path", () => {
|
|
231
|
+
test("module svc, hub UP → driveModuleOp(stop), no ensureHubUnit", async () => {
|
|
237
232
|
const h = makeHarness();
|
|
238
233
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
234
|
+
const sup = makeSupervisorStub({ health: true });
|
|
235
|
+
const log: string[] = [];
|
|
236
|
+
const code = await stop("vault", {
|
|
241
237
|
configDir: h.configDir,
|
|
242
238
|
manifestPath: h.manifestPath,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
log: () => {},
|
|
239
|
+
log: (l) => log.push(l),
|
|
240
|
+
supervisor: sup.opts,
|
|
246
241
|
});
|
|
247
|
-
|
|
248
|
-
expect(
|
|
249
|
-
expect(
|
|
250
|
-
expect(
|
|
242
|
+
expect(code).toBe(0);
|
|
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/);
|
|
251
247
|
} finally {
|
|
252
248
|
h.cleanup();
|
|
253
249
|
}
|
|
254
250
|
});
|
|
255
251
|
|
|
256
|
-
test("
|
|
252
|
+
test("module svc, hub DOWN → success WITHOUT starting the hub or driving stop", async () => {
|
|
257
253
|
const h = makeHarness();
|
|
258
254
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
await
|
|
262
|
-
configDir: h.configDir,
|
|
263
|
-
manifestPath: h.manifestPath,
|
|
264
|
-
spawner: makeSpawner([1]),
|
|
265
|
-
which: () => null,
|
|
266
|
-
log: () => {},
|
|
267
|
-
});
|
|
268
|
-
expect(
|
|
269
|
-
readManifest(h.manifestPath).services.find((s) => s.name === "parachute-vault")
|
|
270
|
-
?.lastStartError,
|
|
271
|
-
).toBeDefined();
|
|
272
|
-
// Second start succeeds (binary present via the permissive default which
|
|
273
|
-
// — stub spawner path) → clears the recorded error.
|
|
274
|
-
await start("vault", {
|
|
255
|
+
const sup = makeSupervisorStub({ health: false });
|
|
256
|
+
const log: string[] = [];
|
|
257
|
+
const code = await stop("vault", {
|
|
275
258
|
configDir: h.configDir,
|
|
276
259
|
manifestPath: h.manifestPath,
|
|
277
|
-
|
|
278
|
-
|
|
260
|
+
log: (l) => log.push(l),
|
|
261
|
+
supervisor: sup.opts,
|
|
279
262
|
});
|
|
280
|
-
expect(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
).
|
|
263
|
+
expect(code).toBe(0);
|
|
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/);
|
|
284
268
|
} finally {
|
|
285
269
|
h.cleanup();
|
|
286
270
|
}
|
|
287
271
|
});
|
|
288
272
|
|
|
289
|
-
test("
|
|
273
|
+
test("stop hub → platform manager (stopHubUnit), never a PID signal", async () => {
|
|
290
274
|
const h = makeHarness();
|
|
291
275
|
try {
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
const code = await
|
|
276
|
+
const sup = makeSupervisorStub();
|
|
277
|
+
const log: string[] = [];
|
|
278
|
+
const code = await stop("hub", {
|
|
295
279
|
configDir: h.configDir,
|
|
296
280
|
manifestPath: h.manifestPath,
|
|
297
|
-
|
|
298
|
-
|
|
281
|
+
log: (l) => log.push(l),
|
|
282
|
+
supervisor: sup.opts,
|
|
299
283
|
});
|
|
300
284
|
expect(code).toBe(0);
|
|
301
|
-
|
|
302
|
-
expect(
|
|
303
|
-
expect(
|
|
304
|
-
const portIdx = cmd.indexOf("--port");
|
|
305
|
-
expect(portIdx).toBeGreaterThan(-1);
|
|
306
|
-
expect(cmd[portIdx + 1]).toBe("5173");
|
|
307
|
-
const mountIdx = cmd.indexOf("--mount");
|
|
308
|
-
expect(mountIdx).toBeGreaterThan(-1);
|
|
309
|
-
expect(cmd[mountIdx + 1]).toBe("/notes");
|
|
285
|
+
expect(sup.stopHubCalls).toBe(1);
|
|
286
|
+
expect(sup.healthProbes).toBe(0);
|
|
287
|
+
expect(log.join("\n")).toMatch(/✓ hub stopped/);
|
|
310
288
|
} finally {
|
|
311
289
|
h.cleanup();
|
|
312
290
|
}
|
|
313
291
|
});
|
|
314
292
|
|
|
315
|
-
test("no-
|
|
293
|
+
test("no svc, unit-installed → stop the hub unit (manager)", async () => {
|
|
316
294
|
const h = makeHarness();
|
|
317
295
|
try {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
const spawner = makeSpawner([9999]);
|
|
321
|
-
const logs: string[] = [];
|
|
322
|
-
const code = await start("vault", {
|
|
296
|
+
const sup = makeSupervisorStub();
|
|
297
|
+
const code = await stop(undefined, {
|
|
323
298
|
configDir: h.configDir,
|
|
324
299
|
manifestPath: h.manifestPath,
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
log: (l) => logs.push(l),
|
|
300
|
+
log: () => {},
|
|
301
|
+
supervisor: sup.opts,
|
|
328
302
|
});
|
|
329
303
|
expect(code).toBe(0);
|
|
330
|
-
expect(
|
|
331
|
-
expect(
|
|
332
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
304
|
+
expect(sup.stopHubCalls).toBe(1);
|
|
305
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
333
306
|
} finally {
|
|
334
307
|
h.cleanup();
|
|
335
308
|
}
|
|
336
309
|
});
|
|
310
|
+
});
|
|
337
311
|
|
|
338
|
-
|
|
312
|
+
describe("restart — supervisor path", () => {
|
|
313
|
+
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(restart)", async () => {
|
|
339
314
|
const h = makeHarness();
|
|
340
315
|
try {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
const code = await start("vault", {
|
|
316
|
+
const sup = makeSupervisorStub();
|
|
317
|
+
const log: string[] = [];
|
|
318
|
+
const code = await restart("vault", {
|
|
345
319
|
configDir: h.configDir,
|
|
346
320
|
manifestPath: h.manifestPath,
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
// post-spawn settle (hub#194) calls alive(pid) on the new pid,
|
|
350
|
-
// so we differentiate per-pid rather than blanket-false.
|
|
351
|
-
alive: (pid) => pid === 7777,
|
|
352
|
-
sleep: async () => {},
|
|
353
|
-
log: () => {},
|
|
321
|
+
log: (l) => log.push(l),
|
|
322
|
+
supervisor: sup.opts,
|
|
354
323
|
});
|
|
355
324
|
expect(code).toBe(0);
|
|
356
|
-
expect(
|
|
357
|
-
expect(
|
|
325
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
326
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
327
|
+
expect(log.join("\n")).toMatch(/✓ vault restarted/);
|
|
358
328
|
} finally {
|
|
359
329
|
h.cleanup();
|
|
360
330
|
}
|
|
361
331
|
});
|
|
362
332
|
|
|
363
|
-
test("
|
|
333
|
+
test("404 not_supervised on restart → fall through to driveModuleOp(start)", async () => {
|
|
364
334
|
const h = makeHarness();
|
|
365
335
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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", {
|
|
370
346
|
configDir: h.configDir,
|
|
371
347
|
manifestPath: h.manifestPath,
|
|
372
|
-
|
|
373
|
-
|
|
348
|
+
log: (l) => log.push(l),
|
|
349
|
+
supervisor: sup.opts,
|
|
374
350
|
});
|
|
375
351
|
expect(code).toBe(0);
|
|
376
|
-
|
|
377
|
-
expect(
|
|
378
|
-
|
|
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/);
|
|
379
358
|
} finally {
|
|
380
359
|
h.cleanup();
|
|
381
360
|
}
|
|
382
361
|
});
|
|
383
362
|
|
|
384
|
-
test("
|
|
385
|
-
// Users who installed during the brief Notes→Lens window (Apr 19–22)
|
|
386
|
-
// will still have `parachute-lens` in services.json until their notes
|
|
387
|
-
// package next boots and rewrites the row. Without the manifest alias,
|
|
388
|
-
// shortNameForManifest returns undefined, resolveTargets skips the
|
|
389
|
-
// entry, and they get "No manageable services" with no hint.
|
|
363
|
+
test("restart hub → platform manager (restartHubUnit), never a PID signal", async () => {
|
|
390
364
|
const h = makeHarness();
|
|
391
365
|
try {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
port: 5173,
|
|
396
|
-
paths: ["/lens"],
|
|
397
|
-
health: "/lens/health",
|
|
398
|
-
version: "0.0.1",
|
|
399
|
-
},
|
|
400
|
-
h.manifestPath,
|
|
401
|
-
);
|
|
402
|
-
const spawner = makeSpawner([5151]);
|
|
403
|
-
const code = await start(undefined, {
|
|
366
|
+
const sup = makeSupervisorStub();
|
|
367
|
+
const log: string[] = [];
|
|
368
|
+
const code = await restart("hub", {
|
|
404
369
|
configDir: h.configDir,
|
|
405
370
|
manifestPath: h.manifestPath,
|
|
406
|
-
|
|
407
|
-
|
|
371
|
+
log: (l) => log.push(l),
|
|
372
|
+
supervisor: sup.opts,
|
|
408
373
|
});
|
|
409
374
|
expect(code).toBe(0);
|
|
410
|
-
expect(
|
|
411
|
-
expect(
|
|
412
|
-
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/);
|
|
413
378
|
} finally {
|
|
414
379
|
h.cleanup();
|
|
415
380
|
}
|
|
416
381
|
});
|
|
417
382
|
|
|
418
|
-
test("
|
|
383
|
+
test("no svc, unit-installed → restart the hub unit (manager), not a fan-out", async () => {
|
|
419
384
|
const h = makeHarness();
|
|
420
385
|
try {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
join(h.configDir, "expose-state.json"),
|
|
424
|
-
JSON.stringify({
|
|
425
|
-
version: 1,
|
|
426
|
-
layer: "tailnet",
|
|
427
|
-
mode: "path",
|
|
428
|
-
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
429
|
-
port: 443,
|
|
430
|
-
funnel: false,
|
|
431
|
-
entries: [],
|
|
432
|
-
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
433
|
-
}),
|
|
434
|
-
);
|
|
435
|
-
const spawner = makeSpawner([4242]);
|
|
436
|
-
const code = await start("vault", {
|
|
386
|
+
const sup = makeSupervisorStub();
|
|
387
|
+
const code = await restart(undefined, {
|
|
437
388
|
configDir: h.configDir,
|
|
438
389
|
manifestPath: h.manifestPath,
|
|
439
|
-
spawner,
|
|
440
390
|
log: () => {},
|
|
391
|
+
supervisor: sup.opts,
|
|
441
392
|
});
|
|
442
393
|
expect(code).toBe(0);
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
446
|
-
PORT: "1940",
|
|
447
|
-
PARACHUTE_HUB_ORIGIN: "https://parachute.taildf9ce2.ts.net",
|
|
448
|
-
});
|
|
449
|
-
// OAuth issuer-mismatch fix: the spawn-env injection above is ephemeral
|
|
450
|
-
// (lost on the next launchd / systemd boot). `start vault` ALSO persists
|
|
451
|
-
// the public origin into vault/.env so the out-of-band daemon validates
|
|
452
|
-
// hub-minted JWTs' `iss` against it. Without this, every reconnect after
|
|
453
|
-
// a reboot / crash-restart 401s.
|
|
454
|
-
expect(readEnvFileValues(join(h.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
455
|
-
"https://parachute.taildf9ce2.ts.net",
|
|
456
|
-
);
|
|
394
|
+
expect(sup.restartHubCalls).toBe(1);
|
|
395
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
457
396
|
} finally {
|
|
458
397
|
h.cleanup();
|
|
459
398
|
}
|
|
460
399
|
});
|
|
400
|
+
});
|
|
401
|
+
|
|
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
|
+
}
|
|
461
427
|
|
|
462
|
-
test("
|
|
463
|
-
// Existing-broken-deploy shape: a Cloudflare deploy whose vault/.env had a
|
|
464
|
-
// loopback PARACHUTE_HUB_ORIGIN baked in (or was unset and a prior run
|
|
465
|
-
// wrote loopback). expose-state.json carries the real public origin. A
|
|
466
|
-
// plain `parachute start vault` must rewrite vault/.env to the public
|
|
467
|
-
// origin so the daemon stops 401ing hub tokens — the self-heal half of the
|
|
468
|
-
// Cloudflare 401 fix.
|
|
428
|
+
test("no unit + offer disabled (omitted) → actionable migrate error, exit 1, no spawn", async () => {
|
|
469
429
|
const h = makeHarness();
|
|
470
430
|
try {
|
|
471
431
|
seedVault(h.manifestPath);
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
layer: "public",
|
|
477
|
-
mode: "subdomain",
|
|
478
|
-
canonicalFqdn: "gitcoin-parachute.unforced.dev",
|
|
479
|
-
port: 1939,
|
|
480
|
-
funnel: false,
|
|
481
|
-
entries: [{ kind: "proxy", mount: "/", target: "http://localhost:1939", service: "hub" }],
|
|
482
|
-
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
483
|
-
}),
|
|
484
|
-
);
|
|
485
|
-
// Pre-seed vault/.env with a stale loopback value (the broken state).
|
|
486
|
-
mkdirSync(join(h.configDir, "vault"), { recursive: true });
|
|
487
|
-
writeFileSync(
|
|
488
|
-
join(h.configDir, "vault", ".env"),
|
|
489
|
-
"PARACHUTE_HUB_ORIGIN=http://127.0.0.1:1939\n",
|
|
490
|
-
);
|
|
491
|
-
const spawner = makeSpawner([4242]);
|
|
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.
|
|
492
436
|
const code = await start("vault", {
|
|
493
437
|
configDir: h.configDir,
|
|
494
438
|
manifestPath: h.manifestPath,
|
|
495
|
-
|
|
496
|
-
log: () => {},
|
|
439
|
+
log: (l) => log.push(l),
|
|
497
440
|
});
|
|
498
|
-
expect(code).toBe(
|
|
499
|
-
expect(
|
|
500
|
-
|
|
501
|
-
);
|
|
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/);
|
|
502
444
|
} finally {
|
|
503
445
|
h.cleanup();
|
|
504
446
|
}
|
|
505
447
|
});
|
|
506
448
|
|
|
507
|
-
test("
|
|
449
|
+
test("start: accept+migrate → dispatches through the supervisor (no detached spawn)", async () => {
|
|
508
450
|
const h = makeHarness();
|
|
509
451
|
try {
|
|
510
452
|
seedVault(h.manifestPath);
|
|
511
|
-
|
|
512
|
-
const
|
|
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.
|
|
513
457
|
const code = await start("vault", {
|
|
514
458
|
configDir: h.configDir,
|
|
515
459
|
manifestPath: h.manifestPath,
|
|
516
|
-
spawner,
|
|
517
460
|
log: () => {},
|
|
461
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
462
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
518
463
|
});
|
|
519
464
|
expect(code).toBe(0);
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
// absent of the key on a loopback-only start.
|
|
524
|
-
expect(existsSync(join(h.configDir, "vault", ".env"))).toBe(false);
|
|
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" }]);
|
|
525
468
|
} finally {
|
|
526
469
|
h.cleanup();
|
|
527
470
|
}
|
|
528
471
|
});
|
|
529
472
|
|
|
530
|
-
test("
|
|
473
|
+
test("start: declined → actionable-error path, exit 1 (no spawn)", async () => {
|
|
531
474
|
const h = makeHarness();
|
|
532
475
|
try {
|
|
533
476
|
seedVault(h.manifestPath);
|
|
534
|
-
|
|
535
|
-
const spawner = makeSpawner([4242]);
|
|
477
|
+
const offerStub = makeOfferStub("declined");
|
|
536
478
|
const code = await start("vault", {
|
|
537
479
|
configDir: h.configDir,
|
|
538
480
|
manifestPath: h.manifestPath,
|
|
539
|
-
spawner,
|
|
540
481
|
log: () => {},
|
|
482
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
541
483
|
});
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
});
|
|
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.
|
|
486
|
+
expect(code).toBe(1);
|
|
487
|
+
expect(offerStub.calls).toBe(1);
|
|
547
488
|
} finally {
|
|
548
489
|
h.cleanup();
|
|
549
490
|
}
|
|
550
491
|
});
|
|
551
492
|
|
|
552
|
-
test("
|
|
493
|
+
test("start: migrate-failed → actionable-error path, exit 1 (fail-safe, no spawn)", async () => {
|
|
553
494
|
const h = makeHarness();
|
|
554
495
|
try {
|
|
555
496
|
seedVault(h.manifestPath);
|
|
556
|
-
|
|
557
|
-
join(h.configDir, "expose-state.json"),
|
|
558
|
-
JSON.stringify({
|
|
559
|
-
version: 1,
|
|
560
|
-
layer: "tailnet",
|
|
561
|
-
mode: "path",
|
|
562
|
-
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
563
|
-
port: 443,
|
|
564
|
-
funnel: false,
|
|
565
|
-
entries: [],
|
|
566
|
-
hubOrigin: "https://parachute.taildf9ce2.ts.net",
|
|
567
|
-
}),
|
|
568
|
-
);
|
|
569
|
-
const spawner = makeSpawner([4242]);
|
|
497
|
+
const offerStub = makeOfferStub("migrate-failed");
|
|
570
498
|
const code = await start("vault", {
|
|
571
499
|
configDir: h.configDir,
|
|
572
500
|
manifestPath: h.manifestPath,
|
|
573
|
-
spawner,
|
|
574
|
-
hubOrigin: "https://override.example.com/",
|
|
575
501
|
log: () => {},
|
|
502
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
576
503
|
});
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
});
|
|
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);
|
|
582
508
|
} finally {
|
|
583
509
|
h.cleanup();
|
|
584
510
|
}
|
|
585
511
|
});
|
|
586
512
|
|
|
587
|
-
test("
|
|
513
|
+
test("stop: accept+migrate → dispatches through the supervisor", async () => {
|
|
588
514
|
const h = makeHarness();
|
|
589
515
|
try {
|
|
590
516
|
seedVault(h.manifestPath);
|
|
591
|
-
const
|
|
592
|
-
const
|
|
517
|
+
const offerStub = makeOfferStub("migrated");
|
|
518
|
+
const sup = makeSupervisorStub();
|
|
519
|
+
const code = await stop("vault", {
|
|
593
520
|
configDir: h.configDir,
|
|
594
521
|
manifestPath: h.manifestPath,
|
|
595
|
-
spawner,
|
|
596
522
|
log: () => {},
|
|
523
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
524
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
597
525
|
});
|
|
598
526
|
expect(code).toBe(0);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
// from services.json. Test renamed from "omits env" to reflect
|
|
602
|
-
// the new minimum-env shape.
|
|
603
|
-
expect(spawner.calls[0]?.env).toEqual({ PORT: "1940" });
|
|
527
|
+
expect(offerStub.calls).toBe(1);
|
|
528
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
|
|
604
529
|
} finally {
|
|
605
530
|
h.cleanup();
|
|
606
531
|
}
|
|
607
532
|
});
|
|
608
533
|
|
|
609
|
-
test("
|
|
610
|
-
// Scribe's API key prompt writes GROQ_API_KEY into ~/.parachute/scribe/.env.
|
|
611
|
-
// Scribe itself doesn't auto-load .env, so `parachute start scribe` has to
|
|
612
|
-
// forward the values into the child env or the API key won't take effect.
|
|
534
|
+
test("restart: accept+migrate → dispatches through the supervisor", async () => {
|
|
613
535
|
const h = makeHarness();
|
|
614
536
|
try {
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
paths: ["/scribe"],
|
|
620
|
-
health: "/scribe/health",
|
|
621
|
-
version: "0.1.0",
|
|
622
|
-
},
|
|
623
|
-
h.manifestPath,
|
|
624
|
-
);
|
|
625
|
-
ensureLogPath("scribe", h.configDir);
|
|
626
|
-
writeFileSync(
|
|
627
|
-
join(h.configDir, "scribe", ".env"),
|
|
628
|
-
'GROQ_API_KEY=gsk_real_value\nQUOTED="quoted_val"\n',
|
|
629
|
-
);
|
|
630
|
-
const spawner = makeSpawner([7777]);
|
|
631
|
-
const code = await start("scribe", {
|
|
537
|
+
seedVault(h.manifestPath);
|
|
538
|
+
const offerStub = makeOfferStub("migrated");
|
|
539
|
+
const sup = makeSupervisorStub();
|
|
540
|
+
const code = await restart("vault", {
|
|
632
541
|
configDir: h.configDir,
|
|
633
542
|
manifestPath: h.manifestPath,
|
|
634
|
-
spawner,
|
|
635
543
|
log: () => {},
|
|
544
|
+
supervisor: { ...sup.opts, unitInstalled: false },
|
|
545
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
636
546
|
});
|
|
637
547
|
expect(code).toBe(0);
|
|
638
|
-
expect(
|
|
639
|
-
|
|
640
|
-
GROQ_API_KEY: "gsk_real_value",
|
|
641
|
-
QUOTED: "quoted_val",
|
|
642
|
-
});
|
|
548
|
+
expect(offerStub.calls).toBe(1);
|
|
549
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
643
550
|
} finally {
|
|
644
551
|
h.cleanup();
|
|
645
552
|
}
|
|
646
553
|
});
|
|
647
554
|
|
|
648
|
-
test("
|
|
649
|
-
// Defense: `start --hub-origin <url>` is the authoritative source for
|
|
650
|
-
// PARACHUTE_HUB_ORIGIN. If a service .env happens to have the same key
|
|
651
|
-
// (e.g. an old hand-edit), the live override should still apply.
|
|
555
|
+
test("offer is NOT made on the supervisor arm (unit already installed)", async () => {
|
|
652
556
|
const h = makeHarness();
|
|
653
557
|
try {
|
|
654
558
|
seedVault(h.manifestPath);
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
join(h.configDir, "vault", ".env"),
|
|
658
|
-
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=http://stale.local\n",
|
|
659
|
-
);
|
|
660
|
-
const spawner = makeSpawner([4242]);
|
|
559
|
+
const offerStub = makeOfferStub("migrated");
|
|
560
|
+
const sup = makeSupervisorStub(); // unitInstalled: true
|
|
661
561
|
const code = await start("vault", {
|
|
662
562
|
configDir: h.configDir,
|
|
663
563
|
manifestPath: h.manifestPath,
|
|
664
|
-
spawner,
|
|
665
|
-
hubOrigin: "https://live.example.com",
|
|
666
|
-
log: () => {},
|
|
667
|
-
});
|
|
668
|
-
expect(code).toBe(0);
|
|
669
|
-
expect(spawner.calls[0]?.env).toEqual({
|
|
670
|
-
PORT: "1940",
|
|
671
|
-
SCRIBE_AUTH_TOKEN: "secret",
|
|
672
|
-
PARACHUTE_HUB_ORIGIN: "https://live.example.com",
|
|
673
|
-
});
|
|
674
|
-
} finally {
|
|
675
|
-
h.cleanup();
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
test("third-party module starts via installDir module.json with cwd", async () => {
|
|
680
|
-
// hub#83: services.json rows that carry installDir resolve their spec
|
|
681
|
-
// from `<installDir>/.parachute/module.json` at lifecycle time. Spawn
|
|
682
|
-
// gets cwd=installDir so manifest-declared relative paths work.
|
|
683
|
-
const h = makeHarness();
|
|
684
|
-
try {
|
|
685
|
-
const installDir = join(h.configDir, "_pkg-someapp");
|
|
686
|
-
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
687
|
-
installDir,
|
|
688
|
-
startCmd: ["bun", "web/server/src/server.ts"],
|
|
689
|
-
port: 1944,
|
|
690
|
-
});
|
|
691
|
-
const spawner = makeSpawner([8080]);
|
|
692
|
-
const code = await start("someapp", {
|
|
693
|
-
configDir: h.configDir,
|
|
694
|
-
manifestPath: h.manifestPath,
|
|
695
|
-
spawner,
|
|
696
564
|
log: () => {},
|
|
565
|
+
supervisor: sup.opts,
|
|
566
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
697
567
|
});
|
|
698
568
|
expect(code).toBe(0);
|
|
699
|
-
|
|
700
|
-
expect(
|
|
701
|
-
expect(
|
|
702
|
-
expect(readPid("someapp", h.configDir)).toBe(8080);
|
|
703
|
-
} finally {
|
|
704
|
-
h.cleanup();
|
|
705
|
-
}
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
test("start: installDir-less third-party row surfaces an actionable error", async () => {
|
|
709
|
-
// A services.json row whose name isn't first-party AND has no installDir
|
|
710
|
-
// can't yield a startCmd. Pre-fix this hit the generic "unknown service"
|
|
711
|
-
// path (misleading — the row exists, just with stale shape). Post-fix
|
|
712
|
-
// resolveTargets returns the entry with spec=undefined and start prints
|
|
713
|
-
// an actionable message that points at the real fix (re-install or
|
|
714
|
-
// upgrade-the-module).
|
|
715
|
-
const h = makeHarness();
|
|
716
|
-
try {
|
|
717
|
-
upsertService(
|
|
718
|
-
{
|
|
719
|
-
name: "mystery",
|
|
720
|
-
port: 1944,
|
|
721
|
-
paths: ["/mystery"],
|
|
722
|
-
health: "/mystery/health",
|
|
723
|
-
version: "0.0.1",
|
|
724
|
-
},
|
|
725
|
-
h.manifestPath,
|
|
726
|
-
);
|
|
727
|
-
const lines: string[] = [];
|
|
728
|
-
const code = await start("mystery", {
|
|
729
|
-
configDir: h.configDir,
|
|
730
|
-
manifestPath: h.manifestPath,
|
|
731
|
-
log: (l) => lines.push(l),
|
|
732
|
-
});
|
|
733
|
-
expect(code).toBe(1);
|
|
734
|
-
const out = lines.join("\n");
|
|
735
|
-
expect(out).toMatch(/services\.json entry has no installDir/);
|
|
736
|
-
expect(out).toMatch(/parachute install <path-to-mystery>/);
|
|
737
|
-
expect(out).not.toMatch(/unknown service/);
|
|
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" }]);
|
|
738
572
|
} finally {
|
|
739
573
|
h.cleanup();
|
|
740
574
|
}
|
|
741
575
|
});
|
|
742
576
|
|
|
743
|
-
test("
|
|
744
|
-
// The genuinely-unknown path: no first-party fallback, no row in
|
|
745
|
-
// services.json. Distinguish from the above (row exists but lacks
|
|
746
|
-
// installDir) so the error message is right-shaped for each.
|
|
577
|
+
test("restart: declined → offer fires EXACTLY ONCE (MUST-FIX 3), exit 1", async () => {
|
|
747
578
|
const h = makeHarness();
|
|
748
579
|
try {
|
|
749
580
|
seedVault(h.manifestPath);
|
|
750
|
-
|
|
751
|
-
|
|
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", {
|
|
752
586
|
configDir: h.configDir,
|
|
753
587
|
manifestPath: h.manifestPath,
|
|
754
|
-
log: (
|
|
588
|
+
log: () => {},
|
|
589
|
+
migrateOffer: { enabled: true, offer: offerStub.offer },
|
|
755
590
|
});
|
|
756
591
|
expect(code).toBe(1);
|
|
757
|
-
|
|
592
|
+
// EXACTLY ONE offer.
|
|
593
|
+
expect(offerStub.calls).toBe(1);
|
|
758
594
|
} finally {
|
|
759
595
|
h.cleanup();
|
|
760
596
|
}
|
|
761
597
|
});
|
|
598
|
+
});
|
|
762
599
|
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
});
|
|
773
|
-
const spawner = makeSpawner([4242, 8080]);
|
|
774
|
-
const code = await start(undefined, {
|
|
775
|
-
configDir: h.configDir,
|
|
776
|
-
manifestPath: h.manifestPath,
|
|
777
|
-
spawner,
|
|
778
|
-
log: () => {},
|
|
779
|
-
});
|
|
780
|
-
expect(code).toBe(0);
|
|
781
|
-
expect(spawner.calls).toHaveLength(2);
|
|
782
|
-
const cmds = spawner.calls.map((c) => c.cmd);
|
|
783
|
-
expect(cmds).toContainEqual(["parachute-vault", "serve"]);
|
|
784
|
-
expect(cmds).toContainEqual(["bun", "server.ts"]);
|
|
785
|
-
} finally {
|
|
786
|
-
h.cleanup();
|
|
787
|
-
}
|
|
788
|
-
});
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
789
609
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
port: 1944,
|
|
800
|
-
paths: ["/broken"],
|
|
801
|
-
health: "/broken/health",
|
|
802
|
-
version: "0.0.1",
|
|
803
|
-
installDir,
|
|
804
|
-
},
|
|
805
|
-
h.manifestPath,
|
|
806
|
-
);
|
|
807
|
-
const lines: string[] = [];
|
|
808
|
-
const code = await start("broken", {
|
|
809
|
-
configDir: h.configDir,
|
|
810
|
-
manifestPath: h.manifestPath,
|
|
811
|
-
log: (l) => lines.push(l),
|
|
812
|
-
});
|
|
813
|
-
expect(code).toBe(1);
|
|
814
|
-
expect(lines.join("\n")).toMatch(/broken: invalid module\.json/);
|
|
815
|
-
} finally {
|
|
816
|
-
h.cleanup();
|
|
817
|
-
}
|
|
818
|
-
});
|
|
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
|
+
}
|
|
819
619
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
//
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
alive: () => false, // child dies immediately after spawn
|
|
837
|
-
sleep: async () => {}, // skip the real wait in tests
|
|
838
|
-
startSettleMs: 1, // any non-zero value engages the check
|
|
839
|
-
log: (l) => lines.push(l),
|
|
840
|
-
});
|
|
841
|
-
expect(code).toBe(1);
|
|
842
|
-
expect(spawner.calls).toHaveLength(1);
|
|
843
|
-
// pidfile is cleared so a follow-up `start` doesn't report
|
|
844
|
-
// already-running against a corpse.
|
|
845
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
846
|
-
const out = lines.join("\n");
|
|
847
|
-
expect(out).toMatch(/✗ vault failed to start/);
|
|
848
|
-
expect(out).toMatch(/exited within 1ms/);
|
|
849
|
-
expect(out).toMatch(/Tail the log/);
|
|
850
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
851
|
-
} finally {
|
|
852
|
-
h.cleanup();
|
|
853
|
-
}
|
|
854
|
-
});
|
|
620
|
+
describe("group-aware kill / liveness (hub#88)", () => {
|
|
621
|
+
test("defaultKill takes down the wrapper and its grandchildren together", async () => {
|
|
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();
|
|
855
636
|
|
|
856
|
-
|
|
857
|
-
// Companion to the above — verifies the success-path shape doesn't
|
|
858
|
-
// regress. Stub alive returns true so the post-spawn check passes,
|
|
859
|
-
// and we still see the `✓ ... started` line.
|
|
860
|
-
const h = makeHarness();
|
|
861
|
-
try {
|
|
862
|
-
seedVault(h.manifestPath);
|
|
863
|
-
const spawner = makeSpawner([4242]);
|
|
864
|
-
const lines: string[] = [];
|
|
865
|
-
const code = await start("vault", {
|
|
866
|
-
configDir: h.configDir,
|
|
867
|
-
manifestPath: h.manifestPath,
|
|
868
|
-
spawner,
|
|
869
|
-
alive: () => true,
|
|
870
|
-
sleep: async () => {},
|
|
871
|
-
startSettleMs: 1,
|
|
872
|
-
log: (l) => lines.push(l),
|
|
873
|
-
});
|
|
874
|
-
expect(code).toBe(0);
|
|
875
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
876
|
-
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
877
|
-
} finally {
|
|
878
|
-
h.cleanup();
|
|
879
|
-
}
|
|
880
|
-
});
|
|
637
|
+
defaultKill(wrapperPid, "SIGKILL");
|
|
881
638
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
// spawner and no `alive` override, the resolved settle is 0 (see
|
|
885
|
-
// resolve() in lifecycle.ts), so the post-spawn check is bypassed
|
|
886
|
-
// entirely and even an `alive: () => false` doesn't matter.
|
|
887
|
-
const h = makeHarness();
|
|
639
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
640
|
+
let grandchildStillAlive = true;
|
|
888
641
|
try {
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
configDir: h.configDir,
|
|
893
|
-
manifestPath: h.manifestPath,
|
|
894
|
-
spawner,
|
|
895
|
-
startSettleMs: 0,
|
|
896
|
-
// intentionally omit alive — defaultAlive against a fake pid
|
|
897
|
-
// would normally report dead, but startSettleMs: 0 skips the
|
|
898
|
-
// call entirely.
|
|
899
|
-
log: () => {},
|
|
900
|
-
});
|
|
901
|
-
expect(code).toBe(0);
|
|
902
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
903
|
-
} finally {
|
|
904
|
-
h.cleanup();
|
|
642
|
+
process.kill(grandchildPid, 0);
|
|
643
|
+
} catch {
|
|
644
|
+
grandchildStillAlive = false;
|
|
905
645
|
}
|
|
646
|
+
expect(grandchildStillAlive).toBe(false);
|
|
906
647
|
});
|
|
907
648
|
|
|
908
|
-
test("
|
|
909
|
-
const
|
|
649
|
+
test("defaultAlive reports group liveness for detached children", async () => {
|
|
650
|
+
const { pid } = spawnDetached(["sh", "-c", "sleep 2"]);
|
|
910
651
|
try {
|
|
911
|
-
|
|
912
|
-
seedThirdParty(h.manifestPath, h.configDir, "noop", {
|
|
913
|
-
installDir,
|
|
914
|
-
port: 1945,
|
|
915
|
-
});
|
|
916
|
-
const lines: string[] = [];
|
|
917
|
-
const code = await start("noop", {
|
|
918
|
-
configDir: h.configDir,
|
|
919
|
-
manifestPath: h.manifestPath,
|
|
920
|
-
log: (l) => lines.push(l),
|
|
921
|
-
});
|
|
922
|
-
expect(code).toBe(1);
|
|
923
|
-
expect(lines.join("\n")).toMatch(/lifecycle not yet supported/);
|
|
652
|
+
expect(defaultAlive(pid)).toBe(true);
|
|
924
653
|
} finally {
|
|
925
|
-
|
|
654
|
+
try {
|
|
655
|
+
process.kill(-pid, "SIGKILL");
|
|
656
|
+
} catch {}
|
|
926
657
|
}
|
|
658
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
659
|
+
expect(defaultAlive(pid)).toBe(false);
|
|
927
660
|
});
|
|
928
661
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
/**
|
|
935
|
-
* A stub spawner that also seeds the service's log file with `content`, so
|
|
936
|
-
* the readiness-failure path's log-tail + EADDRINUSE detection can read a
|
|
937
|
-
* realistic boot error. Mirrors how the real spawner appends stdout/stderr
|
|
938
|
-
* to the logfile.
|
|
939
|
-
*/
|
|
940
|
-
function makeSpawnerWithLog(pid: number, content: string): SpawnerStub {
|
|
941
|
-
const calls: SpawnerStub["calls"] = [];
|
|
942
|
-
return {
|
|
943
|
-
calls,
|
|
944
|
-
spawn(cmd, logFile, opts) {
|
|
945
|
-
calls.push({ cmd: [...cmd], logFile, env: opts?.env, cwd: opts?.cwd });
|
|
946
|
-
// The start path calls ensureLogPath() before spawn, so logFile's
|
|
947
|
-
// parent dir already exists — just write the simulated boot output.
|
|
948
|
-
writeFileSync(logFile, content);
|
|
949
|
-
return pid;
|
|
950
|
-
},
|
|
951
|
-
};
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
test("hub#487: EADDRINUSE in the log → port-in-use message + log tail, not ✓", async () => {
|
|
955
|
-
const h = makeHarness();
|
|
662
|
+
test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
|
|
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.
|
|
665
|
+
const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
666
|
+
const pid = proc.pid;
|
|
956
667
|
try {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
);
|
|
962
|
-
const lines: string[] = [];
|
|
963
|
-
const code = await start("vault", {
|
|
964
|
-
configDir: h.configDir,
|
|
965
|
-
manifestPath: h.manifestPath,
|
|
966
|
-
spawner,
|
|
967
|
-
alive: () => false, // process died right after the EADDRINUSE throw
|
|
968
|
-
sleep: async () => {},
|
|
969
|
-
startSettleMs: 1,
|
|
970
|
-
log: (l) => lines.push(l),
|
|
971
|
-
});
|
|
972
|
-
expect(code).toBe(1);
|
|
973
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
974
|
-
const out = lines.join("\n");
|
|
975
|
-
expect(out).toMatch(/port 1940 is already in use/);
|
|
976
|
-
expect(out).toMatch(/lsof -ti:1940/);
|
|
977
|
-
// The real boot error is surfaced inline so the operator doesn't have to
|
|
978
|
-
// go tail the log themselves.
|
|
979
|
-
expect(out).toMatch(/EADDRINUSE/);
|
|
980
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
668
|
+
expect(defaultAlive(pid)).toBe(true);
|
|
669
|
+
defaultKill(pid, "SIGKILL");
|
|
670
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
671
|
+
expect(defaultAlive(pid)).toBe(false);
|
|
981
672
|
} finally {
|
|
982
|
-
|
|
673
|
+
try {
|
|
674
|
+
process.kill(pid, "SIGKILL");
|
|
675
|
+
} catch {}
|
|
983
676
|
}
|
|
984
677
|
});
|
|
678
|
+
});
|
|
985
679
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const spawner = makeSpawnerWithLog(4242, "vault crashed mid-boot\n");
|
|
991
|
-
const lines: string[] = [];
|
|
992
|
-
let aliveCalls = 0;
|
|
993
|
-
const code = await start("vault", {
|
|
994
|
-
configDir: h.configDir,
|
|
995
|
-
manifestPath: h.manifestPath,
|
|
996
|
-
spawner,
|
|
997
|
-
// Alive through the settle + first readiness poll, then dies — the
|
|
998
|
-
// slow-EADDRINUSE / crash-after-boot shape.
|
|
999
|
-
alive: () => {
|
|
1000
|
-
aliveCalls++;
|
|
1001
|
-
return aliveCalls <= 1;
|
|
1002
|
-
},
|
|
1003
|
-
sleep: async () => {},
|
|
1004
|
-
startSettleMs: 1,
|
|
1005
|
-
startReadyMs: 50,
|
|
1006
|
-
startReadyPollMs: 1,
|
|
1007
|
-
portListening: async () => false, // never binds
|
|
1008
|
-
log: (l) => lines.push(l),
|
|
1009
|
-
});
|
|
1010
|
-
expect(code).toBe(1);
|
|
1011
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
1012
|
-
const out = lines.join("\n");
|
|
1013
|
-
expect(out).toMatch(/✗ vault failed to start/);
|
|
1014
|
-
expect(out).toMatch(/exited during startup/);
|
|
1015
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
1016
|
-
} finally {
|
|
1017
|
-
h.cleanup();
|
|
1018
|
-
}
|
|
1019
|
-
});
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
1020
684
|
|
|
1021
|
-
|
|
685
|
+
describe("parachute logs", () => {
|
|
686
|
+
test("hint when no log file exists", async () => {
|
|
1022
687
|
const h = makeHarness();
|
|
1023
688
|
try {
|
|
1024
689
|
seedVault(h.manifestPath);
|
|
1025
|
-
const
|
|
1026
|
-
const
|
|
1027
|
-
const code = await start("vault", {
|
|
690
|
+
const log: string[] = [];
|
|
691
|
+
const code = await logs("vault", {
|
|
1028
692
|
configDir: h.configDir,
|
|
1029
693
|
manifestPath: h.manifestPath,
|
|
1030
|
-
|
|
1031
|
-
alive: () => true, // stays up the whole time
|
|
1032
|
-
sleep: async () => {},
|
|
1033
|
-
startSettleMs: 1,
|
|
1034
|
-
startReadyMs: 10,
|
|
1035
|
-
startReadyPollMs: 1,
|
|
1036
|
-
portListening: async () => false, // slow boot — not listening yet
|
|
1037
|
-
log: (l) => lines.push(l),
|
|
694
|
+
log: (l) => log.push(l),
|
|
1038
695
|
});
|
|
1039
|
-
// A slow-but-alive daemon isn't a hard failure — we warn rather than fail.
|
|
1040
696
|
expect(code).toBe(0);
|
|
1041
|
-
expect(
|
|
1042
|
-
const out = lines.join("\n");
|
|
1043
|
-
expect(out).toMatch(/port 1940 isn't accepting connections yet/);
|
|
1044
|
-
expect(out).not.toMatch(/✓ vault started/);
|
|
697
|
+
expect(log.join("\n")).toMatch(/no logs yet for vault/);
|
|
1045
698
|
} finally {
|
|
1046
699
|
h.cleanup();
|
|
1047
700
|
}
|
|
1048
701
|
});
|
|
1049
702
|
|
|
1050
|
-
test("
|
|
703
|
+
test("prints last N lines in one-shot mode", async () => {
|
|
1051
704
|
const h = makeHarness();
|
|
1052
705
|
try {
|
|
1053
706
|
seedVault(h.manifestPath);
|
|
1054
|
-
const
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
const code = await
|
|
707
|
+
const path = ensureLogPath("vault", h.configDir);
|
|
708
|
+
writeFileSync(path, "line one\nline two\nline three\n");
|
|
709
|
+
const log: string[] = [];
|
|
710
|
+
const code = await logs("vault", {
|
|
1058
711
|
configDir: h.configDir,
|
|
1059
712
|
manifestPath: h.manifestPath,
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
sleep: async () => {},
|
|
1063
|
-
startSettleMs: 1,
|
|
1064
|
-
startReadyMs: 50,
|
|
1065
|
-
startReadyPollMs: 1,
|
|
1066
|
-
// Not listening on the first poll, bound on the second — exercises the
|
|
1067
|
-
// poll loop rather than an instant true.
|
|
1068
|
-
portListening: async () => {
|
|
1069
|
-
probeCalls++;
|
|
1070
|
-
return probeCalls >= 2;
|
|
1071
|
-
},
|
|
1072
|
-
log: (l) => lines.push(l),
|
|
713
|
+
lines: 2,
|
|
714
|
+
log: (l) => log.push(l),
|
|
1073
715
|
});
|
|
1074
716
|
expect(code).toBe(0);
|
|
1075
|
-
expect(
|
|
1076
|
-
expect(lines.join("\n")).toMatch(/✓ vault started \(pid 4242\)/);
|
|
717
|
+
expect(log).toEqual(["line two", "line three"]);
|
|
1077
718
|
} finally {
|
|
1078
719
|
h.cleanup();
|
|
1079
720
|
}
|
|
1080
721
|
});
|
|
1081
|
-
});
|
|
1082
722
|
|
|
1083
|
-
|
|
1084
|
-
test("no-op when nothing is running", async () => {
|
|
723
|
+
test("unknown service errors cleanly", async () => {
|
|
1085
724
|
const h = makeHarness();
|
|
1086
725
|
try {
|
|
1087
726
|
seedVault(h.manifestPath);
|
|
1088
|
-
const
|
|
1089
|
-
const
|
|
1090
|
-
const code = await stop("vault", {
|
|
727
|
+
const log: string[] = [];
|
|
728
|
+
const code = await logs("nope", {
|
|
1091
729
|
configDir: h.configDir,
|
|
1092
730
|
manifestPath: h.manifestPath,
|
|
1093
|
-
|
|
1094
|
-
log: (l) => logs.push(l),
|
|
731
|
+
log: (l) => log.push(l),
|
|
1095
732
|
});
|
|
1096
|
-
expect(code).toBe(
|
|
1097
|
-
expect(
|
|
1098
|
-
expect(logs.join("\n")).toMatch(/wasn't running/);
|
|
733
|
+
expect(code).toBe(1);
|
|
734
|
+
expect(log.join("\n")).toMatch(/unknown service "nope"/);
|
|
1099
735
|
} finally {
|
|
1100
736
|
h.cleanup();
|
|
1101
737
|
}
|
|
1102
738
|
});
|
|
1103
739
|
|
|
1104
|
-
test("
|
|
740
|
+
test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
|
|
1105
741
|
const h = makeHarness();
|
|
1106
742
|
try {
|
|
1107
743
|
seedVault(h.manifestPath);
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
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);
|
|
747
|
+
const log: string[] = [];
|
|
748
|
+
const code = await logs("vault", {
|
|
1111
749
|
configDir: h.configDir,
|
|
1112
750
|
manifestPath: h.manifestPath,
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
log: () => {},
|
|
751
|
+
alive: () => true,
|
|
752
|
+
log: (l) => log.push(l),
|
|
1116
753
|
});
|
|
1117
754
|
expect(code).toBe(0);
|
|
1118
|
-
expect(
|
|
1119
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
755
|
+
expect(log.join("\n")).toMatch(/is running \(pid .*\) but no log file/);
|
|
1120
756
|
} finally {
|
|
1121
757
|
h.cleanup();
|
|
1122
758
|
}
|
|
1123
759
|
});
|
|
1124
760
|
|
|
1125
|
-
test("
|
|
761
|
+
test("stale pidfile + missing log file: falls through to start hint", async () => {
|
|
1126
762
|
const h = makeHarness();
|
|
1127
763
|
try {
|
|
1128
764
|
seedVault(h.manifestPath);
|
|
1129
|
-
writePid("vault",
|
|
1130
|
-
const
|
|
1131
|
-
|
|
1132
|
-
const code = await stop("vault", {
|
|
765
|
+
writePid("vault", 999999, h.configDir);
|
|
766
|
+
const log: string[] = [];
|
|
767
|
+
const code = await logs("vault", {
|
|
1133
768
|
configDir: h.configDir,
|
|
1134
769
|
manifestPath: h.manifestPath,
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
aliveCall++;
|
|
1138
|
-
return aliveCall === 1;
|
|
1139
|
-
},
|
|
1140
|
-
sleep: async () => {},
|
|
1141
|
-
log: () => {},
|
|
770
|
+
alive: () => false,
|
|
771
|
+
log: (l) => log.push(l),
|
|
1142
772
|
});
|
|
1143
773
|
expect(code).toBe(0);
|
|
1144
|
-
expect(
|
|
1145
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
774
|
+
expect(log.join("\n")).toMatch(/no logs yet for vault/);
|
|
1146
775
|
} finally {
|
|
1147
776
|
h.cleanup();
|
|
1148
777
|
}
|
|
1149
778
|
});
|
|
1150
779
|
|
|
1151
|
-
test("
|
|
780
|
+
test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
|
|
1152
781
|
const h = makeHarness();
|
|
1153
782
|
try {
|
|
1154
783
|
seedVault(h.manifestPath);
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const code = await
|
|
784
|
+
const path = ensureLogPath("vault", h.configDir);
|
|
785
|
+
writeFileSync(path, "boot line\n");
|
|
786
|
+
const log: string[] = [];
|
|
787
|
+
const code = await logs("vault", {
|
|
1159
788
|
configDir: h.configDir,
|
|
1160
789
|
manifestPath: h.manifestPath,
|
|
1161
|
-
|
|
1162
|
-
alive: () => true,
|
|
1163
|
-
sleep: async () => {},
|
|
1164
|
-
now: () => {
|
|
1165
|
-
// Jump past the kill-wait window so the polling loop exits fast.
|
|
1166
|
-
t += 20_000;
|
|
1167
|
-
return t;
|
|
1168
|
-
},
|
|
1169
|
-
killWaitMs: 10_000,
|
|
1170
|
-
log: () => {},
|
|
790
|
+
log: (l) => log.push(l),
|
|
1171
791
|
});
|
|
1172
792
|
expect(code).toBe(0);
|
|
1173
|
-
expect(
|
|
1174
|
-
expect(killed[killed.length - 1]).toEqual([4242, "SIGKILL"]);
|
|
1175
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
793
|
+
expect(log).toEqual(["boot line"]);
|
|
1176
794
|
} finally {
|
|
1177
795
|
h.cleanup();
|
|
1178
796
|
}
|
|
1179
797
|
});
|
|
1180
798
|
|
|
1181
|
-
test("
|
|
1182
|
-
// Graceful-degradation path: an installed-but-stale third-party row
|
|
1183
|
-
// (no installDir field — pre-installDir-contract self-registration)
|
|
1184
|
-
// should still be stoppable. stop only needs the short name to find
|
|
1185
|
-
// the pidfile; spec resolution isn't on the critical path for stop.
|
|
799
|
+
test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
|
|
1186
800
|
const h = makeHarness();
|
|
1187
801
|
try {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
name: "mystery",
|
|
1191
|
-
port: 1944,
|
|
1192
|
-
paths: ["/mystery"],
|
|
1193
|
-
health: "/mystery/health",
|
|
1194
|
-
version: "0.0.1",
|
|
1195
|
-
},
|
|
1196
|
-
h.manifestPath,
|
|
1197
|
-
);
|
|
1198
|
-
writePid("mystery", 4242, h.configDir);
|
|
1199
|
-
const killed: Array<[number, string | number]> = [];
|
|
1200
|
-
let aliveCall = 0;
|
|
1201
|
-
const code = await stop("mystery", {
|
|
802
|
+
const log: string[] = [];
|
|
803
|
+
const code = await logs("hub", {
|
|
1202
804
|
configDir: h.configDir,
|
|
1203
805
|
manifestPath: h.manifestPath,
|
|
1204
|
-
|
|
1205
|
-
alive: () => {
|
|
1206
|
-
aliveCall++;
|
|
1207
|
-
return aliveCall === 1;
|
|
1208
|
-
},
|
|
1209
|
-
sleep: async () => {},
|
|
1210
|
-
log: () => {},
|
|
806
|
+
log: (l) => log.push(l),
|
|
1211
807
|
});
|
|
1212
808
|
expect(code).toBe(0);
|
|
1213
|
-
expect(
|
|
1214
|
-
expect(readPid("mystery", h.configDir)).toBeUndefined();
|
|
809
|
+
expect(log.join("\n")).toMatch(/no logs yet for hub/);
|
|
1215
810
|
} finally {
|
|
1216
811
|
h.cleanup();
|
|
1217
812
|
}
|
|
1218
813
|
});
|
|
1219
|
-
});
|
|
1220
814
|
|
|
1221
|
-
|
|
1222
|
-
test("stops then starts in sequence", async () => {
|
|
815
|
+
test("logs hub: prints the tail when a log file exists", async () => {
|
|
1223
816
|
const h = makeHarness();
|
|
1224
817
|
try {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
const
|
|
1229
|
-
const code = await restart("vault", {
|
|
818
|
+
const path = ensureLogPath("hub", h.configDir);
|
|
819
|
+
writeFileSync(path, "hub line one\nhub line two\n");
|
|
820
|
+
const log: string[] = [];
|
|
821
|
+
const code = await logs("hub", {
|
|
1230
822
|
configDir: h.configDir,
|
|
1231
823
|
manifestPath: h.manifestPath,
|
|
1232
|
-
|
|
1233
|
-
kill: (pid, sig) => killed.push([pid, sig]),
|
|
1234
|
-
// Stale 4242 is dead (stop's stale-pid path skips the kill);
|
|
1235
|
-
// freshly spawned 7777 is alive past the post-spawn settle
|
|
1236
|
-
// (hub#194). Per-pid differentiation rather than blanket-false.
|
|
1237
|
-
alive: (pid) => pid === 7777,
|
|
1238
|
-
sleep: async () => {},
|
|
1239
|
-
log: () => {},
|
|
1240
|
-
});
|
|
1241
|
-
expect(code).toBe(0);
|
|
1242
|
-
expect(killed).toHaveLength(0); // stale pid → cleanup without kill
|
|
1243
|
-
expect(spawner.calls).toHaveLength(1);
|
|
1244
|
-
expect(readPid("vault", h.configDir)).toBe(7777);
|
|
1245
|
-
} finally {
|
|
1246
|
-
h.cleanup();
|
|
1247
|
-
}
|
|
1248
|
-
});
|
|
1249
|
-
});
|
|
1250
|
-
|
|
1251
|
-
describe("parachute logs", () => {
|
|
1252
|
-
test("hint when no log file exists", async () => {
|
|
1253
|
-
const h = makeHarness();
|
|
1254
|
-
try {
|
|
1255
|
-
const lines: string[] = [];
|
|
1256
|
-
const code = await logs("vault", {
|
|
1257
|
-
configDir: h.configDir,
|
|
1258
|
-
log: (l) => lines.push(l),
|
|
1259
|
-
});
|
|
1260
|
-
expect(code).toBe(0);
|
|
1261
|
-
expect(lines.join("\n")).toMatch(/no logs yet/);
|
|
1262
|
-
} finally {
|
|
1263
|
-
h.cleanup();
|
|
1264
|
-
}
|
|
1265
|
-
});
|
|
1266
|
-
|
|
1267
|
-
test("prints last N lines in one-shot mode", async () => {
|
|
1268
|
-
const h = makeHarness();
|
|
1269
|
-
try {
|
|
1270
|
-
const p = ensureLogPath("vault", h.configDir);
|
|
1271
|
-
const content = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n");
|
|
1272
|
-
writeFileSync(p, `${content}\n`);
|
|
1273
|
-
const lines: string[] = [];
|
|
1274
|
-
const code = await logs("vault", {
|
|
1275
|
-
configDir: h.configDir,
|
|
1276
|
-
lines: 3,
|
|
1277
|
-
log: (l) => lines.push(l),
|
|
824
|
+
log: (l) => log.push(l),
|
|
1278
825
|
});
|
|
1279
826
|
expect(code).toBe(0);
|
|
1280
|
-
expect(
|
|
1281
|
-
} finally {
|
|
1282
|
-
h.cleanup();
|
|
1283
|
-
}
|
|
1284
|
-
});
|
|
1285
|
-
|
|
1286
|
-
test("unknown service errors cleanly", async () => {
|
|
1287
|
-
const h = makeHarness();
|
|
1288
|
-
try {
|
|
1289
|
-
const lines: string[] = [];
|
|
1290
|
-
const code = await logs("nope", {
|
|
1291
|
-
configDir: h.configDir,
|
|
1292
|
-
log: (l) => lines.push(l),
|
|
1293
|
-
});
|
|
1294
|
-
expect(code).toBe(1);
|
|
1295
|
-
expect(lines.join("\n")).toMatch(/unknown service/);
|
|
1296
|
-
} finally {
|
|
1297
|
-
h.cleanup();
|
|
1298
|
-
}
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
|
-
test("third-party module name with installDir is recognised", async () => {
|
|
1302
|
-
const h = makeHarness();
|
|
1303
|
-
try {
|
|
1304
|
-
const installDir = join(h.configDir, "_pkg-someapp");
|
|
1305
|
-
seedThirdParty(h.manifestPath, h.configDir, "someapp", {
|
|
1306
|
-
installDir,
|
|
1307
|
-
startCmd: ["bun", "server.ts"],
|
|
1308
|
-
});
|
|
1309
|
-
const p = ensureLogPath("someapp", h.configDir);
|
|
1310
|
-
writeFileSync(p, "someapp line 1\nsomeapp line 2\n");
|
|
1311
|
-
const lines: string[] = [];
|
|
1312
|
-
const code = await logs("someapp", {
|
|
1313
|
-
configDir: h.configDir,
|
|
1314
|
-
manifestPath: h.manifestPath,
|
|
1315
|
-
log: (l) => lines.push(l),
|
|
1316
|
-
});
|
|
1317
|
-
expect(code).toBe(0);
|
|
1318
|
-
expect(lines).toEqual(["someapp line 1", "someapp line 2"]);
|
|
1319
|
-
} finally {
|
|
1320
|
-
h.cleanup();
|
|
1321
|
-
}
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
|
-
test("third-party row without installDir: tails by short name", async () => {
|
|
1325
|
-
// Graceful-degradation path: log file is keyed by short name, written by
|
|
1326
|
-
// start. installDir is irrelevant for tailing — the entry just needs to
|
|
1327
|
-
// exist in services.json.
|
|
1328
|
-
const h = makeHarness();
|
|
1329
|
-
try {
|
|
1330
|
-
upsertService(
|
|
1331
|
-
{
|
|
1332
|
-
name: "mystery",
|
|
1333
|
-
port: 1944,
|
|
1334
|
-
paths: ["/mystery"],
|
|
1335
|
-
health: "/mystery/health",
|
|
1336
|
-
version: "0.0.1",
|
|
1337
|
-
},
|
|
1338
|
-
h.manifestPath,
|
|
1339
|
-
);
|
|
1340
|
-
const p = ensureLogPath("mystery", h.configDir);
|
|
1341
|
-
writeFileSync(p, "mystery line 1\nmystery line 2\n");
|
|
1342
|
-
const lines: string[] = [];
|
|
1343
|
-
const code = await logs("mystery", {
|
|
1344
|
-
configDir: h.configDir,
|
|
1345
|
-
manifestPath: h.manifestPath,
|
|
1346
|
-
log: (l) => lines.push(l),
|
|
1347
|
-
});
|
|
1348
|
-
expect(code).toBe(0);
|
|
1349
|
-
expect(lines).toEqual(["mystery line 1", "mystery line 2"]);
|
|
1350
|
-
} finally {
|
|
1351
|
-
h.cleanup();
|
|
1352
|
-
}
|
|
1353
|
-
});
|
|
1354
|
-
|
|
1355
|
-
test("running daemon + missing log file: surfaces alive-but-no-log shape (hub#335)", async () => {
|
|
1356
|
-
// Aaron's #335 reproducer shape: parachute-app daemon was running
|
|
1357
|
-
// (curl proxied 200s, pidfile alive) but `parachute logs app` printed
|
|
1358
|
-
// `parachute start app to begin` — telling the operator to start a
|
|
1359
|
-
// service that was already up. The fix: when the log file is missing
|
|
1360
|
-
// but a live pidfile exists, surface the running pid + the path we
|
|
1361
|
-
// expected instead of the misleading start-hint.
|
|
1362
|
-
const h = makeHarness();
|
|
1363
|
-
try {
|
|
1364
|
-
seedVault(h.manifestPath);
|
|
1365
|
-
writePid("vault", 9999, h.configDir);
|
|
1366
|
-
const lines: string[] = [];
|
|
1367
|
-
const code = await logs("vault", {
|
|
1368
|
-
configDir: h.configDir,
|
|
1369
|
-
manifestPath: h.manifestPath,
|
|
1370
|
-
// pid 9999 is "alive" — simulates the running daemon case.
|
|
1371
|
-
alive: () => true,
|
|
1372
|
-
log: (l) => lines.push(l),
|
|
1373
|
-
});
|
|
1374
|
-
expect(code).toBe(0);
|
|
1375
|
-
const out = lines.join("\n");
|
|
1376
|
-
expect(out).toMatch(/vault is running \(pid 9999\)/);
|
|
1377
|
-
expect(out).toMatch(/no log file/);
|
|
1378
|
-
expect(out).not.toMatch(/parachute start vault/);
|
|
1379
|
-
} finally {
|
|
1380
|
-
h.cleanup();
|
|
1381
|
-
}
|
|
1382
|
-
});
|
|
1383
|
-
|
|
1384
|
-
test("stale pidfile + missing log file: falls through to start hint", async () => {
|
|
1385
|
-
// The other half of the disambiguation: pidfile exists but the process
|
|
1386
|
-
// is gone (stale pidfile, or cleanly shut down). That's effectively
|
|
1387
|
-
// "not running," so the original `parachute start` hint is still the
|
|
1388
|
-
// right message.
|
|
1389
|
-
const h = makeHarness();
|
|
1390
|
-
try {
|
|
1391
|
-
seedVault(h.manifestPath);
|
|
1392
|
-
writePid("vault", 9999, h.configDir);
|
|
1393
|
-
const lines: string[] = [];
|
|
1394
|
-
const code = await logs("vault", {
|
|
1395
|
-
configDir: h.configDir,
|
|
1396
|
-
manifestPath: h.manifestPath,
|
|
1397
|
-
// pid 9999 is "dead" — `processState` returns `stopped`.
|
|
1398
|
-
alive: () => false,
|
|
1399
|
-
log: (l) => lines.push(l),
|
|
1400
|
-
});
|
|
1401
|
-
expect(code).toBe(0);
|
|
1402
|
-
expect(lines.join("\n")).toMatch(/no logs yet for vault/);
|
|
1403
|
-
} finally {
|
|
1404
|
-
h.cleanup();
|
|
1405
|
-
}
|
|
1406
|
-
});
|
|
1407
|
-
|
|
1408
|
-
test("log file exists: prints tail regardless of pidfile state (hub#335)", async () => {
|
|
1409
|
-
// The happy path Aaron's title calls out: when the log file exists,
|
|
1410
|
-
// we tail it — independent of whether the pidfile is present. A
|
|
1411
|
-
// running daemon's logs are useful; a stopped daemon's prior logs are
|
|
1412
|
-
// useful too (post-mortem). Pidfile state only changes the message
|
|
1413
|
-
// when the file is missing.
|
|
1414
|
-
const h = makeHarness();
|
|
1415
|
-
try {
|
|
1416
|
-
const p = ensureLogPath("vault", h.configDir);
|
|
1417
|
-
writeFileSync(p, "vault line a\nvault line b\n");
|
|
1418
|
-
// No pidfile written — verify we still print the tail.
|
|
1419
|
-
const lines: string[] = [];
|
|
1420
|
-
const code = await logs("vault", {
|
|
1421
|
-
configDir: h.configDir,
|
|
1422
|
-
manifestPath: h.manifestPath,
|
|
1423
|
-
alive: () => false,
|
|
1424
|
-
log: (l) => lines.push(l),
|
|
1425
|
-
});
|
|
1426
|
-
expect(code).toBe(0);
|
|
1427
|
-
expect(lines).toEqual(["vault line a", "vault line b"]);
|
|
1428
|
-
} finally {
|
|
1429
|
-
h.cleanup();
|
|
1430
|
-
}
|
|
1431
|
-
});
|
|
1432
|
-
});
|
|
1433
|
-
|
|
1434
|
-
describe("process-group lifecycle (hub#88)", () => {
|
|
1435
|
-
// Spawn a wrapper that forks a long-running grandchild (sleep), wait for
|
|
1436
|
-
// both to come up, then check that the wrapper PID equals its PGID — the
|
|
1437
|
-
// post-fix invariant that makes group-kill safe. Without `detached: true`
|
|
1438
|
-
// the child inherits the test runner's PGID and group-kill would target
|
|
1439
|
-
// the wrong tree.
|
|
1440
|
-
test("defaultSpawner puts child in its own process group", async () => {
|
|
1441
|
-
const h = makeHarness();
|
|
1442
|
-
try {
|
|
1443
|
-
const logFile = ensureLogPath("test", h.configDir);
|
|
1444
|
-
const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2 & wait"], logFile);
|
|
1445
|
-
try {
|
|
1446
|
-
// Resolve the child's PGID via ps; the kernel reports it as a
|
|
1447
|
-
// numeric column. PGID == PID means our setsid-equivalent worked.
|
|
1448
|
-
const ps = Bun.spawnSync(["ps", "-o", "pgid=", "-p", String(pid)]);
|
|
1449
|
-
const pgid = Number.parseInt(ps.stdout.toString().trim(), 10);
|
|
1450
|
-
expect(pgid).toBe(pid);
|
|
1451
|
-
} finally {
|
|
1452
|
-
try {
|
|
1453
|
-
process.kill(-pid, "SIGKILL");
|
|
1454
|
-
} catch {}
|
|
1455
|
-
}
|
|
1456
|
-
} finally {
|
|
1457
|
-
h.cleanup();
|
|
1458
|
-
}
|
|
1459
|
-
});
|
|
1460
|
-
|
|
1461
|
-
// The smoking-gun scenario from #88: a wrapper (sh) forks a grandchild
|
|
1462
|
-
// (sleep) that keeps a resource — here, just stays alive. SIGKILL on the
|
|
1463
|
-
// wrapper PID alone leaves the grandchild running. With detached spawn +
|
|
1464
|
-
// group-kill, both go down. We assert by checking the grandchild's PID
|
|
1465
|
-
// is no longer kill-able after `defaultKill`.
|
|
1466
|
-
test("defaultKill takes down the wrapper and its grandchildren together", async () => {
|
|
1467
|
-
const h = makeHarness();
|
|
1468
|
-
try {
|
|
1469
|
-
const logFile = ensureLogPath("test", h.configDir);
|
|
1470
|
-
// Wrapper sh forks `sleep 30 & echo $!` so we capture the grandchild
|
|
1471
|
-
// PID via the log file, then `wait` so the wrapper sticks around as
|
|
1472
|
-
// a parent (mirrors `pnpm exec tsx`'s shape).
|
|
1473
|
-
const wrapperPid = defaultSpawner.spawn(
|
|
1474
|
-
["sh", "-c", "sleep 30 & echo $! >&2; wait"],
|
|
1475
|
-
logFile,
|
|
1476
|
-
);
|
|
1477
|
-
// Give the grandchild time to start and the log line to flush.
|
|
1478
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1479
|
-
const log = await Bun.file(logFile).text();
|
|
1480
|
-
const grandchildPid = Number.parseInt(log.trim().split("\n").pop() ?? "", 10);
|
|
1481
|
-
expect(grandchildPid).toBeGreaterThan(0);
|
|
1482
|
-
expect(grandchildPid).not.toBe(wrapperPid);
|
|
1483
|
-
// Both should be alive before kill.
|
|
1484
|
-
expect(() => process.kill(grandchildPid, 0)).not.toThrow();
|
|
1485
|
-
|
|
1486
|
-
defaultKill(wrapperPid, "SIGKILL");
|
|
1487
|
-
|
|
1488
|
-
// Reap + wait for the grandchild to exit; on macOS the kernel may
|
|
1489
|
-
// take a tick to deliver the signal.
|
|
1490
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
1491
|
-
let grandchildStillAlive = true;
|
|
1492
|
-
try {
|
|
1493
|
-
process.kill(grandchildPid, 0);
|
|
1494
|
-
} catch {
|
|
1495
|
-
grandchildStillAlive = false;
|
|
1496
|
-
}
|
|
1497
|
-
expect(grandchildStillAlive).toBe(false);
|
|
1498
|
-
} finally {
|
|
1499
|
-
h.cleanup();
|
|
1500
|
-
}
|
|
1501
|
-
});
|
|
1502
|
-
|
|
1503
|
-
// defaultAlive's post-fix semantics: returns true while any group member
|
|
1504
|
-
// is alive (the wrapper stays in the group as long as it's running),
|
|
1505
|
-
// false after the group drains.
|
|
1506
|
-
test("defaultAlive reports group liveness for detached children", async () => {
|
|
1507
|
-
const h = makeHarness();
|
|
1508
|
-
try {
|
|
1509
|
-
const logFile = ensureLogPath("test", h.configDir);
|
|
1510
|
-
const pid = defaultSpawner.spawn(["sh", "-c", "sleep 2"], logFile);
|
|
1511
|
-
try {
|
|
1512
|
-
expect(defaultAlive(pid)).toBe(true);
|
|
1513
|
-
} finally {
|
|
1514
|
-
try {
|
|
1515
|
-
process.kill(-pid, "SIGKILL");
|
|
1516
|
-
} catch {}
|
|
1517
|
-
}
|
|
1518
|
-
// Wait for the kill to drain the group, then re-check.
|
|
1519
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1520
|
-
expect(defaultAlive(pid)).toBe(false);
|
|
1521
|
-
} finally {
|
|
1522
|
-
h.cleanup();
|
|
1523
|
-
}
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
// Legacy pidfile compatibility: a pre-detached pidfile holds a positive
|
|
1527
|
-
// PID whose pgid is the parent shell, not the pid itself. defaultAlive
|
|
1528
|
-
// must fall back to a bare-pid check so the next `stop` actually runs;
|
|
1529
|
-
// defaultKill must fall back to a bare-pid signal so it can be reaped.
|
|
1530
|
-
test("defaultAlive + defaultKill fall back to bare-pid for legacy (non-detached) processes", async () => {
|
|
1531
|
-
// Spawn a non-detached child to simulate a legacy pidfile (pre-fix
|
|
1532
|
-
// start). It shares the test runner's pgid, so kill(-pid, 0) will
|
|
1533
|
-
// ESRCH and we should fall back.
|
|
1534
|
-
const proc = Bun.spawn(["sh", "-c", "sleep 5"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
1535
|
-
const pid = proc.pid;
|
|
1536
|
-
try {
|
|
1537
|
-
expect(defaultAlive(pid)).toBe(true);
|
|
1538
|
-
defaultKill(pid, "SIGKILL");
|
|
1539
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
1540
|
-
expect(defaultAlive(pid)).toBe(false);
|
|
1541
|
-
} finally {
|
|
1542
|
-
try {
|
|
1543
|
-
process.kill(pid, "SIGKILL");
|
|
1544
|
-
} catch {}
|
|
1545
|
-
}
|
|
1546
|
-
});
|
|
1547
|
-
});
|
|
1548
|
-
|
|
1549
|
-
/**
|
|
1550
|
-
* `parachute start|stop|restart hub` — the bug Aaron filed as hub#166. Hub
|
|
1551
|
-
* isn't a row in services.json, so the generic services-manifest path
|
|
1552
|
-
* surfaced "unknown service: hub". The fix dispatches `svc === "hub"`
|
|
1553
|
-
* straight to hub-control.ts. These tests inject `ensureRunning`/`stop`
|
|
1554
|
-
* stubs so we don't actually fork bun.
|
|
1555
|
-
*/
|
|
1556
|
-
describe("parachute start|stop|restart hub", () => {
|
|
1557
|
-
test("start hub: dispatches to ensureHubRunning, propagates configDir + issuer", async () => {
|
|
1558
|
-
const h = makeHarness();
|
|
1559
|
-
try {
|
|
1560
|
-
const log: string[] = [];
|
|
1561
|
-
const ensureCalls: Array<{ configDir?: string; issuer?: string }> = [];
|
|
1562
|
-
const code = await start("hub", {
|
|
1563
|
-
configDir: h.configDir,
|
|
1564
|
-
manifestPath: h.manifestPath,
|
|
1565
|
-
hubOrigin: "https://hub.example.com",
|
|
1566
|
-
hub: {
|
|
1567
|
-
ensureRunning: async (opts) => {
|
|
1568
|
-
ensureCalls.push({ configDir: opts.configDir, issuer: opts.issuer });
|
|
1569
|
-
return { pid: 4711, port: 1939, started: true };
|
|
1570
|
-
},
|
|
1571
|
-
},
|
|
1572
|
-
log: (l) => log.push(l),
|
|
1573
|
-
});
|
|
1574
|
-
expect(code).toBe(0);
|
|
1575
|
-
expect(ensureCalls).toHaveLength(1);
|
|
1576
|
-
expect(ensureCalls[0]).toEqual({
|
|
1577
|
-
configDir: h.configDir,
|
|
1578
|
-
issuer: "https://hub.example.com",
|
|
1579
|
-
});
|
|
1580
|
-
} finally {
|
|
1581
|
-
h.cleanup();
|
|
1582
|
-
}
|
|
1583
|
-
});
|
|
1584
|
-
|
|
1585
|
-
test("start hub: reports already-running cleanly when ensureHubRunning returns started=false", async () => {
|
|
1586
|
-
const h = makeHarness();
|
|
1587
|
-
try {
|
|
1588
|
-
const log: string[] = [];
|
|
1589
|
-
const code = await start("hub", {
|
|
1590
|
-
configDir: h.configDir,
|
|
1591
|
-
manifestPath: h.manifestPath,
|
|
1592
|
-
hub: {
|
|
1593
|
-
ensureRunning: async () => ({ pid: 8888, port: 1939, started: false }),
|
|
1594
|
-
},
|
|
1595
|
-
log: (l) => log.push(l),
|
|
1596
|
-
});
|
|
1597
|
-
expect(code).toBe(0);
|
|
1598
|
-
expect(log.join("\n")).toMatch(/hub already running \(pid 8888\) on port 1939/);
|
|
1599
|
-
} finally {
|
|
1600
|
-
h.cleanup();
|
|
1601
|
-
}
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
test("start hub: surfaces ensureHubRunning errors as exit 1", async () => {
|
|
1605
|
-
const h = makeHarness();
|
|
1606
|
-
try {
|
|
1607
|
-
const log: string[] = [];
|
|
1608
|
-
const code = await start("hub", {
|
|
1609
|
-
configDir: h.configDir,
|
|
1610
|
-
manifestPath: h.manifestPath,
|
|
1611
|
-
hub: {
|
|
1612
|
-
ensureRunning: async () => {
|
|
1613
|
-
throw new Error("hub: port 1939 unavailable");
|
|
1614
|
-
},
|
|
1615
|
-
},
|
|
1616
|
-
log: (l) => log.push(l),
|
|
1617
|
-
});
|
|
1618
|
-
expect(code).toBe(1);
|
|
1619
|
-
expect(log.join("\n")).toMatch(/hub failed to start.*port 1939 unavailable/);
|
|
1620
|
-
} finally {
|
|
1621
|
-
h.cleanup();
|
|
1622
|
-
}
|
|
1623
|
-
});
|
|
1624
|
-
|
|
1625
|
-
// hub#481 — `start hub` self-heals a stale operator-token issuer. Tests use
|
|
1626
|
-
// the injectable `hub.selfHealOperatorToken` seam to assert the call happens
|
|
1627
|
-
// (and to make it throw without failing start); a separate test drives the
|
|
1628
|
-
// REAL self-heal against an on-disk operator token + hub.db.
|
|
1629
|
-
test("start hub: invokes operator-token self-heal with the resolved issuer + configDir", async () => {
|
|
1630
|
-
const h = makeHarness();
|
|
1631
|
-
try {
|
|
1632
|
-
const log: string[] = [];
|
|
1633
|
-
const calls: Array<{ issuer: string; configDir: string }> = [];
|
|
1634
|
-
const code = await start("hub", {
|
|
1635
|
-
configDir: h.configDir,
|
|
1636
|
-
manifestPath: h.manifestPath,
|
|
1637
|
-
hubOrigin: "https://hub.example.com",
|
|
1638
|
-
hub: {
|
|
1639
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1640
|
-
selfHealOperatorToken: async (args) => {
|
|
1641
|
-
calls.push({ issuer: args.issuer, configDir: args.configDir });
|
|
1642
|
-
return {
|
|
1643
|
-
kind: "rotated",
|
|
1644
|
-
path: "/x/operator.token",
|
|
1645
|
-
scopeSet: "admin",
|
|
1646
|
-
expiresAt: "z",
|
|
1647
|
-
};
|
|
1648
|
-
},
|
|
1649
|
-
},
|
|
1650
|
-
log: (l) => log.push(l),
|
|
1651
|
-
});
|
|
1652
|
-
expect(code).toBe(0);
|
|
1653
|
-
expect(calls).toEqual([{ issuer: "https://hub.example.com", configDir: h.configDir }]);
|
|
1654
|
-
// Rotation emits an operator-facing line.
|
|
1655
|
-
expect(log.join("\n")).toMatch(
|
|
1656
|
-
/refreshed operator\.token issuer → https:\/\/hub\.example\.com/,
|
|
1657
|
-
);
|
|
1658
|
-
} finally {
|
|
1659
|
-
h.cleanup();
|
|
1660
|
-
}
|
|
1661
|
-
});
|
|
1662
|
-
|
|
1663
|
-
test("start hub: skips operator-token self-heal when no hub origin is resolvable", async () => {
|
|
1664
|
-
const h = makeHarness();
|
|
1665
|
-
try {
|
|
1666
|
-
let called = false;
|
|
1667
|
-
// No hubOrigin override, no expose-state, no hub.port file → resolveHubOrigin
|
|
1668
|
-
// yields undefined, so the self-heal seam must NOT be called.
|
|
1669
|
-
const code = await start("hub", {
|
|
1670
|
-
configDir: h.configDir,
|
|
1671
|
-
manifestPath: h.manifestPath,
|
|
1672
|
-
hub: {
|
|
1673
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1674
|
-
selfHealOperatorToken: async () => {
|
|
1675
|
-
called = true;
|
|
1676
|
-
return { kind: "absent" };
|
|
1677
|
-
},
|
|
1678
|
-
},
|
|
1679
|
-
log: () => {},
|
|
1680
|
-
});
|
|
1681
|
-
expect(code).toBe(0);
|
|
1682
|
-
expect(called).toBe(false);
|
|
1683
|
-
} finally {
|
|
1684
|
-
h.cleanup();
|
|
1685
|
-
}
|
|
1686
|
-
});
|
|
1687
|
-
|
|
1688
|
-
test("start hub: a thrown error inside operator-token self-heal does NOT fail start", async () => {
|
|
1689
|
-
const h = makeHarness();
|
|
1690
|
-
try {
|
|
1691
|
-
const log: string[] = [];
|
|
1692
|
-
const code = await start("hub", {
|
|
1693
|
-
configDir: h.configDir,
|
|
1694
|
-
manifestPath: h.manifestPath,
|
|
1695
|
-
hubOrigin: "https://hub.example.com",
|
|
1696
|
-
hub: {
|
|
1697
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1698
|
-
selfHealOperatorToken: async () => {
|
|
1699
|
-
throw new Error("hub.db is locked");
|
|
1700
|
-
},
|
|
1701
|
-
},
|
|
1702
|
-
log: (l) => log.push(l),
|
|
1703
|
-
});
|
|
1704
|
-
expect(code).toBe(0);
|
|
1705
|
-
// Degrades to a brief note, not a hard failure.
|
|
1706
|
-
expect(log.join("\n")).toMatch(
|
|
1707
|
-
/operator\.token issuer self-heal skipped \(hub\.db is locked\)/,
|
|
1708
|
-
);
|
|
1709
|
-
} finally {
|
|
1710
|
-
h.cleanup();
|
|
1711
|
-
}
|
|
1712
|
-
});
|
|
1713
|
-
|
|
1714
|
-
test("start hub: real self-heal re-mints a stale-iss operator token on disk", async () => {
|
|
1715
|
-
const h = makeHarness();
|
|
1716
|
-
try {
|
|
1717
|
-
// Seed signing keys + a stale-iss operator token in the harness configDir's
|
|
1718
|
-
// hub.db / operator.token, then drive the production self-heal seam.
|
|
1719
|
-
const db = openHubDb(hubDbPath(h.configDir));
|
|
1720
|
-
try {
|
|
1721
|
-
rotateSigningKey(db);
|
|
1722
|
-
await issueOperatorToken(db, "user-abc", {
|
|
1723
|
-
dir: h.configDir,
|
|
1724
|
-
issuer: "http://127.0.0.1:1939",
|
|
1725
|
-
scopeSet: "start",
|
|
1726
|
-
});
|
|
1727
|
-
} finally {
|
|
1728
|
-
db.close();
|
|
1729
|
-
}
|
|
1730
|
-
|
|
1731
|
-
const log: string[] = [];
|
|
1732
|
-
const code = await start("hub", {
|
|
1733
|
-
configDir: h.configDir,
|
|
1734
|
-
manifestPath: h.manifestPath,
|
|
1735
|
-
hubOrigin: "https://gitcoin-parachute.unforced.dev",
|
|
1736
|
-
// No selfHealOperatorToken override → exercises defaultSelfHealOperatorToken
|
|
1737
|
-
// (opens hub.db at <configDir>/hub.db).
|
|
1738
|
-
hub: {
|
|
1739
|
-
ensureRunning: async () => ({ pid: 4711, port: 1939, started: true }),
|
|
1740
|
-
},
|
|
1741
|
-
log: (l) => log.push(l),
|
|
1742
|
-
});
|
|
1743
|
-
expect(code).toBe(0);
|
|
1744
|
-
expect(log.join("\n")).toMatch(
|
|
1745
|
-
/refreshed operator\.token issuer → https:\/\/gitcoin-parachute\.unforced\.dev/,
|
|
1746
|
-
);
|
|
1747
|
-
|
|
1748
|
-
// The on-disk token now validates under the new issuer, scope-set preserved.
|
|
1749
|
-
const verifyDb = openHubDb(hubDbPath(h.configDir));
|
|
1750
|
-
try {
|
|
1751
|
-
const onDisk = await readOperatorTokenFile(h.configDir);
|
|
1752
|
-
expect(onDisk).not.toBeNull();
|
|
1753
|
-
const validated = await validateAccessToken(
|
|
1754
|
-
verifyDb,
|
|
1755
|
-
onDisk as string,
|
|
1756
|
-
"https://gitcoin-parachute.unforced.dev",
|
|
1757
|
-
);
|
|
1758
|
-
expect(validated.payload.iss).toBe("https://gitcoin-parachute.unforced.dev");
|
|
1759
|
-
expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
|
|
1760
|
-
} finally {
|
|
1761
|
-
verifyDb.close();
|
|
1762
|
-
}
|
|
1763
|
-
} finally {
|
|
1764
|
-
h.cleanup();
|
|
1765
|
-
}
|
|
1766
|
-
});
|
|
1767
|
-
|
|
1768
|
-
test("stop hub: dispatches to stopHub, true → '✓ hub stopped'", async () => {
|
|
1769
|
-
const h = makeHarness();
|
|
1770
|
-
try {
|
|
1771
|
-
const log: string[] = [];
|
|
1772
|
-
const stopCalls: Array<{ configDir?: string }> = [];
|
|
1773
|
-
const code = await stop("hub", {
|
|
1774
|
-
configDir: h.configDir,
|
|
1775
|
-
manifestPath: h.manifestPath,
|
|
1776
|
-
hub: {
|
|
1777
|
-
stop: async (opts) => {
|
|
1778
|
-
stopCalls.push({ configDir: opts.configDir });
|
|
1779
|
-
return true;
|
|
1780
|
-
},
|
|
1781
|
-
},
|
|
1782
|
-
log: (l) => log.push(l),
|
|
1783
|
-
});
|
|
1784
|
-
expect(code).toBe(0);
|
|
1785
|
-
expect(stopCalls).toHaveLength(1);
|
|
1786
|
-
expect(stopCalls[0]?.configDir).toBe(h.configDir);
|
|
1787
|
-
expect(log.join("\n")).toMatch(/✓ hub stopped/);
|
|
1788
|
-
} finally {
|
|
1789
|
-
h.cleanup();
|
|
1790
|
-
}
|
|
1791
|
-
});
|
|
1792
|
-
|
|
1793
|
-
test("stop hub: false → 'wasn't running' (still exit 0)", async () => {
|
|
1794
|
-
const h = makeHarness();
|
|
1795
|
-
try {
|
|
1796
|
-
const log: string[] = [];
|
|
1797
|
-
const code = await stop("hub", {
|
|
1798
|
-
configDir: h.configDir,
|
|
1799
|
-
manifestPath: h.manifestPath,
|
|
1800
|
-
hub: { stop: async () => false },
|
|
1801
|
-
log: (l) => log.push(l),
|
|
1802
|
-
});
|
|
1803
|
-
expect(code).toBe(0);
|
|
1804
|
-
expect(log.join("\n")).toMatch(/hub wasn't running/);
|
|
1805
|
-
} finally {
|
|
1806
|
-
h.cleanup();
|
|
1807
|
-
}
|
|
1808
|
-
});
|
|
1809
|
-
|
|
1810
|
-
test("restart hub: chains stop then start through the same hub seam", async () => {
|
|
1811
|
-
const h = makeHarness();
|
|
1812
|
-
try {
|
|
1813
|
-
const log: string[] = [];
|
|
1814
|
-
const order: string[] = [];
|
|
1815
|
-
const code = await restart("hub", {
|
|
1816
|
-
configDir: h.configDir,
|
|
1817
|
-
manifestPath: h.manifestPath,
|
|
1818
|
-
hub: {
|
|
1819
|
-
stop: async () => {
|
|
1820
|
-
order.push("stop");
|
|
1821
|
-
return true;
|
|
1822
|
-
},
|
|
1823
|
-
ensureRunning: async () => {
|
|
1824
|
-
order.push("start");
|
|
1825
|
-
return { pid: 5151, port: 1939, started: true };
|
|
1826
|
-
},
|
|
1827
|
-
},
|
|
1828
|
-
log: (l) => log.push(l),
|
|
1829
|
-
});
|
|
1830
|
-
expect(code).toBe(0);
|
|
1831
|
-
expect(order).toEqual(["stop", "start"]);
|
|
1832
|
-
} finally {
|
|
1833
|
-
h.cleanup();
|
|
1834
|
-
}
|
|
1835
|
-
});
|
|
1836
|
-
|
|
1837
|
-
test("logs hub: doesn't reject 'hub' as an unknown service", async () => {
|
|
1838
|
-
const h = makeHarness();
|
|
1839
|
-
try {
|
|
1840
|
-
// No log file yet — exercise the "no logs yet" branch, which still
|
|
1841
|
-
// returns 0. Goal of this test is just the unknown-service guard.
|
|
1842
|
-
const log: string[] = [];
|
|
1843
|
-
const code = await logs("hub", {
|
|
1844
|
-
configDir: h.configDir,
|
|
1845
|
-
manifestPath: h.manifestPath,
|
|
1846
|
-
log: (l) => log.push(l),
|
|
1847
|
-
});
|
|
1848
|
-
expect(code).toBe(0);
|
|
1849
|
-
expect(log.join("\n")).toMatch(/no logs yet for hub/);
|
|
1850
|
-
} finally {
|
|
1851
|
-
h.cleanup();
|
|
1852
|
-
}
|
|
1853
|
-
});
|
|
1854
|
-
|
|
1855
|
-
test("logs hub: prints the tail when a log file exists", async () => {
|
|
1856
|
-
const h = makeHarness();
|
|
1857
|
-
try {
|
|
1858
|
-
const path = ensureLogPath("hub", h.configDir);
|
|
1859
|
-
writeFileSync(path, "hub line one\nhub line two\n");
|
|
1860
|
-
const log: string[] = [];
|
|
1861
|
-
const code = await logs("hub", {
|
|
1862
|
-
configDir: h.configDir,
|
|
1863
|
-
manifestPath: h.manifestPath,
|
|
1864
|
-
log: (l) => log.push(l),
|
|
1865
|
-
});
|
|
1866
|
-
expect(code).toBe(0);
|
|
1867
|
-
expect(log).toEqual(["hub line one", "hub line two"]);
|
|
1868
|
-
} finally {
|
|
1869
|
-
h.cleanup();
|
|
1870
|
-
}
|
|
1871
|
-
});
|
|
1872
|
-
});
|
|
1873
|
-
|
|
1874
|
-
// ---------------------------------------------------------------------------
|
|
1875
|
-
// Phase 3b dual-dispatch (design §3.3): when a hub UNIT is installed, the verbs
|
|
1876
|
-
// drive the running supervisor / platform manager; when no unit is installed,
|
|
1877
|
-
// the existing detached arm is taken UNCHANGED. These suites exercise both arms
|
|
1878
|
-
// via injected `supervisor` seams (unit arm) and a stub spawner (detached arm).
|
|
1879
|
-
// ---------------------------------------------------------------------------
|
|
1880
|
-
|
|
1881
|
-
interface SupervisorStub {
|
|
1882
|
-
opts: NonNullable<LifecycleOpts["supervisor"]>;
|
|
1883
|
-
driveCalls: Array<{ short: string; op: ModuleOp }>;
|
|
1884
|
-
ensureCalls: Array<{ port?: number }>;
|
|
1885
|
-
stopHubCalls: number;
|
|
1886
|
-
restartHubCalls: number;
|
|
1887
|
-
healthProbes: number;
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
/**
|
|
1891
|
-
* Build a `supervisor` seam that forces the unit-installed arm and records the
|
|
1892
|
-
* supervisor / manager calls. `driveResponder` lets a test return a result or
|
|
1893
|
-
* throw a module-ops error per (short, op). The default responder returns a
|
|
1894
|
-
* benign sync-op result. `health` controls `probeHubHealth`.
|
|
1895
|
-
*/
|
|
1896
|
-
function makeSupervisorStub(opts?: {
|
|
1897
|
-
health?: boolean;
|
|
1898
|
-
ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
|
|
1899
|
-
ensureMessages?: string[];
|
|
1900
|
-
driveResponder?: (short: string, op: ModuleOp) => ModuleOpResult | Promise<ModuleOpResult>;
|
|
1901
|
-
stopHubResult?: HubUnitManagerOpResult;
|
|
1902
|
-
restartHubResult?: HubUnitManagerOpResult;
|
|
1903
|
-
}): SupervisorStub {
|
|
1904
|
-
const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
|
|
1905
|
-
const ensureCalls: Array<{ port?: number }> = [];
|
|
1906
|
-
const stub: SupervisorStub = {
|
|
1907
|
-
driveCalls,
|
|
1908
|
-
ensureCalls,
|
|
1909
|
-
stopHubCalls: 0,
|
|
1910
|
-
restartHubCalls: 0,
|
|
1911
|
-
healthProbes: 0,
|
|
1912
|
-
opts: {
|
|
1913
|
-
unitInstalled: true,
|
|
1914
|
-
// openDb is never exercised by the stub driveModuleOp, but the dispatch
|
|
1915
|
-
// opens+closes it around the call — hand back a no-op closer.
|
|
1916
|
-
openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
|
|
1917
|
-
driveModuleOp: async (short, op) => {
|
|
1918
|
-
driveCalls.push({ short, op });
|
|
1919
|
-
if (opts?.driveResponder) return await opts.driveResponder(short, op);
|
|
1920
|
-
return { status: 200, body: { short, state: { status: "running" } } };
|
|
1921
|
-
},
|
|
1922
|
-
ensureHubUnit: async (o) => {
|
|
1923
|
-
ensureCalls.push({ port: o.port });
|
|
1924
|
-
return {
|
|
1925
|
-
outcome: opts?.ensureOutcome ?? "already-up",
|
|
1926
|
-
port: o.port ?? 1939,
|
|
1927
|
-
messages: opts?.ensureMessages ?? [],
|
|
1928
|
-
};
|
|
1929
|
-
},
|
|
1930
|
-
stopHubUnit: () => {
|
|
1931
|
-
stub.stopHubCalls++;
|
|
1932
|
-
return opts?.stopHubResult ?? { outcome: "ok", messages: [] };
|
|
1933
|
-
},
|
|
1934
|
-
restartHubUnit: () => {
|
|
1935
|
-
stub.restartHubCalls++;
|
|
1936
|
-
return opts?.restartHubResult ?? { outcome: "ok", messages: [] };
|
|
1937
|
-
},
|
|
1938
|
-
probeHubHealth: async () => {
|
|
1939
|
-
stub.healthProbes++;
|
|
1940
|
-
return opts?.health ?? true;
|
|
1941
|
-
},
|
|
1942
|
-
},
|
|
1943
|
-
};
|
|
1944
|
-
return stub;
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
describe("Phase 3b dual-dispatch — start", () => {
|
|
1948
|
-
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(start)", async () => {
|
|
1949
|
-
const h = makeHarness();
|
|
1950
|
-
try {
|
|
1951
|
-
const sup = makeSupervisorStub();
|
|
1952
|
-
const log: string[] = [];
|
|
1953
|
-
const code = await start("vault", {
|
|
1954
|
-
configDir: h.configDir,
|
|
1955
|
-
manifestPath: h.manifestPath,
|
|
1956
|
-
log: (l) => log.push(l),
|
|
1957
|
-
supervisor: sup.opts,
|
|
1958
|
-
});
|
|
1959
|
-
expect(code).toBe(0);
|
|
1960
|
-
expect(sup.ensureCalls).toHaveLength(1);
|
|
1961
|
-
expect(sup.driveCalls).toEqual([{ short: "vault", op: "start" }]);
|
|
1962
|
-
expect(log.join("\n")).toMatch(/✓ vault started/);
|
|
1963
|
-
} finally {
|
|
1964
|
-
h.cleanup();
|
|
1965
|
-
}
|
|
1966
|
-
});
|
|
1967
|
-
|
|
1968
|
-
test("no svc, unit-installed → ensureHubUnit only (boots all modules), no driveModuleOp", async () => {
|
|
1969
|
-
const h = makeHarness();
|
|
1970
|
-
try {
|
|
1971
|
-
const sup = makeSupervisorStub();
|
|
1972
|
-
const code = await start(undefined, {
|
|
1973
|
-
configDir: h.configDir,
|
|
1974
|
-
manifestPath: h.manifestPath,
|
|
1975
|
-
log: () => {},
|
|
1976
|
-
supervisor: sup.opts,
|
|
1977
|
-
});
|
|
1978
|
-
expect(code).toBe(0);
|
|
1979
|
-
expect(sup.ensureCalls).toHaveLength(1);
|
|
1980
|
-
expect(sup.driveCalls).toHaveLength(0);
|
|
1981
|
-
} finally {
|
|
1982
|
-
h.cleanup();
|
|
1983
|
-
}
|
|
1984
|
-
});
|
|
1985
|
-
|
|
1986
|
-
test("module svc, NO unit → existing detached spawner path (unchanged)", async () => {
|
|
1987
|
-
const h = makeHarness();
|
|
1988
|
-
try {
|
|
1989
|
-
seedVault(h.manifestPath);
|
|
1990
|
-
const spawner = makeSpawner([4242]);
|
|
1991
|
-
const code = await start("vault", {
|
|
1992
|
-
configDir: h.configDir,
|
|
1993
|
-
manifestPath: h.manifestPath,
|
|
1994
|
-
spawner,
|
|
1995
|
-
log: () => {},
|
|
1996
|
-
// supervisor omitted → detached arm, deterministically.
|
|
1997
|
-
});
|
|
1998
|
-
expect(code).toBe(0);
|
|
1999
|
-
expect(spawner.calls).toHaveLength(1);
|
|
2000
|
-
expect(spawner.calls[0]?.cmd).toEqual(["parachute-vault", "serve"]);
|
|
2001
|
-
expect(readPid("vault", h.configDir)).toBe(4242);
|
|
2002
|
-
} finally {
|
|
2003
|
-
h.cleanup();
|
|
2004
|
-
}
|
|
2005
|
-
});
|
|
2006
|
-
|
|
2007
|
-
test("module svc, NoOperatorTokenError → actionable message surfaced (not raw-thrown)", async () => {
|
|
2008
|
-
const h = makeHarness();
|
|
2009
|
-
try {
|
|
2010
|
-
const sup = makeSupervisorStub({
|
|
2011
|
-
driveResponder: () => {
|
|
2012
|
-
throw new NoOperatorTokenError();
|
|
2013
|
-
},
|
|
2014
|
-
});
|
|
2015
|
-
const log: string[] = [];
|
|
2016
|
-
const code = await start("vault", {
|
|
2017
|
-
configDir: h.configDir,
|
|
2018
|
-
manifestPath: h.manifestPath,
|
|
2019
|
-
log: (l) => log.push(l),
|
|
2020
|
-
supervisor: sup.opts,
|
|
2021
|
-
});
|
|
2022
|
-
expect(code).toBe(1);
|
|
2023
|
-
expect(log.join("\n")).toMatch(/no operator token/);
|
|
2024
|
-
expect(log.join("\n")).toMatch(/parachute auth rotate-operator/);
|
|
2025
|
-
} finally {
|
|
2026
|
-
h.cleanup();
|
|
2027
|
-
}
|
|
2028
|
-
});
|
|
2029
|
-
|
|
2030
|
-
test("module svc, 400 not_installed → actionable install hint", async () => {
|
|
2031
|
-
const h = makeHarness();
|
|
2032
|
-
try {
|
|
2033
|
-
const sup = makeSupervisorStub({
|
|
2034
|
-
driveResponder: () => {
|
|
2035
|
-
throw new ModuleOpHttpError(400, "not_installed", "vault is not installed");
|
|
2036
|
-
},
|
|
2037
|
-
});
|
|
2038
|
-
const log: string[] = [];
|
|
2039
|
-
const code = await start("vault", {
|
|
2040
|
-
configDir: h.configDir,
|
|
2041
|
-
manifestPath: h.manifestPath,
|
|
2042
|
-
log: (l) => log.push(l),
|
|
2043
|
-
supervisor: sup.opts,
|
|
2044
|
-
});
|
|
2045
|
-
expect(code).toBe(1);
|
|
2046
|
-
expect(log.join("\n")).toMatch(/not installed/);
|
|
2047
|
-
expect(log.join("\n")).toMatch(/parachute install vault/);
|
|
2048
|
-
} finally {
|
|
2049
|
-
h.cleanup();
|
|
2050
|
-
}
|
|
2051
|
-
});
|
|
2052
|
-
});
|
|
2053
|
-
|
|
2054
|
-
describe("Phase 3b dual-dispatch — stop", () => {
|
|
2055
|
-
test("module svc, hub UP → driveModuleOp(stop), no ensureHubUnit", async () => {
|
|
2056
|
-
const h = makeHarness();
|
|
2057
|
-
try {
|
|
2058
|
-
const sup = makeSupervisorStub({ health: true });
|
|
2059
|
-
const log: string[] = [];
|
|
2060
|
-
const code = await stop("vault", {
|
|
2061
|
-
configDir: h.configDir,
|
|
2062
|
-
manifestPath: h.manifestPath,
|
|
2063
|
-
log: (l) => log.push(l),
|
|
2064
|
-
supervisor: sup.opts,
|
|
2065
|
-
});
|
|
2066
|
-
expect(code).toBe(0);
|
|
2067
|
-
expect(sup.healthProbes).toBe(1);
|
|
2068
|
-
expect(sup.driveCalls).toEqual([{ short: "vault", op: "stop" }]);
|
|
2069
|
-
expect(sup.ensureCalls).toHaveLength(0); // never start the hub just to stop a module
|
|
2070
|
-
expect(log.join("\n")).toMatch(/✓ vault stopped/);
|
|
2071
|
-
} finally {
|
|
2072
|
-
h.cleanup();
|
|
2073
|
-
}
|
|
2074
|
-
});
|
|
2075
|
-
|
|
2076
|
-
test("module svc, hub DOWN → success WITHOUT starting the hub or driving stop", async () => {
|
|
2077
|
-
const h = makeHarness();
|
|
2078
|
-
try {
|
|
2079
|
-
const sup = makeSupervisorStub({ health: false });
|
|
2080
|
-
const log: string[] = [];
|
|
2081
|
-
const code = await stop("vault", {
|
|
2082
|
-
configDir: h.configDir,
|
|
2083
|
-
manifestPath: h.manifestPath,
|
|
2084
|
-
log: (l) => log.push(l),
|
|
2085
|
-
supervisor: sup.opts,
|
|
2086
|
-
});
|
|
2087
|
-
expect(code).toBe(0);
|
|
2088
|
-
expect(sup.healthProbes).toBe(1);
|
|
2089
|
-
expect(sup.driveCalls).toHaveLength(0); // nothing to stop — module already down
|
|
2090
|
-
expect(sup.ensureCalls).toHaveLength(0); // did NOT ensureHubUnit
|
|
2091
|
-
expect(log.join("\n")).toMatch(/already stopped/);
|
|
2092
|
-
} finally {
|
|
2093
|
-
h.cleanup();
|
|
2094
|
-
}
|
|
2095
|
-
});
|
|
2096
|
-
|
|
2097
|
-
test("stop hub → platform manager (stopHubUnit), never a PID signal", async () => {
|
|
2098
|
-
const h = makeHarness();
|
|
2099
|
-
try {
|
|
2100
|
-
const sup = makeSupervisorStub();
|
|
2101
|
-
// A kill seam that fails the test if ANY PID signal is sent.
|
|
2102
|
-
const kill = () => {
|
|
2103
|
-
throw new Error("stop hub must NOT signal a PID — it must go through the manager");
|
|
2104
|
-
};
|
|
2105
|
-
const log: string[] = [];
|
|
2106
|
-
const code = await stop("hub", {
|
|
2107
|
-
configDir: h.configDir,
|
|
2108
|
-
manifestPath: h.manifestPath,
|
|
2109
|
-
log: (l) => log.push(l),
|
|
2110
|
-
kill,
|
|
2111
|
-
supervisor: sup.opts,
|
|
2112
|
-
});
|
|
2113
|
-
expect(code).toBe(0);
|
|
2114
|
-
expect(sup.stopHubCalls).toBe(1);
|
|
2115
|
-
expect(sup.healthProbes).toBe(0);
|
|
2116
|
-
expect(log.join("\n")).toMatch(/✓ hub stopped/);
|
|
2117
|
-
} finally {
|
|
2118
|
-
h.cleanup();
|
|
2119
|
-
}
|
|
2120
|
-
});
|
|
2121
|
-
|
|
2122
|
-
test("no svc, unit-installed → stop the hub unit (manager)", async () => {
|
|
2123
|
-
const h = makeHarness();
|
|
2124
|
-
try {
|
|
2125
|
-
const sup = makeSupervisorStub();
|
|
2126
|
-
const code = await stop(undefined, {
|
|
2127
|
-
configDir: h.configDir,
|
|
2128
|
-
manifestPath: h.manifestPath,
|
|
2129
|
-
log: () => {},
|
|
2130
|
-
supervisor: sup.opts,
|
|
2131
|
-
});
|
|
2132
|
-
expect(code).toBe(0);
|
|
2133
|
-
expect(sup.stopHubCalls).toBe(1);
|
|
2134
|
-
expect(sup.driveCalls).toHaveLength(0);
|
|
2135
|
-
} finally {
|
|
2136
|
-
h.cleanup();
|
|
2137
|
-
}
|
|
2138
|
-
});
|
|
2139
|
-
|
|
2140
|
-
test("module svc, NO unit → existing detached stop path (unchanged)", async () => {
|
|
2141
|
-
const h = makeHarness();
|
|
2142
|
-
try {
|
|
2143
|
-
seedVault(h.manifestPath);
|
|
2144
|
-
writePid("vault", 4242, h.configDir);
|
|
2145
|
-
const killed: Array<{ pid: number; sig: NodeJS.Signals | number }> = [];
|
|
2146
|
-
const code = await stop("vault", {
|
|
2147
|
-
configDir: h.configDir,
|
|
2148
|
-
manifestPath: h.manifestPath,
|
|
2149
|
-
kill: (pid, sig) => killed.push({ pid, sig }),
|
|
2150
|
-
alive: (() => {
|
|
2151
|
-
let n = 0;
|
|
2152
|
-
return () => n++ < 1; // alive once (for the SIGTERM), then dead.
|
|
2153
|
-
})(),
|
|
2154
|
-
log: () => {},
|
|
2155
|
-
// supervisor omitted → detached arm.
|
|
2156
|
-
});
|
|
2157
|
-
expect(code).toBe(0);
|
|
2158
|
-
expect(killed[0]).toEqual({ pid: 4242, sig: "SIGTERM" });
|
|
2159
|
-
expect(readPid("vault", h.configDir)).toBeUndefined();
|
|
2160
|
-
} finally {
|
|
2161
|
-
h.cleanup();
|
|
2162
|
-
}
|
|
2163
|
-
});
|
|
2164
|
-
});
|
|
2165
|
-
|
|
2166
|
-
describe("Phase 3b dual-dispatch — restart", () => {
|
|
2167
|
-
test("module svc, unit-installed → ensureHubUnit then driveModuleOp(restart)", async () => {
|
|
2168
|
-
const h = makeHarness();
|
|
2169
|
-
try {
|
|
2170
|
-
const sup = makeSupervisorStub();
|
|
2171
|
-
const log: string[] = [];
|
|
2172
|
-
const code = await restart("vault", {
|
|
2173
|
-
configDir: h.configDir,
|
|
2174
|
-
manifestPath: h.manifestPath,
|
|
2175
|
-
log: (l) => log.push(l),
|
|
2176
|
-
supervisor: sup.opts,
|
|
2177
|
-
});
|
|
2178
|
-
expect(code).toBe(0);
|
|
2179
|
-
expect(sup.ensureCalls).toHaveLength(1);
|
|
2180
|
-
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
2181
|
-
expect(log.join("\n")).toMatch(/✓ vault restarted/);
|
|
2182
|
-
} finally {
|
|
2183
|
-
h.cleanup();
|
|
2184
|
-
}
|
|
2185
|
-
});
|
|
2186
|
-
|
|
2187
|
-
test("404 not_supervised on restart → fall through to driveModuleOp(start)", async () => {
|
|
2188
|
-
const h = makeHarness();
|
|
2189
|
-
try {
|
|
2190
|
-
const sup = makeSupervisorStub({
|
|
2191
|
-
driveResponder: (_short, op) => {
|
|
2192
|
-
if (op === "restart") {
|
|
2193
|
-
throw new ModuleOpHttpError(404, "not_supervised", "vault is not currently supervised");
|
|
2194
|
-
}
|
|
2195
|
-
return { status: 200, body: { short: "vault", state: { status: "running" } } };
|
|
2196
|
-
},
|
|
2197
|
-
});
|
|
2198
|
-
const log: string[] = [];
|
|
2199
|
-
const code = await restart("vault", {
|
|
2200
|
-
configDir: h.configDir,
|
|
2201
|
-
manifestPath: h.manifestPath,
|
|
2202
|
-
log: (l) => log.push(l),
|
|
2203
|
-
supervisor: sup.opts,
|
|
2204
|
-
});
|
|
2205
|
-
expect(code).toBe(0);
|
|
2206
|
-
// restart was attempted, then start as the 404-fallthrough (§6.2).
|
|
2207
|
-
expect(sup.driveCalls).toEqual([
|
|
2208
|
-
{ short: "vault", op: "restart" },
|
|
2209
|
-
{ short: "vault", op: "start" },
|
|
2210
|
-
]);
|
|
2211
|
-
expect(log.join("\n")).toMatch(/✓ vault started/);
|
|
2212
|
-
} finally {
|
|
2213
|
-
h.cleanup();
|
|
2214
|
-
}
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
test("restart hub → platform manager (restartHubUnit), never a PID signal", async () => {
|
|
2218
|
-
const h = makeHarness();
|
|
2219
|
-
try {
|
|
2220
|
-
const sup = makeSupervisorStub();
|
|
2221
|
-
const kill = () => {
|
|
2222
|
-
throw new Error("restart hub must NOT signal a PID — it must go through the manager");
|
|
2223
|
-
};
|
|
2224
|
-
const log: string[] = [];
|
|
2225
|
-
const code = await restart("hub", {
|
|
2226
|
-
configDir: h.configDir,
|
|
2227
|
-
manifestPath: h.manifestPath,
|
|
2228
|
-
log: (l) => log.push(l),
|
|
2229
|
-
kill,
|
|
2230
|
-
supervisor: sup.opts,
|
|
2231
|
-
});
|
|
2232
|
-
expect(code).toBe(0);
|
|
2233
|
-
expect(sup.restartHubCalls).toBe(1);
|
|
2234
|
-
expect(sup.driveCalls).toHaveLength(0); // NOT a per-module fan-out
|
|
2235
|
-
expect(log.join("\n")).toMatch(/✓ hub restarted/);
|
|
2236
|
-
} finally {
|
|
2237
|
-
h.cleanup();
|
|
2238
|
-
}
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
|
-
test("no svc, unit-installed → restart the hub unit (manager), not a fan-out", async () => {
|
|
2242
|
-
const h = makeHarness();
|
|
2243
|
-
try {
|
|
2244
|
-
const sup = makeSupervisorStub();
|
|
2245
|
-
const code = await restart(undefined, {
|
|
2246
|
-
configDir: h.configDir,
|
|
2247
|
-
manifestPath: h.manifestPath,
|
|
2248
|
-
log: () => {},
|
|
2249
|
-
supervisor: sup.opts,
|
|
2250
|
-
});
|
|
2251
|
-
expect(code).toBe(0);
|
|
2252
|
-
expect(sup.restartHubCalls).toBe(1);
|
|
2253
|
-
expect(sup.driveCalls).toHaveLength(0);
|
|
2254
|
-
} finally {
|
|
2255
|
-
h.cleanup();
|
|
2256
|
-
}
|
|
2257
|
-
});
|
|
2258
|
-
|
|
2259
|
-
test("module svc, NO unit → existing detached stop-then-start path (unchanged)", async () => {
|
|
2260
|
-
const h = makeHarness();
|
|
2261
|
-
try {
|
|
2262
|
-
seedVault(h.manifestPath);
|
|
2263
|
-
writePid("vault", 4242, h.configDir);
|
|
2264
|
-
const spawner = makeSpawner([7777]);
|
|
2265
|
-
const killed: Array<{ pid: number; sig: NodeJS.Signals | number }> = [];
|
|
2266
|
-
const code = await restart("vault", {
|
|
2267
|
-
configDir: h.configDir,
|
|
2268
|
-
manifestPath: h.manifestPath,
|
|
2269
|
-
spawner,
|
|
2270
|
-
kill: (pid, sig) => killed.push({ pid, sig }),
|
|
2271
|
-
// Stale 4242 is dead (stop's stale-pid path cleans up without a kill);
|
|
2272
|
-
// freshly spawned 7777 is alive past the post-spawn settle (hub#194).
|
|
2273
|
-
// Per-pid differentiation, mirroring the existing detached-restart test.
|
|
2274
|
-
alive: (pid) => pid === 7777,
|
|
2275
|
-
sleep: async () => {},
|
|
2276
|
-
log: () => {},
|
|
2277
|
-
// supervisor omitted → detached arm (stop then start).
|
|
2278
|
-
});
|
|
2279
|
-
expect(code).toBe(0);
|
|
2280
|
-
expect(killed).toHaveLength(0); // stale pid → detached cleanup, no kill
|
|
2281
|
-
expect(spawner.calls).toHaveLength(1); // detached start ran (NOT the supervisor)
|
|
2282
|
-
expect(readPid("vault", h.configDir)).toBe(7777);
|
|
827
|
+
expect(log).toEqual(["hub line one", "hub line two"]);
|
|
2283
828
|
} finally {
|
|
2284
829
|
h.cleanup();
|
|
2285
830
|
}
|