@openparachute/hub 0.6.5-rc.5 → 0.6.5-rc.7
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__/serve.test.ts +72 -0
- package/src/__tests__/wizard.test.ts +89 -20
- package/src/commands/serve.ts +81 -10
- package/src/commands/wizard.ts +10 -3
- package/src/setup-wizard.ts +25 -0
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { _resetBootstrapTokenForTests, getBootstrapToken } from "../bootstrap-token.ts";
|
|
6
6
|
import {
|
|
7
|
+
armServeDbWatchdog,
|
|
7
8
|
formatBootstrapTokenBanner,
|
|
8
9
|
formatListeningBanner,
|
|
9
10
|
hubPortConflictMessage,
|
|
@@ -463,3 +464,74 @@ describe("resolveStartupIssuer — expose-state fallback (#531)", () => {
|
|
|
463
464
|
expect(resolveStartupIssuer({}, {}, throwing)).toBeUndefined();
|
|
464
465
|
});
|
|
465
466
|
});
|
|
467
|
+
|
|
468
|
+
describe("armServeDbWatchdog — #610/#619 ghost-fd watchdog wiring on the serve path", () => {
|
|
469
|
+
let tmp: string;
|
|
470
|
+
let realDbPath: string;
|
|
471
|
+
|
|
472
|
+
beforeEach(() => {
|
|
473
|
+
tmp = mkdtempSync(join(tmpdir(), "serve-watchdog-"));
|
|
474
|
+
realDbPath = join(tmp, "hub.db");
|
|
475
|
+
});
|
|
476
|
+
afterEach(() => {
|
|
477
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("starts the liveness timer (without it, a wipe is never noticed)", () => {
|
|
481
|
+
let tick: (() => void) | undefined;
|
|
482
|
+
const { livenessTimer } = armServeDbWatchdog(realDbPath, {
|
|
483
|
+
openDb: () => openHubDb(realDbPath),
|
|
484
|
+
statInode: () => ({ dev: 1, ino: 42 }),
|
|
485
|
+
setIntervalFn: (cb) => {
|
|
486
|
+
tick = cb;
|
|
487
|
+
return 0;
|
|
488
|
+
},
|
|
489
|
+
clearIntervalFn: () => {},
|
|
490
|
+
});
|
|
491
|
+
// The timer must actually be armed — the captured tick callback proves
|
|
492
|
+
// startDbPathLivenessTimer ran (the #619 bug was that it never did on this path).
|
|
493
|
+
expect(tick).toBeInstanceOf(Function);
|
|
494
|
+
expect(livenessTimer).toBeDefined();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("opens the db BEFORE snapshotting the inode, so a wipe tick self-exits (#619 ordering)", () => {
|
|
498
|
+
// The load-bearing invariant: `initialInode` must be a DEFINED baseline so
|
|
499
|
+
// a later "gone" verdict fires reopen-or-exit. If the helper statted before
|
|
500
|
+
// opening (the bug), a fresh path would yield ENOENT → undefined baseline →
|
|
501
|
+
// probe stuck at "unknown" → NEVER exits on a wipe.
|
|
502
|
+
let opened = false;
|
|
503
|
+
let wiped = false;
|
|
504
|
+
let tick: (() => void) | undefined;
|
|
505
|
+
const exitCodes: number[] = [];
|
|
506
|
+
armServeDbWatchdog(realDbPath, {
|
|
507
|
+
openDb: () => {
|
|
508
|
+
if (wiped) throw new Error("ENOENT: state dir wiped");
|
|
509
|
+
opened = true;
|
|
510
|
+
return openHubDb(realDbPath);
|
|
511
|
+
},
|
|
512
|
+
statInode: () => {
|
|
513
|
+
if (wiped) return undefined; // path gone
|
|
514
|
+
// Proves ordering: if the helper statted before opening, this throws and
|
|
515
|
+
// the helper's catch leaves initialInode undefined (watchdog disabled).
|
|
516
|
+
if (!opened) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
|
|
517
|
+
return { dev: 1, ino: 42 };
|
|
518
|
+
},
|
|
519
|
+
setIntervalFn: (cb) => {
|
|
520
|
+
tick = cb;
|
|
521
|
+
return 0;
|
|
522
|
+
},
|
|
523
|
+
clearIntervalFn: () => {},
|
|
524
|
+
exit: (code) => exitCodes.push(code),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Simulate the wipe, then drive one watchdog tick.
|
|
528
|
+
wiped = true;
|
|
529
|
+
expect(tick).toBeInstanceOf(Function);
|
|
530
|
+
tick?.();
|
|
531
|
+
|
|
532
|
+
// The probe saw "gone" against a real baseline → reopen threw (dir gone) →
|
|
533
|
+
// exit(1). A non-zero exitCodes proves `initialInode` was a defined baseline,
|
|
534
|
+
// which proves the db was opened before the inode snapshot.
|
|
535
|
+
expect(exitCodes).toEqual([1]);
|
|
536
|
+
});
|
|
537
|
+
});
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
* OK envelope.
|
|
10
10
|
* 3. POST /admin/setup/vault (application/json) → 200 OK envelope
|
|
11
11
|
* with `op_id`.
|
|
12
|
-
* 4. GET /
|
|
12
|
+
* 4. GET /admin/setup?op=<id> until the `operation` envelope field
|
|
13
|
+
* reaches a terminal status (hub#616 — session-authed poll surface,
|
|
14
|
+
* NOT the Bearer-gated /api/modules/operations/:id the SPA uses).
|
|
13
15
|
* 5. POST /admin/setup/expose (application/json) → 200 OK.
|
|
14
16
|
*
|
|
15
17
|
* The stub fetch in this file is a mini-router that mimics the
|
|
@@ -32,6 +34,15 @@ interface FakeHubState {
|
|
|
32
34
|
importParams?: { remoteUrl: string; pat?: string; mode: string };
|
|
33
35
|
exposeMode?: string;
|
|
34
36
|
posted: Array<{ path: string; body: unknown }>;
|
|
37
|
+
/** hub#616: path+query of every op-poll GET, to assert the wizard polls the session surface. */
|
|
38
|
+
polled: string[];
|
|
39
|
+
/**
|
|
40
|
+
* hub#616: number of poll ticks the vault op reports `"running"` before
|
|
41
|
+
* flipping to `"succeeded"`. 0 (default) = succeeds immediately on the first
|
|
42
|
+
* poll. >0 exercises the multi-tick poll loop (the import-mode long-running
|
|
43
|
+
* provisioning path).
|
|
44
|
+
*/
|
|
45
|
+
vaultProvisionTicks?: number;
|
|
35
46
|
/** hub#576: when set, the fake GET /admin/setup reports requireBootstrapToken=true. */
|
|
36
47
|
requireBootstrapToken?: boolean;
|
|
37
48
|
/** hub#576: when set, the fake GET also returns it (loopback-probe behavior). */
|
|
@@ -52,6 +63,7 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
|
|
|
52
63
|
hasVault: false,
|
|
53
64
|
hasExposeMode: false,
|
|
54
65
|
posted: [],
|
|
66
|
+
polled: [],
|
|
55
67
|
...initialState,
|
|
56
68
|
};
|
|
57
69
|
// Synthesize a stable CSRF token + session token for the stub. The
|
|
@@ -69,6 +81,9 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
|
|
|
69
81
|
error?: string;
|
|
70
82
|
}
|
|
71
83
|
>();
|
|
84
|
+
// hub#616: per-op countdown of remaining `"running"` poll ticks before the
|
|
85
|
+
// op flips to `"succeeded"` (see FakeHubState.vaultProvisionTicks).
|
|
86
|
+
const opTicksRemaining = new Map<string, number>();
|
|
72
87
|
|
|
73
88
|
const fetchImpl = async (
|
|
74
89
|
input: string | URL | Request,
|
|
@@ -81,12 +96,28 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
|
|
|
81
96
|
const body = init?.body;
|
|
82
97
|
const bodyJson: unknown = body ? JSON.parse(String(body)) : null;
|
|
83
98
|
|
|
84
|
-
// GET /admin/setup
|
|
85
|
-
if (
|
|
99
|
+
// GET /admin/setup (incl. the `?op=<id>` poll surface — hub#616)
|
|
100
|
+
if (url.pathname === "/admin/setup" && method === "GET") {
|
|
86
101
|
let step: "welcome" | "vault" | "expose" | "done" = "welcome";
|
|
87
102
|
if (state.hasAdmin && state.hasVault && state.hasExposeMode) step = "done";
|
|
88
103
|
else if (state.hasAdmin && state.hasVault) step = "expose";
|
|
89
104
|
else if (state.hasAdmin) step = "vault";
|
|
105
|
+
// hub#616: the CLI wizard polls vault provisioning via this session-authed
|
|
106
|
+
// GET with `?op=<id>`; the op snapshot rides in the `operation` field.
|
|
107
|
+
const opId = url.searchParams.get("op");
|
|
108
|
+
if (opId) state.polled.push(path);
|
|
109
|
+
const op = opId ? ops.get(opId) : undefined;
|
|
110
|
+
// hub#616: drive a multi-tick op (running → succeeded) so the poll loop
|
|
111
|
+
// is exercised across more than one fetch when configured.
|
|
112
|
+
if (op && op.status === "running") {
|
|
113
|
+
const remaining = opTicksRemaining.get(op.id) ?? 0;
|
|
114
|
+
if (remaining <= 0) {
|
|
115
|
+
op.status = "succeeded";
|
|
116
|
+
state.hasVault = true;
|
|
117
|
+
} else {
|
|
118
|
+
opTicksRemaining.set(op.id, remaining - 1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
90
121
|
const respBody = JSON.stringify({
|
|
91
122
|
step,
|
|
92
123
|
hasAdmin: state.hasAdmin,
|
|
@@ -96,6 +127,7 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
|
|
|
96
127
|
csrfToken: csrf,
|
|
97
128
|
// hub#576: a loopback probe carries the actual token value.
|
|
98
129
|
...(state.bootstrapToken ? { bootstrapToken: state.bootstrapToken } : {}),
|
|
130
|
+
...(op ? { operation: op } : {}),
|
|
99
131
|
});
|
|
100
132
|
return new Response(respBody, {
|
|
101
133
|
status: 200,
|
|
@@ -155,30 +187,36 @@ function makeFakeHub(initialState?: Partial<FakeHubState>): {
|
|
|
155
187
|
...(b.pat ? { pat: b.pat } : {}),
|
|
156
188
|
};
|
|
157
189
|
}
|
|
158
|
-
// Create an op
|
|
190
|
+
// Create an op. By default it succeeds immediately on the first poll;
|
|
191
|
+
// when `vaultProvisionTicks` is set it reports `"running"` for that many
|
|
192
|
+
// poll ticks first (hub#616 — exercises the multi-tick poll loop).
|
|
159
193
|
const opId = `op-test-${++opCount}`;
|
|
160
|
-
|
|
161
|
-
|
|
194
|
+
const ticks = state.vaultProvisionTicks ?? 0;
|
|
195
|
+
if (ticks > 0) {
|
|
196
|
+
ops.set(opId, { id: opId, status: "running", log: ["bun add -g"] });
|
|
197
|
+
opTicksRemaining.set(opId, ticks);
|
|
198
|
+
} else {
|
|
199
|
+
ops.set(opId, { id: opId, status: "succeeded", log: ["bun add -g", "spawned"] });
|
|
200
|
+
state.hasVault = true;
|
|
201
|
+
}
|
|
162
202
|
return new Response(JSON.stringify({ op_id: opId, step: "vault", mode: b.mode }), {
|
|
163
203
|
status: 200,
|
|
164
204
|
headers: { "content-type": "application/json; charset=utf-8" },
|
|
165
205
|
});
|
|
166
206
|
}
|
|
167
207
|
|
|
168
|
-
// GET /api/modules/operations/<id>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
headers: { "content-type": "application/json; charset=utf-8" },
|
|
181
|
-
});
|
|
208
|
+
// GET /api/modules/operations/<id> — the Bearer-gated SPA/install-CLI poll
|
|
209
|
+
// surface. hub#616: the CLI wizard must NOT use it (it holds only a session
|
|
210
|
+
// cookie, not a host-admin Bearer). Mirror the real 401 so any regression
|
|
211
|
+
// back to this path fails the wizard poll loudly instead of silently.
|
|
212
|
+
if (url.pathname.startsWith("/api/modules/operations/") && method === "GET") {
|
|
213
|
+
return new Response(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
error: "unauthenticated",
|
|
216
|
+
error_description: "Authorization: Bearer required",
|
|
217
|
+
}),
|
|
218
|
+
{ status: 401, headers: { "content-type": "application/json; charset=utf-8" } },
|
|
219
|
+
);
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
// POST /admin/setup/expose
|
|
@@ -287,6 +325,37 @@ describe("runCliWizard", () => {
|
|
|
287
325
|
expect(vaultBody.mode).toBe("create");
|
|
288
326
|
expect(vaultBody.vault_name).toBe("default");
|
|
289
327
|
expect(state.exposeMode).toBe("localhost");
|
|
328
|
+
// hub#616: the vault-provisioning op is polled over the session-authed
|
|
329
|
+
// wizard surface, NOT the Bearer-gated /api/modules/operations/:id (which
|
|
330
|
+
// the fake 401s, mirroring the real gate). At least one poll must land on
|
|
331
|
+
// /admin/setup?op=, and every poll must use that path.
|
|
332
|
+
expect(state.polled.length).toBeGreaterThan(0);
|
|
333
|
+
for (const p of state.polled) expect(p.startsWith("/admin/setup?op=")).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test("multi-tick vault op (running → succeeded) polls the session surface across ticks — hub#616", async () => {
|
|
337
|
+
// Models the import-mode long-running provisioning path: the op reports
|
|
338
|
+
// `running` for two poll ticks before flipping to `succeeded`.
|
|
339
|
+
const { state, fetchImpl } = makeFakeHub({ vaultProvisionTicks: 2 });
|
|
340
|
+
const logs: string[] = [];
|
|
341
|
+
const code = await runCliWizard({
|
|
342
|
+
hubUrl: "http://127.0.0.1:1939",
|
|
343
|
+
log: (l) => logs.push(l),
|
|
344
|
+
fetchImpl,
|
|
345
|
+
sleep: async () => {},
|
|
346
|
+
accountUsername: "admin",
|
|
347
|
+
accountPassword: "longpassword",
|
|
348
|
+
vaultMode: "create",
|
|
349
|
+
vaultName: "default",
|
|
350
|
+
exposeMode: "localhost",
|
|
351
|
+
});
|
|
352
|
+
expect(code).toBe(0);
|
|
353
|
+
// The loop ran more than once (2 running ticks + the terminal succeeded
|
|
354
|
+
// poll), and every tick used the session-authed surface — never the
|
|
355
|
+
// Bearer-gated /api/modules/operations/:id.
|
|
356
|
+
expect(state.polled.length).toBeGreaterThanOrEqual(3);
|
|
357
|
+
for (const p of state.polled) expect(p.startsWith("/admin/setup?op=")).toBe(true);
|
|
358
|
+
expect(state.exposeMode).toBe("localhost");
|
|
290
359
|
});
|
|
291
360
|
|
|
292
361
|
test("loopback-probe bootstrap token is sent transparently (no prompt) — hub#576", async () => {
|
package/src/commands/serve.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { generateBootstrapToken } from "../bootstrap-token.ts";
|
|
|
34
34
|
// path isolation.
|
|
35
35
|
import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
|
|
36
36
|
import { readExposeState } from "../expose-state.ts";
|
|
37
|
-
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
37
|
+
import { createDbHolder, defaultStatInode, startDbPathLivenessTimer } from "../hub-db-liveness.ts";
|
|
38
38
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
39
39
|
import { hubFetch } from "../hub-server.ts";
|
|
40
40
|
import { writeHubFile } from "../hub.ts";
|
|
@@ -304,6 +304,80 @@ export function formatBootstrapTokenBanner(token: string, hubUrl?: string): stri
|
|
|
304
304
|
].join("\n");
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Injectable seams for {@link armServeDbWatchdog} (test-only). Generic on the
|
|
309
|
+
* timer handle `H` so the scheduler seams never name `setInterval` in type
|
|
310
|
+
* position — mirrors `DbLivenessTimerDeps<H>` in hub-db-liveness.ts, which
|
|
311
|
+
* keeps the public interface portable to a types-less tsc environment.
|
|
312
|
+
*/
|
|
313
|
+
export interface ServeDbWatchdogDeps<H = unknown> {
|
|
314
|
+
log?: (line: string) => void;
|
|
315
|
+
/** Open a db handle (default {@link openHubDb}). Tests inject a fake that creates a fixture. */
|
|
316
|
+
openDb?: (path: string) => ReturnType<typeof openHubDb>;
|
|
317
|
+
/** Path stat for the inode snapshot + proactive probe (default {@link defaultStatInode}). */
|
|
318
|
+
statInode?: typeof defaultStatInode;
|
|
319
|
+
/** Injectable scheduler threaded to the liveness timer (default `setInterval`). */
|
|
320
|
+
setIntervalFn?: (cb: () => void, ms: number) => H;
|
|
321
|
+
/** Injectable clear threaded to the liveness timer (default `clearInterval`). */
|
|
322
|
+
clearIntervalFn?: (handle: H) => void;
|
|
323
|
+
/** Process-exit fn threaded into the holder's reopen-or-exit (default `process.exit`). */
|
|
324
|
+
exit?: (code: number) => void;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Build the self-heal DB holder (#594) + start the proactive ghost-fd watchdog
|
|
329
|
+
* (#610) for the `parachute serve` path, returning both so the caller wires
|
|
330
|
+
* `getDb`/`onDbError`/`probeDbPath` and stops the timer on shutdown.
|
|
331
|
+
*
|
|
332
|
+
* Extracted + exported so the wiring is unit-testable WITHOUT binding a real
|
|
333
|
+
* port (#619): a serve()-level test would have to `Bun.serve` and risk the
|
|
334
|
+
* hub#535 launchd-bootout hazard, so this pure helper carries the load-bearing
|
|
335
|
+
* invariants instead — (1) the db is OPENED before the inode is snapshotted, so
|
|
336
|
+
* a fresh-install first boot gets a defined baseline (an ENOENT snapshot would
|
|
337
|
+
* silently disable the proactive probe for the whole process lifetime), and
|
|
338
|
+
* (2) the liveness timer is actually started. Both were absent on this path
|
|
339
|
+
* before #619 — the watchdog was wired only into `createHubServer`.
|
|
340
|
+
*/
|
|
341
|
+
export function armServeDbWatchdog<H = unknown>(
|
|
342
|
+
dbPath: string,
|
|
343
|
+
deps: ServeDbWatchdogDeps<H> = {},
|
|
344
|
+
): {
|
|
345
|
+
dbHolder: ReturnType<typeof createDbHolder>;
|
|
346
|
+
livenessTimer: ReturnType<typeof startDbPathLivenessTimer>;
|
|
347
|
+
} {
|
|
348
|
+
const openDb = deps.openDb ?? openHubDb;
|
|
349
|
+
const statInode = deps.statInode ?? defaultStatInode;
|
|
350
|
+
// Open FIRST — `openHubDb` mkdir's + creates the file when absent, so the
|
|
351
|
+
// stat below sees a real inode on a fresh-install first boot. Reversing this
|
|
352
|
+
// would leave `initialInode` undefined (ENOENT) and the probe at "unknown"
|
|
353
|
+
// for the process lifetime. Mirrors `createHubServer`'s ordering.
|
|
354
|
+
const db = openDb(dbPath);
|
|
355
|
+
let initialInode: ReturnType<typeof defaultStatInode> | undefined;
|
|
356
|
+
try {
|
|
357
|
+
initialInode = statInode(dbPath);
|
|
358
|
+
} catch {
|
|
359
|
+
initialInode = undefined;
|
|
360
|
+
}
|
|
361
|
+
const dbHolder = createDbHolder(db, {
|
|
362
|
+
reopen: () => openDb(dbPath),
|
|
363
|
+
dbPath,
|
|
364
|
+
statInode,
|
|
365
|
+
initialInode,
|
|
366
|
+
...(deps.log !== undefined ? { log: deps.log } : {}),
|
|
367
|
+
...(deps.exit !== undefined ? { exit: deps.exit } : {}),
|
|
368
|
+
});
|
|
369
|
+
// The active `parachute serve` path (systemd / launchd / container ExecStart)
|
|
370
|
+
// MUST start the watchdog here, not only in `createHubServer` — else a
|
|
371
|
+
// `rm -rf ~/.parachute` under a running unit leaves a ghost fd that keeps
|
|
372
|
+
// SELECT 1 succeeding with no thrown error, the reactive path never fires,
|
|
373
|
+
// and the hub never self-recovers (#619).
|
|
374
|
+
const livenessTimer = startDbPathLivenessTimer<H>(dbHolder, {
|
|
375
|
+
...(deps.setIntervalFn !== undefined ? { setIntervalFn: deps.setIntervalFn } : {}),
|
|
376
|
+
...(deps.clearIntervalFn !== undefined ? { clearIntervalFn: deps.clearIntervalFn } : {}),
|
|
377
|
+
});
|
|
378
|
+
return { dbHolder, livenessTimer };
|
|
379
|
+
}
|
|
380
|
+
|
|
307
381
|
/**
|
|
308
382
|
* Run the hub fetch loop in the foreground. Resolves when `Bun.serve` is
|
|
309
383
|
* bound; the returned `stop()` shuts the server down for tests.
|
|
@@ -355,15 +429,8 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
355
429
|
writeHubFile(hubHtmlPath);
|
|
356
430
|
|
|
357
431
|
const dbPath = hubDbPath();
|
|
358
|
-
// Self-heal-or-die DB holder (#594)
|
|
359
|
-
|
|
360
|
-
// error / malformed image — e.g. the state dir deleted under a running hub)
|
|
361
|
-
// can reopen the handle once, or exit(1) for the platform manager to restart
|
|
362
|
-
// us with a fresh one. `getDb` reads the current handle from the holder.
|
|
363
|
-
const dbHolder = createDbHolder(openHubDb(dbPath), {
|
|
364
|
-
reopen: () => openHubDb(dbPath),
|
|
365
|
-
log,
|
|
366
|
-
});
|
|
432
|
+
// Self-heal-or-die DB holder (#594) + proactive ghost-fd watchdog (#610/#619).
|
|
433
|
+
const { dbHolder, livenessTimer } = armServeDbWatchdog(dbPath, { log });
|
|
367
434
|
const adminBootstrap = await seedInitialAdminIfNeeded(dbHolder.get(), env, log);
|
|
368
435
|
|
|
369
436
|
if (adminBootstrap === "needs-setup") {
|
|
@@ -401,6 +468,9 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
401
468
|
fetch: hubFetch(WELL_KNOWN_DIR, {
|
|
402
469
|
getDb: () => dbHolder.get(),
|
|
403
470
|
onDbError: (err) => dbHolder.healOrExit(err),
|
|
471
|
+
// #610: /health's db check probes the path so monitoring + the #591
|
|
472
|
+
// adoption probe see a wipe instead of the ghost-fd lie.
|
|
473
|
+
probeDbPath: () => dbHolder.probePath(),
|
|
404
474
|
issuer,
|
|
405
475
|
loopbackPort: port,
|
|
406
476
|
supervisor,
|
|
@@ -486,6 +556,7 @@ export async function serve(opts: ServeOpts = {}): Promise<{
|
|
|
486
556
|
for (const state of supervisor.list()) {
|
|
487
557
|
await supervisor.stop(state.short);
|
|
488
558
|
}
|
|
559
|
+
livenessTimer.stop();
|
|
489
560
|
await server.stop();
|
|
490
561
|
dbHolder.get().close();
|
|
491
562
|
},
|
package/src/commands/wizard.ts
CHANGED
|
@@ -347,9 +347,15 @@ async function pollOperation(
|
|
|
347
347
|
const start = Date.now();
|
|
348
348
|
let lastLogIndex = 0;
|
|
349
349
|
for (;;) {
|
|
350
|
+
// hub#616: poll over the session-authed wizard surface (`/admin/setup?op=`),
|
|
351
|
+
// mirroring the browser wizard's re-GET — NOT the Bearer-gated
|
|
352
|
+
// `/api/modules/operations/:id` the SPA + install CLI use. Mid-setup the
|
|
353
|
+
// wizard holds only a session cookie; the op endpoint demands a host-admin
|
|
354
|
+
// Bearer it doesn't have, so a direct poll 401s and the vault step dies.
|
|
355
|
+
// The op snapshot rides back in the envelope's `operation` field.
|
|
350
356
|
const res = await setupFetch(
|
|
351
357
|
hubUrl,
|
|
352
|
-
`/
|
|
358
|
+
`/admin/setup?op=${encodeURIComponent(opId)}`,
|
|
353
359
|
jar,
|
|
354
360
|
fetchImpl,
|
|
355
361
|
);
|
|
@@ -358,10 +364,11 @@ async function pollOperation(
|
|
|
358
364
|
`op-poll failed (${res.status}) for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
|
|
359
365
|
);
|
|
360
366
|
}
|
|
361
|
-
const
|
|
367
|
+
const envelope = res.json as { operation?: Partial<OperationSnapshot> } | undefined;
|
|
368
|
+
const body = envelope?.operation;
|
|
362
369
|
if (!body || typeof body !== "object" || typeof body.id !== "string") {
|
|
363
370
|
throw new Error(
|
|
364
|
-
`op-poll returned
|
|
371
|
+
`op-poll returned no operation snapshot for ${shortLabel} op ${opId}: ${res.bodyText.slice(0, 200)}`,
|
|
365
372
|
);
|
|
366
373
|
}
|
|
367
374
|
// Print any new log lines since the last tick so the operator sees
|
package/src/setup-wizard.ts
CHANGED
|
@@ -1640,6 +1640,12 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1640
1640
|
requireBootstrapToken: boolean;
|
|
1641
1641
|
csrfToken: string;
|
|
1642
1642
|
bootstrapToken?: string;
|
|
1643
|
+
operation?: {
|
|
1644
|
+
id: string;
|
|
1645
|
+
status: "pending" | "running" | "succeeded" | "failed";
|
|
1646
|
+
log: readonly string[];
|
|
1647
|
+
error?: string;
|
|
1648
|
+
};
|
|
1643
1649
|
} = {
|
|
1644
1650
|
step: state.step,
|
|
1645
1651
|
hasAdmin: state.hasAdmin,
|
|
@@ -1648,6 +1654,25 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1648
1654
|
requireBootstrapToken: requireToken,
|
|
1649
1655
|
csrfToken: csrf.token,
|
|
1650
1656
|
};
|
|
1657
|
+
// hub#616: the CLI wizard polls vault-provisioning over THIS session-authed
|
|
1658
|
+
// surface (mirroring the browser wizard's `/admin/setup?op=<id>` re-GET),
|
|
1659
|
+
// not the Bearer-gated `/api/modules/operations/:id` the SPA + install CLI
|
|
1660
|
+
// use. The wizard holds only a session cookie mid-setup; the op endpoint
|
|
1661
|
+
// requires a host-admin Bearer it doesn't have, so a direct poll 401s and
|
|
1662
|
+
// the vault step dies. Threading the op snapshot into the envelope keeps the
|
|
1663
|
+
// poll on the auth the wizard already carries.
|
|
1664
|
+
const opId = url.searchParams.get("op");
|
|
1665
|
+
if (opId) {
|
|
1666
|
+
const op = deps.registry?.get(opId);
|
|
1667
|
+
if (op) {
|
|
1668
|
+
envelope.operation = {
|
|
1669
|
+
id: op.id,
|
|
1670
|
+
status: op.status,
|
|
1671
|
+
log: op.log,
|
|
1672
|
+
...(op.error !== undefined ? { error: op.error } : {}),
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1651
1676
|
// hub#576: hand the actual token to a LOOPBACK caller only. The on-box
|
|
1652
1677
|
// operator (`parachute init` → CLI wizard, or a curl from their own shell)
|
|
1653
1678
|
// already proves box access by reaching loopback — same trust level as
|