@openparachute/hub 0.6.3-rc.1 → 0.6.3-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -35
- package/package.json +1 -1
- package/src/__tests__/api-hub-upgrade.test.ts +690 -0
- package/src/__tests__/expose-cloudflare.test.ts +163 -72
- package/src/__tests__/expose-off-auto.test.ts +26 -1
- package/src/__tests__/expose.test.ts +260 -240
- package/src/__tests__/hub-control.test.ts +1 -242
- package/src/__tests__/hub-server.test.ts +64 -0
- package/src/__tests__/lifecycle.test.ts +431 -1886
- package/src/__tests__/migrate-cutover.test.ts +840 -0
- package/src/__tests__/migrate-offer.test.ts +240 -0
- package/src/__tests__/migrate.test.ts +132 -0
- package/src/__tests__/status-supervisor.test.ts +12 -77
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/upgrade.test.ts +351 -5
- package/src/api-hub-upgrade.ts +384 -0
- package/src/api-hub.ts +2 -1
- package/src/cli.ts +85 -10
- package/src/commands/expose-cloudflare.ts +63 -71
- package/src/commands/expose-supervisor.ts +247 -0
- package/src/commands/expose.ts +59 -48
- package/src/commands/lifecycle.ts +184 -873
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/status.ts +35 -282
- package/src/commands/upgrade.ts +100 -2
- package/src/help.ts +128 -68
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +28 -0
- package/src/hub-upgrade-helper.ts +306 -0
- package/src/hub-upgrade-mode.ts +209 -0
- package/src/hub-upgrade-status.ts +150 -0
- package/src/managed-unit.ts +20 -2
- package/src/migrate-offer.ts +186 -0
- package/src/process-state.ts +19 -3
- package/src/supervisor.ts +29 -24
- package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
- package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the SPA-driven hub self-upgrade (design 2026-06-01 §5.3 / D4):
|
|
3
|
+
* - `POST /api/hub/upgrade` endpoint: auth gate, channel validation, 202
|
|
4
|
+
* shape, the handler-spawns-the-helper-and-does-NOT-rewrite-inline
|
|
5
|
+
* invariant, redeploy-required short-circuit, status pollability.
|
|
6
|
+
* - The detached one-shot helper's rewrite-then-restart sequence (unit-
|
|
7
|
+
* managed + container graceful-exit), via injected seams.
|
|
8
|
+
* - The in-place-vs-redeploy mode detection (§5.3 heuristic).
|
|
9
|
+
*
|
|
10
|
+
* No real `bun add -g`, no real systemctl/launchctl, no real process signal —
|
|
11
|
+
* every side effect is a seam.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
type SpawnHelperArgs,
|
|
20
|
+
handleHubUpgrade,
|
|
21
|
+
handleHubUpgradeStatus,
|
|
22
|
+
} from "../api-hub-upgrade.ts";
|
|
23
|
+
import type { UpgradeOpts } from "../commands/upgrade.ts";
|
|
24
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
25
|
+
import type { HubUnitDeps, HubUnitManagerOpResult } from "../hub-unit.ts";
|
|
26
|
+
import { runHubUpgradeHelper } from "../hub-upgrade-helper.ts";
|
|
27
|
+
import { detectHubUpgradeMode } from "../hub-upgrade-mode.ts";
|
|
28
|
+
import {
|
|
29
|
+
type HubUpgradeStatus,
|
|
30
|
+
appendHubUpgradeStatus,
|
|
31
|
+
readHubUpgradeStatus,
|
|
32
|
+
writeHubUpgradeStatus,
|
|
33
|
+
} from "../hub-upgrade-status.ts";
|
|
34
|
+
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
35
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
36
|
+
import { createUser } from "../users.ts";
|
|
37
|
+
|
|
38
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
39
|
+
|
|
40
|
+
interface Harness {
|
|
41
|
+
dir: string;
|
|
42
|
+
db: ReturnType<typeof openHubDb>;
|
|
43
|
+
userId: string;
|
|
44
|
+
cleanup: () => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function makeHarness(): Promise<Harness> {
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-hub-upgrade-"));
|
|
49
|
+
const db = openHubDb(hubDbPath(dir));
|
|
50
|
+
rotateSigningKey(db);
|
|
51
|
+
const user = await createUser(db, "owner", "pw");
|
|
52
|
+
return {
|
|
53
|
+
dir,
|
|
54
|
+
db,
|
|
55
|
+
userId: user.id,
|
|
56
|
+
cleanup: () => {
|
|
57
|
+
db.close();
|
|
58
|
+
rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
64
|
+
const signed = await signAccessToken(h.db, {
|
|
65
|
+
sub: h.userId,
|
|
66
|
+
scopes,
|
|
67
|
+
audience: "parachute-hub",
|
|
68
|
+
clientId: "parachute-hub",
|
|
69
|
+
issuer: ISSUER,
|
|
70
|
+
ttlSeconds: 3600,
|
|
71
|
+
});
|
|
72
|
+
recordTokenMint(h.db, {
|
|
73
|
+
jti: signed.jti,
|
|
74
|
+
createdVia: "operator_mint",
|
|
75
|
+
subject: h.userId,
|
|
76
|
+
clientId: "parachute-hub",
|
|
77
|
+
scopes,
|
|
78
|
+
expiresAt: signed.expiresAt,
|
|
79
|
+
});
|
|
80
|
+
return signed.token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function postReq(headers: Record<string, string>, body?: unknown): Request {
|
|
84
|
+
const init: RequestInit = { method: "POST", headers };
|
|
85
|
+
if (body !== undefined) {
|
|
86
|
+
init.headers = { ...headers, "content-type": "application/json" };
|
|
87
|
+
init.body = JSON.stringify(body);
|
|
88
|
+
}
|
|
89
|
+
return new Request("http://localhost/api/hub/upgrade", init);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getStatusReq(headers: Record<string, string>): Request {
|
|
93
|
+
return new Request("http://localhost/api/hub/upgrade/status", { method: "GET", headers });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Base deps that never spawn a real process / touch npm. */
|
|
97
|
+
function baseDeps(h: Harness, overrides: Partial<Parameters<typeof handleHubUpgrade>[1]> = {}) {
|
|
98
|
+
const spawned: SpawnHelperArgs[] = [];
|
|
99
|
+
const deps = {
|
|
100
|
+
db: h.db,
|
|
101
|
+
issuer: ISSUER,
|
|
102
|
+
configDir: h.dir,
|
|
103
|
+
spawnHelper: (args: SpawnHelperArgs) => spawned.push(args),
|
|
104
|
+
resolveTargetVersion: async () => "0.6.3-rc.2",
|
|
105
|
+
currentVersion: () => "0.6.3-rc.1",
|
|
106
|
+
// Default: non-container, npm install → in-place. Tests that need
|
|
107
|
+
// redeploy-required override env + hubSrcDir.
|
|
108
|
+
env: { PARACHUTE_HOME: "/home/op/.parachute" } as Record<string, string | undefined>,
|
|
109
|
+
...overrides,
|
|
110
|
+
};
|
|
111
|
+
return { deps, spawned };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let harness: Harness;
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
harness = await makeHarness();
|
|
117
|
+
});
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
harness.cleanup();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("POST /api/hub/upgrade — auth gate", () => {
|
|
123
|
+
test("401 without a bearer", async () => {
|
|
124
|
+
const { deps } = baseDeps(harness, {
|
|
125
|
+
// Force in-place so we'd otherwise spawn — but auth must reject first.
|
|
126
|
+
hubSrcDir: "/nonexistent",
|
|
127
|
+
});
|
|
128
|
+
const res = await handleHubUpgrade(postReq({}), deps);
|
|
129
|
+
expect(res.status).toBe(401);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("403 with a host:auth-only token (lacks host:admin)", async () => {
|
|
133
|
+
const bearer = await mintBearer(harness, ["parachute:host:auth"]);
|
|
134
|
+
const { deps, spawned } = baseDeps(harness);
|
|
135
|
+
const res = await handleHubUpgrade(postReq({ authorization: `Bearer ${bearer}` }), deps);
|
|
136
|
+
expect(res.status).toBe(403);
|
|
137
|
+
const body = (await res.json()) as { error: string };
|
|
138
|
+
expect(body.error).toBe("insufficient_scope");
|
|
139
|
+
// The handler must NOT have spawned the helper on an auth failure.
|
|
140
|
+
expect(spawned.length).toBe(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("405 on non-POST", async () => {
|
|
144
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
145
|
+
const req = new Request("http://localhost/api/hub/upgrade", {
|
|
146
|
+
method: "GET",
|
|
147
|
+
headers: { authorization: `Bearer ${bearer}` },
|
|
148
|
+
});
|
|
149
|
+
const { deps } = baseDeps(harness);
|
|
150
|
+
const res = await handleHubUpgrade(req, deps);
|
|
151
|
+
expect(res.status).toBe(405);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("POST /api/hub/upgrade — channel validation (closed enum)", () => {
|
|
156
|
+
test("rejects a non-enum channel with 400", async () => {
|
|
157
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
158
|
+
const { deps, spawned } = baseDeps(harness);
|
|
159
|
+
const res = await handleHubUpgrade(
|
|
160
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "evil; rm -rf /" }),
|
|
161
|
+
deps,
|
|
162
|
+
);
|
|
163
|
+
expect(res.status).toBe(400);
|
|
164
|
+
const body = (await res.json()) as { error: string };
|
|
165
|
+
expect(body.error).toBe("invalid_channel");
|
|
166
|
+
expect(spawned.length).toBe(0);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("accepts channel: rc", async () => {
|
|
170
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
171
|
+
const { deps, spawned } = baseDeps(harness);
|
|
172
|
+
const res = await handleHubUpgrade(
|
|
173
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
174
|
+
deps,
|
|
175
|
+
);
|
|
176
|
+
expect(res.status).toBe(202);
|
|
177
|
+
expect(spawned[0]?.channel).toBe("rc");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("accepts channel: latest", async () => {
|
|
181
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
182
|
+
const { deps, spawned } = baseDeps(harness);
|
|
183
|
+
const res = await handleHubUpgrade(
|
|
184
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "latest" }),
|
|
185
|
+
deps,
|
|
186
|
+
);
|
|
187
|
+
expect(res.status).toBe(202);
|
|
188
|
+
expect(spawned[0]?.channel).toBe("latest");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("auto-detects channel from current version when none given (rc → rc)", async () => {
|
|
192
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
193
|
+
const { deps, spawned } = baseDeps(harness, { currentVersion: () => "0.6.3-rc.1" });
|
|
194
|
+
const res = await handleHubUpgrade(postReq({ authorization: `Bearer ${bearer}` }), deps);
|
|
195
|
+
expect(res.status).toBe(202);
|
|
196
|
+
const body = (await res.json()) as { channel: string };
|
|
197
|
+
expect(body.channel).toBe("rc");
|
|
198
|
+
expect(spawned[0]?.channel).toBe("rc");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("auto-detects channel from current version (stable → latest)", async () => {
|
|
202
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
203
|
+
const { deps } = baseDeps(harness, { currentVersion: () => "0.6.2" });
|
|
204
|
+
const res = await handleHubUpgrade(postReq({ authorization: `Bearer ${bearer}` }), deps);
|
|
205
|
+
const body = (await res.json()) as { channel: string };
|
|
206
|
+
expect(body.channel).toBe("latest");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("POST /api/hub/upgrade — 202 + spawn-not-inline (in-place)", () => {
|
|
211
|
+
test("202 with operation_id/target_version/channel/mode", async () => {
|
|
212
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
213
|
+
const { deps } = baseDeps(harness);
|
|
214
|
+
const res = await handleHubUpgrade(
|
|
215
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
216
|
+
deps,
|
|
217
|
+
);
|
|
218
|
+
expect(res.status).toBe(202);
|
|
219
|
+
const body = (await res.json()) as {
|
|
220
|
+
operation_id: string;
|
|
221
|
+
target_version: string;
|
|
222
|
+
channel: string;
|
|
223
|
+
mode: string;
|
|
224
|
+
};
|
|
225
|
+
expect(typeof body.operation_id).toBe("string");
|
|
226
|
+
expect(body.operation_id.length).toBeGreaterThan(0);
|
|
227
|
+
expect(body.target_version).toBe("0.6.3-rc.2");
|
|
228
|
+
expect(body.channel).toBe("rc");
|
|
229
|
+
expect(body.mode).toBe("in-place");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("spawns the detached helper (does NOT rewrite inline)", async () => {
|
|
233
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
234
|
+
const { deps, spawned } = baseDeps(harness);
|
|
235
|
+
const res = await handleHubUpgrade(
|
|
236
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
237
|
+
deps,
|
|
238
|
+
);
|
|
239
|
+
const body = (await res.json()) as { operation_id: string };
|
|
240
|
+
// Exactly one helper spawn; its op id matches the 202; no inline rewrite
|
|
241
|
+
// (the handler has no UpgradeRunner — the only way to rewrite is the
|
|
242
|
+
// helper, which we recorded rather than executed).
|
|
243
|
+
expect(spawned.length).toBe(1);
|
|
244
|
+
expect(spawned[0]?.operationId).toBe(body.operation_id);
|
|
245
|
+
expect(spawned[0]?.configDir).toBe(harness.dir);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("status file is seeded + pollable via GET /status", async () => {
|
|
249
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
250
|
+
const { deps } = baseDeps(harness);
|
|
251
|
+
const post = await handleHubUpgrade(
|
|
252
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
253
|
+
deps,
|
|
254
|
+
);
|
|
255
|
+
const { operation_id } = (await post.json()) as { operation_id: string };
|
|
256
|
+
|
|
257
|
+
const statusRes = await handleHubUpgradeStatus(
|
|
258
|
+
getStatusReq({ authorization: `Bearer ${bearer}` }),
|
|
259
|
+
deps,
|
|
260
|
+
);
|
|
261
|
+
expect(statusRes.status).toBe(200);
|
|
262
|
+
const status = (await statusRes.json()) as {
|
|
263
|
+
operation_id: string;
|
|
264
|
+
phase: string;
|
|
265
|
+
mode: string;
|
|
266
|
+
};
|
|
267
|
+
expect(status.operation_id).toBe(operation_id);
|
|
268
|
+
expect(status.phase).toBe("pending");
|
|
269
|
+
expect(status.mode).toBe("in-place");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("container in-place passes the hub pid to the helper (graceful-exit path)", async () => {
|
|
273
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
274
|
+
// Inject a container/in-place mode result (the real source-detection would
|
|
275
|
+
// classify this test's checkout as bun-linked; we want the container arm).
|
|
276
|
+
const { deps, spawned } = baseDeps(harness, {
|
|
277
|
+
detectMode: () => ({
|
|
278
|
+
mode: "in-place",
|
|
279
|
+
source: "container",
|
|
280
|
+
reason: "container with $BUN_INSTALL on the persistent disk",
|
|
281
|
+
}),
|
|
282
|
+
});
|
|
283
|
+
const res = await handleHubUpgrade(
|
|
284
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
285
|
+
deps,
|
|
286
|
+
);
|
|
287
|
+
expect(res.status).toBe(202);
|
|
288
|
+
const body = (await res.json()) as { mode: string };
|
|
289
|
+
expect(body.mode).toBe("in-place");
|
|
290
|
+
// Container source → the handler hands the helper a hub pid to signal.
|
|
291
|
+
expect(spawned[0]?.hubPid).toBe(process.pid);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe("POST /api/hub/upgrade — redeploy-required short-circuit (§5.3)", () => {
|
|
296
|
+
test("image-pinned container → 202 mode redeploy-required, NO helper spawned", async () => {
|
|
297
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
298
|
+
// Inject the image-pinned (redeploy-required) detection result. (The mode
|
|
299
|
+
// heuristic itself is unit-tested separately in `detectHubUpgradeMode`;
|
|
300
|
+
// here we assert the endpoint's short-circuit behavior on that result.)
|
|
301
|
+
const { deps, spawned } = baseDeps(harness, {
|
|
302
|
+
detectMode: () => ({
|
|
303
|
+
mode: "redeploy-required",
|
|
304
|
+
source: "container",
|
|
305
|
+
reason: "container image-pinned ($BUN_INSTALL not on the persistent disk)",
|
|
306
|
+
}),
|
|
307
|
+
});
|
|
308
|
+
const res = await handleHubUpgrade(
|
|
309
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
310
|
+
deps,
|
|
311
|
+
);
|
|
312
|
+
expect(res.status).toBe(202);
|
|
313
|
+
const body = (await res.json()) as { mode: string };
|
|
314
|
+
expect(body.mode).toBe("redeploy-required");
|
|
315
|
+
// The honest path: NO helper, NO misleading no-op rewrite.
|
|
316
|
+
expect(spawned.length).toBe(0);
|
|
317
|
+
// Status file reflects redeploy-required (terminal) so the SPA renders the
|
|
318
|
+
// dashboard hint, not a spinner.
|
|
319
|
+
const status = readHubUpgradeStatus(harness.dir);
|
|
320
|
+
expect(status?.phase).toBe("redeploy-required");
|
|
321
|
+
expect(status?.mode).toBe("redeploy-required");
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("POST /api/hub/upgrade — 409 in-flight guard (concurrent-upgrade)", () => {
|
|
326
|
+
/** Seed the status file with a prior op in the given phase. */
|
|
327
|
+
function seedStatus(dir: string, phase: HubUpgradeStatus["phase"], opId = "prior-op"): void {
|
|
328
|
+
writeHubUpgradeStatus(dir, {
|
|
329
|
+
operation_id: opId,
|
|
330
|
+
phase,
|
|
331
|
+
mode: "in-place",
|
|
332
|
+
current_version: "0.6.3-rc.1",
|
|
333
|
+
target_version: "0.6.3-rc.2",
|
|
334
|
+
channel: "rc",
|
|
335
|
+
log: [],
|
|
336
|
+
started_at: new Date().toISOString(),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
for (const phase of ["pending", "running", "restarting"] as const) {
|
|
341
|
+
test(`rejects a second POST while phase=${phase} with 409, no second helper spawned`, async () => {
|
|
342
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
343
|
+
seedStatus(harness.dir, phase);
|
|
344
|
+
const { deps, spawned } = baseDeps(harness);
|
|
345
|
+
const res = await handleHubUpgrade(
|
|
346
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
347
|
+
deps,
|
|
348
|
+
);
|
|
349
|
+
expect(res.status).toBe(409);
|
|
350
|
+
const body = (await res.json()) as { error: string };
|
|
351
|
+
expect(body.error).toBe("upgrade_in_flight");
|
|
352
|
+
// No second helper spawned; the prior op's status is untouched.
|
|
353
|
+
expect(spawned.length).toBe(0);
|
|
354
|
+
const status = readHubUpgradeStatus(harness.dir);
|
|
355
|
+
expect(status?.operation_id).toBe("prior-op");
|
|
356
|
+
expect(status?.phase).toBe(phase);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const phase of ["failed", "redeploy-required", "succeeded"] as const) {
|
|
361
|
+
test(`allows a new POST when the prior op is terminal (phase=${phase})`, async () => {
|
|
362
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
363
|
+
seedStatus(harness.dir, phase);
|
|
364
|
+
const { deps, spawned } = baseDeps(harness);
|
|
365
|
+
const res = await handleHubUpgrade(
|
|
366
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
367
|
+
deps,
|
|
368
|
+
);
|
|
369
|
+
expect(res.status).toBe(202);
|
|
370
|
+
// A fresh operation took the slot + spawned its helper.
|
|
371
|
+
expect(spawned.length).toBe(1);
|
|
372
|
+
const status = readHubUpgradeStatus(harness.dir);
|
|
373
|
+
expect(status?.operation_id).not.toBe("prior-op");
|
|
374
|
+
expect(spawned[0]?.operationId).toBe(status?.operation_id);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
test("no prior status file → POST proceeds normally", async () => {
|
|
379
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
380
|
+
const { deps, spawned } = baseDeps(harness);
|
|
381
|
+
const res = await handleHubUpgrade(
|
|
382
|
+
postReq({ authorization: `Bearer ${bearer}` }, { channel: "rc" }),
|
|
383
|
+
deps,
|
|
384
|
+
);
|
|
385
|
+
expect(res.status).toBe(202);
|
|
386
|
+
expect(spawned.length).toBe(1);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("appendHubUpgradeStatus — operation_id guard (stale-helper isolation)", () => {
|
|
391
|
+
test("a mismatched operationId is a NO-OP (cannot overwrite a newer op's status)", () => {
|
|
392
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-append-guard-"));
|
|
393
|
+
try {
|
|
394
|
+
// The slot is owned by the NEWER operation "op-2".
|
|
395
|
+
writeHubUpgradeStatus(dir, {
|
|
396
|
+
operation_id: "op-2",
|
|
397
|
+
phase: "pending",
|
|
398
|
+
mode: "in-place",
|
|
399
|
+
current_version: "0.6.3-rc.2",
|
|
400
|
+
target_version: "0.6.3-rc.3",
|
|
401
|
+
channel: "rc",
|
|
402
|
+
log: ["op-2 accepted"],
|
|
403
|
+
started_at: new Date().toISOString(),
|
|
404
|
+
});
|
|
405
|
+
// A STALE helper from the superseded "op-1" tries to write — must no-op.
|
|
406
|
+
appendHubUpgradeStatus(dir, "op-1", { phase: "failed", error: "stale" }, "stale helper line");
|
|
407
|
+
const after = readHubUpgradeStatus(dir);
|
|
408
|
+
expect(after?.operation_id).toBe("op-2");
|
|
409
|
+
expect(after?.phase).toBe("pending"); // NOT "failed"
|
|
410
|
+
expect(after?.error).toBeUndefined();
|
|
411
|
+
expect(after?.log).toEqual(["op-2 accepted"]); // stale line NOT appended
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("a matching operationId writes (phase advances + log appends)", () => {
|
|
418
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-append-match-"));
|
|
419
|
+
try {
|
|
420
|
+
writeHubUpgradeStatus(dir, {
|
|
421
|
+
operation_id: "op-1",
|
|
422
|
+
phase: "pending",
|
|
423
|
+
mode: "in-place",
|
|
424
|
+
current_version: "0.6.3-rc.1",
|
|
425
|
+
target_version: "0.6.3-rc.2",
|
|
426
|
+
channel: "rc",
|
|
427
|
+
log: ["accepted"],
|
|
428
|
+
started_at: new Date().toISOString(),
|
|
429
|
+
});
|
|
430
|
+
appendHubUpgradeStatus(dir, "op-1", { phase: "running" }, "helper started");
|
|
431
|
+
const after = readHubUpgradeStatus(dir);
|
|
432
|
+
expect(after?.operation_id).toBe("op-1");
|
|
433
|
+
expect(after?.phase).toBe("running");
|
|
434
|
+
expect(after?.log).toEqual(["accepted", "helper started"]);
|
|
435
|
+
} finally {
|
|
436
|
+
rmSync(dir, { recursive: true, force: true });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("GET /api/hub/upgrade/status", () => {
|
|
442
|
+
test("404 when no upgrade has been started", async () => {
|
|
443
|
+
const bearer = await mintBearer(harness, ["parachute:host:admin"]);
|
|
444
|
+
const { deps } = baseDeps(harness);
|
|
445
|
+
const res = await handleHubUpgradeStatus(
|
|
446
|
+
getStatusReq({ authorization: `Bearer ${bearer}` }),
|
|
447
|
+
deps,
|
|
448
|
+
);
|
|
449
|
+
expect(res.status).toBe(404);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("401 without bearer", async () => {
|
|
453
|
+
const { deps } = baseDeps(harness);
|
|
454
|
+
const res = await handleHubUpgradeStatus(getStatusReq({}), deps);
|
|
455
|
+
expect(res.status).toBe(401);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
describe("detectHubUpgradeMode — §5.3 heuristic", () => {
|
|
460
|
+
test("bun-linked → in-place", () => {
|
|
461
|
+
const r = detectHubUpgradeMode({
|
|
462
|
+
env: { PARACHUTE_HOME: "/home/op/.parachute" },
|
|
463
|
+
source: { kind: "bun-linked", path: "/home/op/parachute-hub" },
|
|
464
|
+
});
|
|
465
|
+
expect(r.mode).toBe("in-place");
|
|
466
|
+
expect(r.source).toBe("bun-linked");
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("npm on a VM (non-container) → in-place", () => {
|
|
470
|
+
const r = detectHubUpgradeMode({
|
|
471
|
+
env: { PARACHUTE_HOME: "/home/op/.parachute" },
|
|
472
|
+
source: { kind: "npm", path: "/home/op/.bun/install/global/node_modules/@openparachute/hub" },
|
|
473
|
+
});
|
|
474
|
+
expect(r.mode).toBe("in-place");
|
|
475
|
+
expect(r.source).toBe("npm");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("container + BUN_INSTALL on persistent disk → in-place", () => {
|
|
479
|
+
const r = detectHubUpgradeMode({
|
|
480
|
+
env: { PARACHUTE_HOME: "/parachute", BUN_INSTALL: "/parachute/.bun" },
|
|
481
|
+
source: { kind: "npm", path: "/parachute/.bun/install/global/node_modules" },
|
|
482
|
+
});
|
|
483
|
+
expect(r.mode).toBe("in-place");
|
|
484
|
+
expect(r.source).toBe("container");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("container image-pinned (BUN_INSTALL off-mount) → redeploy-required", () => {
|
|
488
|
+
const r = detectHubUpgradeMode({
|
|
489
|
+
env: { PARACHUTE_HOME: "/parachute", BUN_INSTALL: "/root/.bun" },
|
|
490
|
+
source: { kind: "npm", path: "/app/src" },
|
|
491
|
+
});
|
|
492
|
+
expect(r.mode).toBe("redeploy-required");
|
|
493
|
+
expect(r.source).toBe("container");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("container with BUN_INSTALL unset → redeploy-required (conservative)", () => {
|
|
497
|
+
const r = detectHubUpgradeMode({
|
|
498
|
+
env: { PARACHUTE_HOME: "/parachute" },
|
|
499
|
+
source: { kind: "bun-linked", path: "/app" },
|
|
500
|
+
});
|
|
501
|
+
// bun-linked wins even in a container (git pull persists on disk).
|
|
502
|
+
expect(r.mode).toBe("in-place");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("unknown source (non-container) → redeploy-required (honest fallback)", () => {
|
|
506
|
+
const r = detectHubUpgradeMode({
|
|
507
|
+
env: { PARACHUTE_HOME: "/home/op/.parachute" },
|
|
508
|
+
source: { kind: "unknown" },
|
|
509
|
+
});
|
|
510
|
+
expect(r.mode).toBe("redeploy-required");
|
|
511
|
+
expect(r.source).toBe("unknown");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("descendant-of guard: a stray /parachute path component is NOT on-mount", () => {
|
|
515
|
+
const r = detectHubUpgradeMode({
|
|
516
|
+
env: { PARACHUTE_HOME: "/parachute", BUN_INSTALL: "/parachute-other/.bun" },
|
|
517
|
+
source: { kind: "npm", path: "/app/src" },
|
|
518
|
+
});
|
|
519
|
+
// /parachute-other is NOT under /parachute/ → image-pinned.
|
|
520
|
+
expect(r.mode).toBe("redeploy-required");
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
describe("hub-upgrade helper — rewrite-then-restart sequence", () => {
|
|
525
|
+
function helperHarness() {
|
|
526
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-helper-"));
|
|
527
|
+
writeHubUpgradeStatus(dir, {
|
|
528
|
+
operation_id: "op-test",
|
|
529
|
+
phase: "pending",
|
|
530
|
+
mode: "in-place",
|
|
531
|
+
current_version: "0.6.3-rc.1",
|
|
532
|
+
target_version: "0.6.3-rc.2",
|
|
533
|
+
channel: "rc",
|
|
534
|
+
log: [],
|
|
535
|
+
started_at: new Date().toISOString(),
|
|
536
|
+
});
|
|
537
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
test("unit-managed: rewrite (no upgrade-side restart) then restartHubUnit", async () => {
|
|
541
|
+
const { dir, cleanup } = helperHarness();
|
|
542
|
+
try {
|
|
543
|
+
const upgradeCalls: { svc: string; opts: UpgradeOpts }[] = [];
|
|
544
|
+
let restartUnitCalled = 0;
|
|
545
|
+
const code = await runHubUpgradeHelper(
|
|
546
|
+
{ operationId: "op-test", channel: "rc", configDir: dir },
|
|
547
|
+
{
|
|
548
|
+
upgrade: async (svc, opts) => {
|
|
549
|
+
upgradeCalls.push({ svc, opts });
|
|
550
|
+
opts.log?.("fake bun add -g done");
|
|
551
|
+
return 0;
|
|
552
|
+
},
|
|
553
|
+
isHubUnitInstalled: () => true,
|
|
554
|
+
restartHubUnit: (): HubUnitManagerOpResult => {
|
|
555
|
+
restartUnitCalled++;
|
|
556
|
+
return { outcome: "ok", messages: ["restarted hub unit"] };
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
expect(code).toBe(0);
|
|
561
|
+
// upgrade was called for "hub" with a no-op restartFn (rewrite-only) and
|
|
562
|
+
// NO supervisor block (helper owns the restart, not upgrade's dual-dispatch).
|
|
563
|
+
expect(upgradeCalls.length).toBe(1);
|
|
564
|
+
expect(upgradeCalls[0]?.svc).toBe("hub");
|
|
565
|
+
expect(upgradeCalls[0]?.opts.channel).toBe("rc");
|
|
566
|
+
expect(upgradeCalls[0]?.opts.supervisor).toBeUndefined();
|
|
567
|
+
expect(typeof upgradeCalls[0]?.opts.restartFn).toBe("function");
|
|
568
|
+
// restartHubUnit was the restart authority.
|
|
569
|
+
expect(restartUnitCalled).toBe(1);
|
|
570
|
+
const status = readHubUpgradeStatus(dir);
|
|
571
|
+
expect(status?.phase).toBe("restarting");
|
|
572
|
+
} finally {
|
|
573
|
+
cleanup();
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("container (no unit): rewrite then SIGTERM the hub pid", async () => {
|
|
578
|
+
const { dir, cleanup } = helperHarness();
|
|
579
|
+
try {
|
|
580
|
+
const signals: [number, string][] = [];
|
|
581
|
+
const code = await runHubUpgradeHelper(
|
|
582
|
+
{ operationId: "op-test", channel: "rc", configDir: dir, hubPid: 4242 },
|
|
583
|
+
{
|
|
584
|
+
upgrade: async () => 0,
|
|
585
|
+
isHubUnitInstalled: () => false,
|
|
586
|
+
signalHub: (pid, sig) => signals.push([pid, sig]),
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
expect(code).toBe(0);
|
|
590
|
+
expect(signals).toEqual([[4242, "SIGTERM"]]);
|
|
591
|
+
const status = readHubUpgradeStatus(dir);
|
|
592
|
+
expect(status?.phase).toBe("restarting");
|
|
593
|
+
} finally {
|
|
594
|
+
cleanup();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("rewrite failure → phase failed, NO restart fired", async () => {
|
|
599
|
+
const { dir, cleanup } = helperHarness();
|
|
600
|
+
try {
|
|
601
|
+
let restartUnitCalled = 0;
|
|
602
|
+
const signals: unknown[] = [];
|
|
603
|
+
const code = await runHubUpgradeHelper(
|
|
604
|
+
{ operationId: "op-test", channel: "rc", configDir: dir, hubPid: 1 },
|
|
605
|
+
{
|
|
606
|
+
upgrade: async () => 1, // rewrite failed
|
|
607
|
+
isHubUnitInstalled: () => false,
|
|
608
|
+
restartHubUnit: () => {
|
|
609
|
+
restartUnitCalled++;
|
|
610
|
+
return { outcome: "ok", messages: [] };
|
|
611
|
+
},
|
|
612
|
+
signalHub: (pid, sig) => signals.push([pid, sig]),
|
|
613
|
+
},
|
|
614
|
+
);
|
|
615
|
+
expect(code).toBe(1);
|
|
616
|
+
expect(restartUnitCalled).toBe(0);
|
|
617
|
+
expect(signals.length).toBe(0);
|
|
618
|
+
const status = readHubUpgradeStatus(dir);
|
|
619
|
+
expect(status?.phase).toBe("failed");
|
|
620
|
+
} finally {
|
|
621
|
+
cleanup();
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
test("unit restart failure → phase failed", async () => {
|
|
626
|
+
const { dir, cleanup } = helperHarness();
|
|
627
|
+
try {
|
|
628
|
+
const code = await runHubUpgradeHelper(
|
|
629
|
+
{ operationId: "op-test", channel: "rc", configDir: dir },
|
|
630
|
+
{
|
|
631
|
+
upgrade: async () => 0,
|
|
632
|
+
isHubUnitInstalled: () => true,
|
|
633
|
+
restartHubUnit: (): HubUnitManagerOpResult => ({
|
|
634
|
+
outcome: "failed",
|
|
635
|
+
messages: ["systemctl restart failed"],
|
|
636
|
+
}),
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
expect(code).toBe(1);
|
|
640
|
+
const status = readHubUpgradeStatus(dir);
|
|
641
|
+
expect(status?.phase).toBe("failed");
|
|
642
|
+
} finally {
|
|
643
|
+
cleanup();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test("container with no hub pid → still succeeds (relies on runtime restart)", async () => {
|
|
648
|
+
const { dir, cleanup } = helperHarness();
|
|
649
|
+
try {
|
|
650
|
+
const signals: unknown[] = [];
|
|
651
|
+
const code = await runHubUpgradeHelper(
|
|
652
|
+
{ operationId: "op-test", channel: "rc", configDir: dir },
|
|
653
|
+
{
|
|
654
|
+
upgrade: async () => 0,
|
|
655
|
+
isHubUnitInstalled: () => false,
|
|
656
|
+
signalHub: (pid, sig) => signals.push([pid, sig]),
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
expect(code).toBe(0);
|
|
660
|
+
expect(signals.length).toBe(0);
|
|
661
|
+
const status = readHubUpgradeStatus(dir);
|
|
662
|
+
expect(status?.phase).toBe("restarting");
|
|
663
|
+
} finally {
|
|
664
|
+
cleanup();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("status-file progress writes accumulate in order", async () => {
|
|
669
|
+
const { dir, cleanup } = helperHarness();
|
|
670
|
+
try {
|
|
671
|
+
await runHubUpgradeHelper(
|
|
672
|
+
{ operationId: "op-test", channel: "rc", configDir: dir, hubPid: 99 },
|
|
673
|
+
{
|
|
674
|
+
upgrade: async (_svc, opts) => {
|
|
675
|
+
opts.log?.("running bun add -g @openparachute/hub@rc");
|
|
676
|
+
return 0;
|
|
677
|
+
},
|
|
678
|
+
isHubUnitInstalled: () => false,
|
|
679
|
+
signalHub: () => {},
|
|
680
|
+
},
|
|
681
|
+
);
|
|
682
|
+
const status = readHubUpgradeStatus(dir);
|
|
683
|
+
expect(status?.log[0]).toContain("helper started");
|
|
684
|
+
expect(status?.log.some((l) => l.includes("bun add -g"))).toBe(true);
|
|
685
|
+
expect(status?.log.some((l) => l.includes("signalling the hub"))).toBe(true);
|
|
686
|
+
} finally {
|
|
687
|
+
cleanup();
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|