@openparachute/hub 0.6.5-rc.4 → 0.6.5-rc.6
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/package.json +1 -1
- package/src/__tests__/hub-db-liveness.test.ts +215 -1
- package/src/__tests__/hub-server.test.ts +63 -0
- package/src/__tests__/install.test.ts +121 -0
- package/src/__tests__/setup-wizard.test.ts +139 -0
- package/src/__tests__/wizard.test.ts +89 -20
- package/src/commands/install.ts +157 -9
- package/src/commands/wizard.ts +10 -3
- package/src/hub-db-liveness.ts +287 -27
- package/src/hub-server.ts +73 -6
- package/src/setup-wizard.ts +43 -1
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { describe, expect, test } from "bun:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type DbInode,
|
|
5
|
+
type StatInodeFn,
|
|
6
|
+
classifyDbError,
|
|
7
|
+
classifyPathLiveness,
|
|
8
|
+
createDbHolder,
|
|
9
|
+
probeDbLiveness,
|
|
10
|
+
startDbPathLivenessTimer,
|
|
11
|
+
} from "../hub-db-liveness.ts";
|
|
4
12
|
|
|
5
13
|
/** Build a `SQLiteError`-shaped object with the given code + message. */
|
|
6
14
|
function sqliteErr(code: string, message: string): Error & { code: string } {
|
|
@@ -137,3 +145,209 @@ describe("createDbHolder (#594)", () => {
|
|
|
137
145
|
initial.close();
|
|
138
146
|
});
|
|
139
147
|
});
|
|
148
|
+
|
|
149
|
+
const INODE_A: DbInode = { dev: 1, ino: 100 };
|
|
150
|
+
const INODE_B: DbInode = { dev: 1, ino: 200 };
|
|
151
|
+
|
|
152
|
+
describe("classifyPathLiveness (#610)", () => {
|
|
153
|
+
test("same inode → ok", () => {
|
|
154
|
+
expect(classifyPathLiveness({ expected: INODE_A, current: INODE_A })).toBe("ok");
|
|
155
|
+
expect(classifyPathLiveness({ expected: INODE_A, current: { ...INODE_A } })).toBe("ok");
|
|
156
|
+
});
|
|
157
|
+
test("ENOENT on the path (current undefined) → gone", () => {
|
|
158
|
+
expect(classifyPathLiveness({ expected: INODE_A, current: undefined })).toBe("gone");
|
|
159
|
+
});
|
|
160
|
+
test("different inode → replaced", () => {
|
|
161
|
+
expect(classifyPathLiveness({ expected: INODE_A, current: INODE_B })).toBe("replaced");
|
|
162
|
+
// a different device counts too
|
|
163
|
+
expect(classifyPathLiveness({ expected: INODE_A, current: { dev: 2, ino: 100 } })).toBe(
|
|
164
|
+
"replaced",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
test("no baseline snapshot (expected undefined) → unknown, never self-heals", () => {
|
|
168
|
+
expect(classifyPathLiveness({ expected: undefined, current: INODE_A })).toBe("unknown");
|
|
169
|
+
expect(classifyPathLiveness({ expected: undefined, current: undefined })).toBe("unknown");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("DbHolder.probePath (#610 proactive detection)", () => {
|
|
174
|
+
/** A holder whose path stat is driven by the injected `statInode`. */
|
|
175
|
+
function makeHolder(opts: {
|
|
176
|
+
initialInode: DbInode | undefined;
|
|
177
|
+
statInode: StatInodeFn;
|
|
178
|
+
onReopen?: () => Database;
|
|
179
|
+
}) {
|
|
180
|
+
const initial = new Database(":memory:");
|
|
181
|
+
let reopens = 0;
|
|
182
|
+
let exits = 0;
|
|
183
|
+
let exitCode: number | undefined;
|
|
184
|
+
const holder = createDbHolder(initial, {
|
|
185
|
+
dbPath: "/fake/hub.db",
|
|
186
|
+
initialInode: opts.initialInode,
|
|
187
|
+
statInode: opts.statInode,
|
|
188
|
+
reopen: () => {
|
|
189
|
+
reopens += 1;
|
|
190
|
+
return opts.onReopen ? opts.onReopen() : new Database(":memory:");
|
|
191
|
+
},
|
|
192
|
+
exit: (code) => {
|
|
193
|
+
exits += 1;
|
|
194
|
+
exitCode = code;
|
|
195
|
+
},
|
|
196
|
+
log: () => {},
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
holder,
|
|
200
|
+
stats: () => ({ reopens, exits, exitCode }),
|
|
201
|
+
cleanup: () => {
|
|
202
|
+
try {
|
|
203
|
+
initial.close();
|
|
204
|
+
} catch {}
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
test("healthy path (same inode) → no reopen, no exit", () => {
|
|
210
|
+
const h = makeHolder({ initialInode: INODE_A, statInode: () => INODE_A });
|
|
211
|
+
expect(h.holder.probePath()).toBe("ok");
|
|
212
|
+
expect(h.stats().reopens).toBe(0);
|
|
213
|
+
expect(h.stats().exits).toBe(0);
|
|
214
|
+
h.cleanup();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("path GONE (ENOENT) → reopen attempted; reopen verify fails → exit(1)", () => {
|
|
218
|
+
// Reopen returns a closed handle (the dir is still gone) → SELECT 1 throws
|
|
219
|
+
// → exit. This is the genuine `rm -rf ~/.parachute` field shape.
|
|
220
|
+
const dead = new Database(":memory:");
|
|
221
|
+
dead.close();
|
|
222
|
+
const h = makeHolder({
|
|
223
|
+
initialInode: INODE_A,
|
|
224
|
+
statInode: () => undefined, // ENOENT
|
|
225
|
+
onReopen: () => dead,
|
|
226
|
+
});
|
|
227
|
+
expect(h.holder.probePath()).toBe("gone");
|
|
228
|
+
expect(h.stats().reopens).toBe(1);
|
|
229
|
+
expect(h.stats().exits).toBe(1);
|
|
230
|
+
expect(h.stats().exitCode).toBe(1);
|
|
231
|
+
h.cleanup();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("path REPLACED (different inode) → reopen + swap (heals, no exit)", () => {
|
|
235
|
+
const h = makeHolder({
|
|
236
|
+
initialInode: INODE_A,
|
|
237
|
+
statInode: () => INODE_B, // path now resolves to a different inode
|
|
238
|
+
onReopen: () => new Database(":memory:"),
|
|
239
|
+
});
|
|
240
|
+
expect(h.holder.probePath()).toBe("replaced");
|
|
241
|
+
expect(h.stats().reopens).toBe(1);
|
|
242
|
+
expect(h.stats().exits).toBe(0);
|
|
243
|
+
h.cleanup();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("NEVER fires on a transient stat throw (EACCES) — returns ok, no reopen/exit", () => {
|
|
247
|
+
const h = makeHolder({
|
|
248
|
+
initialInode: INODE_A,
|
|
249
|
+
statInode: () => {
|
|
250
|
+
const e = new Error("permission denied") as Error & { code: string };
|
|
251
|
+
e.code = "EACCES";
|
|
252
|
+
throw e;
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
expect(h.holder.probePath()).toBe("ok");
|
|
256
|
+
expect(h.stats().reopens).toBe(0);
|
|
257
|
+
expect(h.stats().exits).toBe(0);
|
|
258
|
+
h.cleanup();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("no baseline inode → unknown, never self-heals (safe degradation)", () => {
|
|
262
|
+
const h = makeHolder({ initialInode: undefined, statInode: () => undefined });
|
|
263
|
+
expect(h.holder.probePath()).toBe("unknown");
|
|
264
|
+
expect(h.stats().reopens).toBe(0);
|
|
265
|
+
expect(h.stats().exits).toBe(0);
|
|
266
|
+
h.cleanup();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("no dbPath configured → probePath is a no-op (unknown)", () => {
|
|
270
|
+
const initial = new Database(":memory:");
|
|
271
|
+
const holder = createDbHolder(initial, {
|
|
272
|
+
reopen: () => new Database(":memory:"),
|
|
273
|
+
exit: () => {},
|
|
274
|
+
log: () => {},
|
|
275
|
+
});
|
|
276
|
+
expect(holder.probePath()).toBe("unknown");
|
|
277
|
+
initial.close();
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("after a heal (replaced), the inode baseline is re-snapshotted to the new file", () => {
|
|
281
|
+
// First probe sees INODE_B (replaced) → reopen; statInode then returns
|
|
282
|
+
// INODE_B again so the NEXT probe sees the SAME inode → ok (not a loop).
|
|
283
|
+
let exits = 0;
|
|
284
|
+
const initial = new Database(":memory:");
|
|
285
|
+
const holder = createDbHolder(initial, {
|
|
286
|
+
dbPath: "/fake/hub.db",
|
|
287
|
+
initialInode: INODE_A,
|
|
288
|
+
statInode: () => INODE_B,
|
|
289
|
+
reopen: () => new Database(":memory:"),
|
|
290
|
+
exit: () => {
|
|
291
|
+
exits += 1;
|
|
292
|
+
},
|
|
293
|
+
log: () => {},
|
|
294
|
+
});
|
|
295
|
+
expect(holder.probePath()).toBe("replaced"); // A → B, heal
|
|
296
|
+
expect(holder.probePath()).toBe("ok"); // B → B, no further action
|
|
297
|
+
expect(exits).toBe(0);
|
|
298
|
+
initial.close();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("startDbPathLivenessTimer (#610 bounded watchdog)", () => {
|
|
303
|
+
test("each tick calls probePath exactly once; stop() clears the timer", () => {
|
|
304
|
+
let probes = 0;
|
|
305
|
+
const fakeHolder = {
|
|
306
|
+
get: () => new Database(":memory:"),
|
|
307
|
+
healOrExit: () => "ignored" as const,
|
|
308
|
+
probePath: () => {
|
|
309
|
+
probes += 1;
|
|
310
|
+
return "ok" as const;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
let registered: (() => void) | undefined;
|
|
314
|
+
let cleared = false;
|
|
315
|
+
const timer = startDbPathLivenessTimer<number>(fakeHolder, {
|
|
316
|
+
setIntervalFn: (cb) => {
|
|
317
|
+
registered = cb;
|
|
318
|
+
return 42;
|
|
319
|
+
},
|
|
320
|
+
clearIntervalFn: (h) => {
|
|
321
|
+
expect(h).toBe(42);
|
|
322
|
+
cleared = true;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
expect(registered).toBeDefined();
|
|
326
|
+
registered?.();
|
|
327
|
+
registered?.();
|
|
328
|
+
expect(probes).toBe(2);
|
|
329
|
+
timer.stop();
|
|
330
|
+
expect(cleared).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("a probe that throws is swallowed (the timer callback never crashes the process)", () => {
|
|
334
|
+
const fakeHolder = {
|
|
335
|
+
get: () => new Database(":memory:"),
|
|
336
|
+
healOrExit: () => "ignored" as const,
|
|
337
|
+
probePath: (): "ok" => {
|
|
338
|
+
throw new Error("unexpected");
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
let registered: (() => void) | undefined;
|
|
342
|
+
startDbPathLivenessTimer<number>(fakeHolder, {
|
|
343
|
+
setIntervalFn: (cb) => {
|
|
344
|
+
registered = cb;
|
|
345
|
+
return 1;
|
|
346
|
+
},
|
|
347
|
+
clearIntervalFn: () => {},
|
|
348
|
+
log: () => {},
|
|
349
|
+
});
|
|
350
|
+
// Must NOT throw out of the callback.
|
|
351
|
+
expect(() => registered?.()).not.toThrow();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -110,6 +110,69 @@ describe("hubFetch routing", () => {
|
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
test("/health reports db:ok when getDb is live and the proactive path probe is ok (#610)", async () => {
|
|
114
|
+
const h = makeHarness();
|
|
115
|
+
try {
|
|
116
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
117
|
+
try {
|
|
118
|
+
const res = await hubFetch(h.dir, {
|
|
119
|
+
getDb: () => db,
|
|
120
|
+
probeDbPath: () => "ok",
|
|
121
|
+
})(req("/health"));
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
const body = (await res.json()) as { status: string; db: string };
|
|
124
|
+
expect(body.status).toBe("ok");
|
|
125
|
+
expect(body.db).toBe("ok");
|
|
126
|
+
} finally {
|
|
127
|
+
db.close();
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
h.cleanup();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("/health surfaces db:error:path-gone when the proactive probe sees a wiped path (#610)", async () => {
|
|
135
|
+
// The ghost-fd lie: SELECT 1 still succeeds against the unlinked inode, so
|
|
136
|
+
// probeDbLiveness alone would report ok. probeDbPath stat()s the PATH and
|
|
137
|
+
// returns "gone" → /health must report the fault instead of lying.
|
|
138
|
+
const h = makeHarness();
|
|
139
|
+
try {
|
|
140
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
141
|
+
try {
|
|
142
|
+
const res = await hubFetch(h.dir, {
|
|
143
|
+
getDb: () => db,
|
|
144
|
+
probeDbPath: () => "gone",
|
|
145
|
+
})(req("/health"));
|
|
146
|
+
expect(res.status).toBe(200); // /health stays 200 (process liveness)
|
|
147
|
+
const body = (await res.json()) as { db: string };
|
|
148
|
+
expect(body.db).toBe("error: path-gone");
|
|
149
|
+
} finally {
|
|
150
|
+
db.close();
|
|
151
|
+
}
|
|
152
|
+
} finally {
|
|
153
|
+
h.cleanup();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("/health surfaces db:error:path-replaced when the proactive probe sees an inode swap (#610)", async () => {
|
|
158
|
+
const h = makeHarness();
|
|
159
|
+
try {
|
|
160
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
161
|
+
try {
|
|
162
|
+
const res = await hubFetch(h.dir, {
|
|
163
|
+
getDb: () => db,
|
|
164
|
+
probeDbPath: () => "replaced",
|
|
165
|
+
})(req("/health"));
|
|
166
|
+
const body = (await res.json()) as { db: string };
|
|
167
|
+
expect(body.db).toBe("error: path-replaced");
|
|
168
|
+
} finally {
|
|
169
|
+
db.close();
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
h.cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
113
176
|
test("/ renders the signed-out indicator dynamically when DB is configured but no session cookie (rc.13)", async () => {
|
|
114
177
|
// The dynamic path takes over from the static disk file the moment a
|
|
115
178
|
// DB is configured. With no session cookie, we still render — just
|
|
@@ -377,6 +377,127 @@ describe("install", () => {
|
|
|
377
377
|
}
|
|
378
378
|
});
|
|
379
379
|
|
|
380
|
+
test("ADOPT-KILLS an attributable same-module orphan on the canonical port + reclaims it (#609)", async () => {
|
|
381
|
+
// Wipe-recovery: `rm -rf ~/.parachute` + re-`init` leaves the supervised
|
|
382
|
+
// vault child running on :1940. The fresh install must reclaim the canonical
|
|
383
|
+
// port (adopt-kill the attributable orphan) rather than port-walk to 1944.
|
|
384
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
385
|
+
try {
|
|
386
|
+
const logs: string[] = [];
|
|
387
|
+
const kills: Array<{ pid: number; signal: string | number }> = [];
|
|
388
|
+
// installDir = /opt/.parachute/vault → the orphan's cmdline carries it, so
|
|
389
|
+
// attribution (per-module marker = installDir) succeeds.
|
|
390
|
+
const installDirPkg = "/opt/.parachute/vault/package.json";
|
|
391
|
+
// Port 1940 is held UNTIL the SIGTERM lands; after the kill the re-probe
|
|
392
|
+
// (collectOccupiedPorts) sees it free, so the assignment lands on 1940.
|
|
393
|
+
let killed = false;
|
|
394
|
+
const code = await install("vault", {
|
|
395
|
+
runner: async () => 0,
|
|
396
|
+
manifestPath: path,
|
|
397
|
+
configDir,
|
|
398
|
+
startService: async () => 0,
|
|
399
|
+
isLinked: () => false,
|
|
400
|
+
findGlobalInstall: () => installDirPkg,
|
|
401
|
+
portProbe: async (p) => p === 1940 && !killed,
|
|
402
|
+
pidOnPort: (p) => (p === 1940 && !killed ? 7777 : undefined),
|
|
403
|
+
ownerOfPid: (pid) =>
|
|
404
|
+
pid === 7777 ? "parachute-vault --port 1940 (/opt/.parachute/vault)" : undefined,
|
|
405
|
+
killPid: (pid, signal) => {
|
|
406
|
+
kills.push({ pid, signal });
|
|
407
|
+
killed = true; // the orphan releases the port on SIGTERM
|
|
408
|
+
},
|
|
409
|
+
sleep: async () => {},
|
|
410
|
+
reclaimDelayMs: 0,
|
|
411
|
+
log: (l) => logs.push(l),
|
|
412
|
+
});
|
|
413
|
+
expect(code).toBe(0);
|
|
414
|
+
const joined = logs.join("\n");
|
|
415
|
+
// We adopt-killed the attributable orphan…
|
|
416
|
+
expect(joined).toMatch(/attributable prior vault instance \(pid 7777/);
|
|
417
|
+
expect(joined).toMatch(/reclaiming it \(adopt-kill\)/);
|
|
418
|
+
expect(kills.map((k) => k.signal)).toContain("SIGTERM");
|
|
419
|
+
// …and the fresh install landed on the CANONICAL port, not a fallback.
|
|
420
|
+
const entry = findService("parachute-vault", path);
|
|
421
|
+
expect(entry?.port).toBe(1940);
|
|
422
|
+
expect(joined).not.toMatch(/is in use; assigned/);
|
|
423
|
+
} finally {
|
|
424
|
+
cleanup();
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("escalates to SIGKILL when the orphan ignores SIGTERM (#609)", async () => {
|
|
429
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
430
|
+
try {
|
|
431
|
+
const logs: string[] = [];
|
|
432
|
+
const signals: Array<string | number> = [];
|
|
433
|
+
const code = await install("vault", {
|
|
434
|
+
runner: async () => 0,
|
|
435
|
+
manifestPath: path,
|
|
436
|
+
configDir,
|
|
437
|
+
startService: async () => 0,
|
|
438
|
+
isLinked: () => false,
|
|
439
|
+
findGlobalInstall: () => "/opt/.parachute/vault/package.json",
|
|
440
|
+
// Orphan never releases 1940 → install ultimately walks (degrades
|
|
441
|
+
// gracefully), but it MUST have escalated to SIGKILL first.
|
|
442
|
+
portProbe: async (p) => p === 1940,
|
|
443
|
+
pidOnPort: (p) => (p === 1940 ? 8888 : undefined),
|
|
444
|
+
ownerOfPid: (pid) => (pid === 8888 ? "parachute-vault (/opt/.parachute/vault)" : undefined),
|
|
445
|
+
killPid: (_pid, signal) => {
|
|
446
|
+
signals.push(signal);
|
|
447
|
+
},
|
|
448
|
+
sleep: async () => {},
|
|
449
|
+
reclaimDelayMs: 0,
|
|
450
|
+
log: (l) => logs.push(l),
|
|
451
|
+
});
|
|
452
|
+
expect(code).toBe(0);
|
|
453
|
+
expect(signals).toContain("SIGTERM");
|
|
454
|
+
expect(signals).toContain("SIGKILL");
|
|
455
|
+
expect(logs.join("\n")).toMatch(/escalated to SIGKILL/);
|
|
456
|
+
} finally {
|
|
457
|
+
cleanup();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("does NOT kill a FOREIGN holder on the canonical port — walks + warns instead (#609 safety)", async () => {
|
|
462
|
+
// The crux: a non-attributable holder (an operator's unrelated process, or a
|
|
463
|
+
// sibling module) on :1940 must NEVER be killed. We fall through to the #590
|
|
464
|
+
// warn-and-walk path unchanged.
|
|
465
|
+
const { path, configDir, cleanup } = makeTempPath();
|
|
466
|
+
try {
|
|
467
|
+
const logs: string[] = [];
|
|
468
|
+
let killCalled = false;
|
|
469
|
+
const code = await install("vault", {
|
|
470
|
+
runner: async () => 0,
|
|
471
|
+
manifestPath: path,
|
|
472
|
+
configDir,
|
|
473
|
+
startService: async () => 0,
|
|
474
|
+
isLinked: () => false,
|
|
475
|
+
findGlobalInstall: () => "/opt/.parachute/vault/package.json",
|
|
476
|
+
portProbe: async (p) => p === 1940,
|
|
477
|
+
pidOnPort: (p) => (p === 1940 ? 5555 : undefined),
|
|
478
|
+
// Foreign cmdline — does NOT contain the vault installDir marker.
|
|
479
|
+
ownerOfPid: (pid) => (pid === 5555 ? "/usr/bin/python3 /opt/my-own-server.py" : undefined),
|
|
480
|
+
killPid: () => {
|
|
481
|
+
killCalled = true;
|
|
482
|
+
},
|
|
483
|
+
sleep: async () => {},
|
|
484
|
+
reclaimDelayMs: 0,
|
|
485
|
+
log: (l) => logs.push(l),
|
|
486
|
+
});
|
|
487
|
+
expect(code).toBe(0);
|
|
488
|
+
expect(killCalled).toBe(false); // NEVER kill a foreign process
|
|
489
|
+
const joined = logs.join("\n");
|
|
490
|
+
// The #590 warn-and-walk path is unchanged for the foreign holder.
|
|
491
|
+
expect(joined).toMatch(/canonical port 1940 is in use; assigned/);
|
|
492
|
+
expect(joined).toContain("pid 5555 (/usr/bin/python3 /opt/my-own-server.py)");
|
|
493
|
+
expect(joined).toMatch(/stale pre-supervisor daemon/);
|
|
494
|
+
const entry = findService("parachute-vault", path);
|
|
495
|
+
expect(entry?.port).not.toBe(1940); // walked, not reclaimed
|
|
496
|
+
} finally {
|
|
497
|
+
cleanup();
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
380
501
|
test("squatter pid present but command line unreadable → names the pid alone (#590)", async () => {
|
|
381
502
|
const { path, configDir, cleanup } = makeTempPath();
|
|
382
503
|
try {
|
|
@@ -179,6 +179,46 @@ describe("deriveWizardState", () => {
|
|
|
179
179
|
}
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
test("vault step when admin exists and only the SEED placeholder vault row is present (hub#607)", async () => {
|
|
183
|
+
// `parachute init` seeds a `parachute-vault` placeholder into
|
|
184
|
+
// services.json at SEED_VERSION ("0.0.0-linked") under hub#168 Cut 1
|
|
185
|
+
// (`noCreate`): the MODULE is installed, but no instance exists yet.
|
|
186
|
+
// Pre-#607, `hasVault` keyed off a bare `findService(...) !== undefined`
|
|
187
|
+
// check, which the placeholder satisfied — so the wizard silently
|
|
188
|
+
// skipped its vault step on EVERY init'd box and the operator finished
|
|
189
|
+
// setup with no vault. The placeholder must NOT count as a real vault.
|
|
190
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
191
|
+
try {
|
|
192
|
+
await createUser(db, "owner", "pw");
|
|
193
|
+
writeManifest(
|
|
194
|
+
{
|
|
195
|
+
services: [
|
|
196
|
+
{
|
|
197
|
+
name: "parachute-vault",
|
|
198
|
+
version: "0.0.0-linked",
|
|
199
|
+
port: 1940,
|
|
200
|
+
paths: ["/vault/default"],
|
|
201
|
+
health: "/health",
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
h.manifestPath,
|
|
206
|
+
);
|
|
207
|
+
const s = deriveWizardState({
|
|
208
|
+
db,
|
|
209
|
+
manifestPath: h.manifestPath,
|
|
210
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
211
|
+
});
|
|
212
|
+
// The placeholder is module-installed-but-no-instance, so the wizard
|
|
213
|
+
// still owns vault creation: it presents the create/import/skip step.
|
|
214
|
+
expect(s.step).toBe("vault");
|
|
215
|
+
expect(s.hasAdmin).toBe(true);
|
|
216
|
+
expect(s.hasVault).toBe(false);
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
182
222
|
test("expose step when admin + vault exist but expose mode not set yet (hub#268 Item 2)", async () => {
|
|
183
223
|
const db = openHubDb(hubDbPath(h.dir));
|
|
184
224
|
try {
|
|
@@ -1506,6 +1546,105 @@ describe("handleSetupVaultPost", () => {
|
|
|
1506
1546
|
}
|
|
1507
1547
|
});
|
|
1508
1548
|
|
|
1549
|
+
test("create on a SEED-placeholder box: does NOT short-circuit + drives the supervisor to start vault (hub#607 + hub#608)", async () => {
|
|
1550
|
+
// hub#607 + hub#608 coupled fresh-operator flow. On an init'd box,
|
|
1551
|
+
// services.json already carries a `parachute-vault` placeholder at
|
|
1552
|
+
// SEED_VERSION ("0.0.0-linked") — the MODULE is installed, no instance
|
|
1553
|
+
// exists. With the hub#607 `hasVault` discrimination, the wizard's vault
|
|
1554
|
+
// step still appears (the placeholder isn't a real vault), so a
|
|
1555
|
+
// `mode=create` POST must NOT be treated as "already provisioned" and
|
|
1556
|
+
// short-circuit to expose. It runs `runInstall`, which seeds/stamps the
|
|
1557
|
+
// row and — the hub#608 fix — drives `supervisor.start(...)` so the
|
|
1558
|
+
// freshly-created vault is ACTIVE immediately, not inactive-until-
|
|
1559
|
+
// restart. We assert both: the install op fired (no short-circuit) AND
|
|
1560
|
+
// the supervisor now reports a live vault child.
|
|
1561
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
1562
|
+
try {
|
|
1563
|
+
const user = await createUser(db, "owner", "pw");
|
|
1564
|
+
const { createSession, SESSION_COOKIE_NAME: SC } = await import("../sessions.ts");
|
|
1565
|
+
const session = createSession(db, { userId: user.id });
|
|
1566
|
+
// Simulate `parachute init`: the vault MODULE is seeded as a
|
|
1567
|
+
// placeholder, no supervisor entry yet (init ran with noStart).
|
|
1568
|
+
writeManifest(
|
|
1569
|
+
{
|
|
1570
|
+
services: [
|
|
1571
|
+
{
|
|
1572
|
+
name: "parachute-vault",
|
|
1573
|
+
version: "0.0.0-linked",
|
|
1574
|
+
port: 1940,
|
|
1575
|
+
paths: ["/vault/default"],
|
|
1576
|
+
health: "/health",
|
|
1577
|
+
},
|
|
1578
|
+
],
|
|
1579
|
+
},
|
|
1580
|
+
h.manifestPath,
|
|
1581
|
+
);
|
|
1582
|
+
const get = handleSetupGet(req("/admin/setup"), {
|
|
1583
|
+
db,
|
|
1584
|
+
manifestPath: h.manifestPath,
|
|
1585
|
+
configDir: h.dir,
|
|
1586
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1587
|
+
issuer: "https://hub.example",
|
|
1588
|
+
registry: getDefaultOperationsRegistry(),
|
|
1589
|
+
});
|
|
1590
|
+
const csrf = setCookie(get, CSRF_COOKIE_NAME) ?? "";
|
|
1591
|
+
const supervisor = makeSupervisor();
|
|
1592
|
+
// Sanity: no vault child before the wizard create.
|
|
1593
|
+
expect(supervisor.get("vault")).toBeUndefined();
|
|
1594
|
+
const runCalls: string[][] = [];
|
|
1595
|
+
const stubbedRun = async (cmd: readonly string[]) => {
|
|
1596
|
+
runCalls.push([...cmd]);
|
|
1597
|
+
return 0;
|
|
1598
|
+
};
|
|
1599
|
+
const post = await handleSetupVaultPost(
|
|
1600
|
+
req("/admin/setup/vault", {
|
|
1601
|
+
method: "POST",
|
|
1602
|
+
body: new URLSearchParams({
|
|
1603
|
+
[CSRF_FIELD_NAME]: csrf,
|
|
1604
|
+
mode: "create",
|
|
1605
|
+
vault_name: "myvault",
|
|
1606
|
+
scribe_provider: "none",
|
|
1607
|
+
}).toString(),
|
|
1608
|
+
headers: {
|
|
1609
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1610
|
+
cookie: `${CSRF_COOKIE_NAME}=${csrf}; ${SC}=${session.id}`,
|
|
1611
|
+
},
|
|
1612
|
+
}),
|
|
1613
|
+
{
|
|
1614
|
+
db,
|
|
1615
|
+
manifestPath: h.manifestPath,
|
|
1616
|
+
configDir: h.dir,
|
|
1617
|
+
readExposeStateFn: h.readExposeStateFn,
|
|
1618
|
+
issuer: "https://hub.example",
|
|
1619
|
+
supervisor,
|
|
1620
|
+
registry: getDefaultOperationsRegistry(),
|
|
1621
|
+
run: stubbedRun,
|
|
1622
|
+
isLinked: () => false,
|
|
1623
|
+
},
|
|
1624
|
+
);
|
|
1625
|
+
// Not short-circuited: the placeholder is not a real vault, so the
|
|
1626
|
+
// POST enqueues an install op rather than redirecting to expose.
|
|
1627
|
+
expect(post.status).toBe(303);
|
|
1628
|
+
const location = post.headers.get("location") ?? "";
|
|
1629
|
+
expect(location).toMatch(/^\/admin\/setup\?op=/);
|
|
1630
|
+
// Let the background runInstall promise reach the runner + supervisor.
|
|
1631
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
1632
|
+
// #607 proof: the install actually ran (not the "already provisioned"
|
|
1633
|
+
// short-circuit, which fires no `bun add`).
|
|
1634
|
+
expect(runCalls.some((c) => c.join(" ").includes("bun add -g @openparachute/vault"))).toBe(
|
|
1635
|
+
true,
|
|
1636
|
+
);
|
|
1637
|
+
// #608 proof: the supervisor was driven to start the vault child, so
|
|
1638
|
+
// the vault is live immediately after the wizard create — no manual
|
|
1639
|
+
// `parachute start vault` / hub restart needed.
|
|
1640
|
+
const vaultState = supervisor.get("vault");
|
|
1641
|
+
expect(vaultState).toBeDefined();
|
|
1642
|
+
expect(["starting", "running", "restarting"]).toContain(vaultState?.status ?? "");
|
|
1643
|
+
} finally {
|
|
1644
|
+
db.close();
|
|
1645
|
+
}
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1509
1648
|
// --- scribe cleanup sub-form (2026-05-27) -----------------------------
|
|
1510
1649
|
//
|
|
1511
1650
|
// The vault step's scribe sub-form was extended with a second radio
|