@openparachute/hub 0.5.13-rc.14 → 0.5.13-rc.23
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 +18 -0
- package/package.json +1 -1
- package/src/__tests__/api-hub.test.ts +251 -0
- package/src/__tests__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +17 -57
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-hub.ts +201 -0
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +18 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +4 -34
- package/src/service-spec.ts +44 -67
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-CG229ge6.js +61 -0
- package/web/ui/dist/assets/index-DArp3eO_.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D63mUkVX.js +0 -61
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/README.md
CHANGED
|
@@ -8,12 +8,30 @@ The hub coordinates the modules running on your machine: it installs them, runs
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
+
### Local (Bun)
|
|
12
|
+
|
|
11
13
|
```sh
|
|
12
14
|
bun add -g @openparachute/hub
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
|
|
16
18
|
|
|
19
|
+
### Hosted (Render)
|
|
20
|
+
|
|
21
|
+
[](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
|
|
22
|
+
|
|
23
|
+
One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
|
|
24
|
+
|
|
25
|
+
**Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
|
|
26
|
+
|
|
27
|
+
After deploy:
|
|
28
|
+
|
|
29
|
+
1. Open your Render service URL → wizard runs at `/admin/setup`
|
|
30
|
+
2. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match
|
|
31
|
+
3. Install modules via the admin SPA at `/admin/modules` (or via the wizard)
|
|
32
|
+
|
|
33
|
+
Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
|
|
34
|
+
|
|
17
35
|
## First 5 minutes
|
|
18
36
|
|
|
19
37
|
```sh
|
package/package.json
CHANGED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET /api/hub` — hub version + uptime + install-source surface for the
|
|
3
|
+
* admin SPA's version badge.
|
|
4
|
+
*
|
|
5
|
+
* Tests assert the contract:
|
|
6
|
+
* - 405 on non-GET (matches the shape of other /api/* read endpoints).
|
|
7
|
+
* - 401/403 on missing or under-scoped bearer (host:admin required).
|
|
8
|
+
* - Happy path returns the expected shape + uptime increments between
|
|
9
|
+
* calls.
|
|
10
|
+
* - PARACHUTE_HOME=/parachute (the Render Blueprint pin) overrides
|
|
11
|
+
* `source` to "container".
|
|
12
|
+
* - PARACHUTE_BUILD_TIME passes through as `container_build_time`.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { type HubStatusResponse, handleApiHub } from "../api-hub.ts";
|
|
20
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
21
|
+
import { signAccessToken } from "../jwt-sign.ts";
|
|
22
|
+
import { mintOperatorToken } from "../operator-token.ts";
|
|
23
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
24
|
+
import { createUser } from "../users.ts";
|
|
25
|
+
|
|
26
|
+
interface Harness {
|
|
27
|
+
dir: string;
|
|
28
|
+
cleanup: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeHarness(): Harness {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-hub-"));
|
|
33
|
+
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
37
|
+
|
|
38
|
+
// `hubSrcDir` defaults to `dirname(import.meta.url)` of api-hub.ts — but in
|
|
39
|
+
// tests we drive it explicitly so test-side test-double dirs don't accidentally
|
|
40
|
+
// pick up the real package.json. Point it at this file's dir (under __tests__/)
|
|
41
|
+
// so the climb-to-package.json loop walks up to the repo root and finds the
|
|
42
|
+
// real hub package.json.
|
|
43
|
+
const HUB_SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
44
|
+
|
|
45
|
+
async function bootstrap(
|
|
46
|
+
dir: string,
|
|
47
|
+
): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
|
|
48
|
+
const db = openHubDb(hubDbPath(dir));
|
|
49
|
+
rotateSigningKey(db);
|
|
50
|
+
const u = await createUser(db, "owner", "pw");
|
|
51
|
+
return { db, userId: u.id };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getRequest(headers: Record<string, string> = {}): Request {
|
|
55
|
+
return new Request("http://localhost/api/hub", {
|
|
56
|
+
method: "GET",
|
|
57
|
+
headers,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("GET /api/hub (hub version + uptime badge surface)", () => {
|
|
62
|
+
test("405 on non-GET", async () => {
|
|
63
|
+
const h = makeHarness();
|
|
64
|
+
try {
|
|
65
|
+
const { db } = await bootstrap(h.dir);
|
|
66
|
+
try {
|
|
67
|
+
const req = new Request("http://localhost/api/hub", { method: "POST" });
|
|
68
|
+
const resp = await handleApiHub(req, { db, issuer: ISSUER });
|
|
69
|
+
expect(resp.status).toBe(405);
|
|
70
|
+
} finally {
|
|
71
|
+
db.close();
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
h.cleanup();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("401 when no Authorization header", async () => {
|
|
79
|
+
const h = makeHarness();
|
|
80
|
+
try {
|
|
81
|
+
const { db } = await bootstrap(h.dir);
|
|
82
|
+
try {
|
|
83
|
+
const resp = await handleApiHub(getRequest(), { db, issuer: ISSUER });
|
|
84
|
+
expect(resp.status).toBe(401);
|
|
85
|
+
} finally {
|
|
86
|
+
db.close();
|
|
87
|
+
}
|
|
88
|
+
} finally {
|
|
89
|
+
h.cleanup();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("403 when bearer scope lacks parachute:host:admin", async () => {
|
|
94
|
+
const h = makeHarness();
|
|
95
|
+
try {
|
|
96
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
97
|
+
try {
|
|
98
|
+
const narrow = await signAccessToken(db, {
|
|
99
|
+
sub: userId,
|
|
100
|
+
// Adjacent host scope but NOT host:admin — host:auth is the
|
|
101
|
+
// tokens-registry scope, not the admin one. Confirms the gate
|
|
102
|
+
// checks the exact scope, not any host:* membership.
|
|
103
|
+
scopes: ["parachute:host:auth"],
|
|
104
|
+
audience: "hub",
|
|
105
|
+
clientId: "parachute-hub",
|
|
106
|
+
issuer: ISSUER,
|
|
107
|
+
ttlSeconds: 3600,
|
|
108
|
+
});
|
|
109
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${narrow.token}` }), {
|
|
110
|
+
db,
|
|
111
|
+
issuer: ISSUER,
|
|
112
|
+
});
|
|
113
|
+
expect(resp.status).toBe(403);
|
|
114
|
+
} finally {
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
h.cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("happy path: returns version + started_at + uptime_ms + source", async () => {
|
|
123
|
+
const h = makeHarness();
|
|
124
|
+
try {
|
|
125
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
126
|
+
try {
|
|
127
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
128
|
+
const startedAt = new Date(Date.now() - 5000); // started 5s ago
|
|
129
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
130
|
+
db,
|
|
131
|
+
issuer: ISSUER,
|
|
132
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
133
|
+
startedAt,
|
|
134
|
+
// Override env so we're not at the mercy of the host's
|
|
135
|
+
// PARACHUTE_HOME (or PARACHUTE_BUILD_TIME) when the test runs.
|
|
136
|
+
env: {},
|
|
137
|
+
});
|
|
138
|
+
expect(resp.status).toBe(200);
|
|
139
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
140
|
+
// Version pulled from the real hub package.json — assert SemVer
|
|
141
|
+
// shape rather than pinning a specific number (otherwise this test
|
|
142
|
+
// breaks on every rc bump).
|
|
143
|
+
expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
144
|
+
expect(body.started_at).toBe(startedAt.toISOString());
|
|
145
|
+
expect(body.uptime_ms).toBeGreaterThanOrEqual(5000);
|
|
146
|
+
// hubSrcDir points at the real repo's src/, so install-source
|
|
147
|
+
// classification will report bun-linked OR npm OR unknown — never
|
|
148
|
+
// "container" because we cleared PARACHUTE_HOME.
|
|
149
|
+
expect(body.source).not.toBe("container");
|
|
150
|
+
expect(body.container_build_time).toBeUndefined();
|
|
151
|
+
} finally {
|
|
152
|
+
db.close();
|
|
153
|
+
}
|
|
154
|
+
} finally {
|
|
155
|
+
h.cleanup();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("uptime_ms increments between calls", async () => {
|
|
160
|
+
const h = makeHarness();
|
|
161
|
+
try {
|
|
162
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
163
|
+
try {
|
|
164
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
165
|
+
const startedAt = new Date("2026-05-23T14:00:00.000Z");
|
|
166
|
+
// Drive "now" so the assertion isn't a flaky timing test.
|
|
167
|
+
const first = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
168
|
+
db,
|
|
169
|
+
issuer: ISSUER,
|
|
170
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
171
|
+
startedAt,
|
|
172
|
+
now: () => new Date("2026-05-23T14:00:05.000Z"),
|
|
173
|
+
env: {},
|
|
174
|
+
});
|
|
175
|
+
const second = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
176
|
+
db,
|
|
177
|
+
issuer: ISSUER,
|
|
178
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
179
|
+
startedAt,
|
|
180
|
+
now: () => new Date("2026-05-23T14:00:08.000Z"),
|
|
181
|
+
env: {},
|
|
182
|
+
});
|
|
183
|
+
const firstBody = (await first.json()) as HubStatusResponse;
|
|
184
|
+
const secondBody = (await second.json()) as HubStatusResponse;
|
|
185
|
+
expect(firstBody.uptime_ms).toBe(5000);
|
|
186
|
+
expect(secondBody.uptime_ms).toBe(8000);
|
|
187
|
+
expect(secondBody.uptime_ms).toBeGreaterThan(firstBody.uptime_ms);
|
|
188
|
+
// started_at stays stable across calls (captured once at process
|
|
189
|
+
// start, not per-request).
|
|
190
|
+
expect(secondBody.started_at).toBe(firstBody.started_at);
|
|
191
|
+
} finally {
|
|
192
|
+
db.close();
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
h.cleanup();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("PARACHUTE_HOME=/parachute overrides source to 'container'", async () => {
|
|
200
|
+
const h = makeHarness();
|
|
201
|
+
try {
|
|
202
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
203
|
+
try {
|
|
204
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
205
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
206
|
+
db,
|
|
207
|
+
issuer: ISSUER,
|
|
208
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
209
|
+
env: { PARACHUTE_HOME: "/parachute" },
|
|
210
|
+
});
|
|
211
|
+
expect(resp.status).toBe(200);
|
|
212
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
213
|
+
expect(body.source).toBe("container");
|
|
214
|
+
// bun_linked_path is suppressed under container source — operators
|
|
215
|
+
// on Render don't have a meaningful "checkout path" to surface.
|
|
216
|
+
expect(body.bun_linked_path).toBeUndefined();
|
|
217
|
+
} finally {
|
|
218
|
+
db.close();
|
|
219
|
+
}
|
|
220
|
+
} finally {
|
|
221
|
+
h.cleanup();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("PARACHUTE_BUILD_TIME passes through as container_build_time", async () => {
|
|
226
|
+
const h = makeHarness();
|
|
227
|
+
try {
|
|
228
|
+
const { db, userId } = await bootstrap(h.dir);
|
|
229
|
+
try {
|
|
230
|
+
const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
|
|
231
|
+
const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
|
|
232
|
+
db,
|
|
233
|
+
issuer: ISSUER,
|
|
234
|
+
hubSrcDir: HUB_SRC_DIR,
|
|
235
|
+
env: {
|
|
236
|
+
PARACHUTE_HOME: "/parachute",
|
|
237
|
+
PARACHUTE_BUILD_TIME: "2026-05-23T14:21:00.000Z",
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
expect(resp.status).toBe(200);
|
|
241
|
+
const body = (await resp.json()) as HubStatusResponse;
|
|
242
|
+
expect(body.container_build_time).toBe("2026-05-23T14:21:00.000Z");
|
|
243
|
+
expect(body.source).toBe("container");
|
|
244
|
+
} finally {
|
|
245
|
+
db.close();
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
h.cleanup();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -70,6 +70,14 @@ function postReq(path: string, headers: Record<string, string>): Request {
|
|
|
70
70
|
return new Request(`http://localhost${path}`, { method: "POST", headers });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function postReqJson(path: string, headers: Record<string, string>, body: unknown): Request {
|
|
74
|
+
return new Request(`http://localhost${path}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { ...headers, "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
function getReq(path: string, headers: Record<string, string>): Request {
|
|
74
82
|
return new Request(`http://localhost${path}`, { method: "GET", headers });
|
|
75
83
|
}
|
|
@@ -347,6 +355,222 @@ describe("POST /api/modules/:short/install", () => {
|
|
|
347
355
|
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
348
356
|
});
|
|
349
357
|
|
|
358
|
+
// hub#337 — per-request channel in body + PARACHUTE_INSTALL_CHANNEL env var.
|
|
359
|
+
// Precedence: body.channel > PARACHUTE_INSTALL_CHANNEL env > hub_settings row > "latest".
|
|
360
|
+
|
|
361
|
+
test("body { channel: 'rc' } overrides the hub_settings row (hub#337)", async () => {
|
|
362
|
+
// SPA-driven "install X at rc" affordance: per-call override that
|
|
363
|
+
// doesn't flip the cluster-wide toggle.
|
|
364
|
+
setModuleInstallChannel(h.db, "latest");
|
|
365
|
+
const { supervisor } = makeIdleSupervisor();
|
|
366
|
+
const { run, calls } = alwaysOkRun();
|
|
367
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
368
|
+
const res = await handleInstall(
|
|
369
|
+
postReqJson(
|
|
370
|
+
"/api/modules/vault/install",
|
|
371
|
+
{ authorization: `Bearer ${bearer}` },
|
|
372
|
+
{ channel: "rc" },
|
|
373
|
+
),
|
|
374
|
+
"vault",
|
|
375
|
+
{
|
|
376
|
+
db: h.db,
|
|
377
|
+
issuer: ISSUER,
|
|
378
|
+
manifestPath: h.manifestPath,
|
|
379
|
+
configDir: h.dir,
|
|
380
|
+
supervisor,
|
|
381
|
+
run,
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
expect(res.status).toBe(202);
|
|
385
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
386
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
387
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("body { channel: 'latest' } overrides hub_settings.module_install_channel = rc (hub#337)", async () => {
|
|
391
|
+
setModuleInstallChannel(h.db, "rc");
|
|
392
|
+
const { supervisor } = makeIdleSupervisor();
|
|
393
|
+
const { run, calls } = alwaysOkRun();
|
|
394
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
395
|
+
await handleInstall(
|
|
396
|
+
postReqJson(
|
|
397
|
+
"/api/modules/vault/install",
|
|
398
|
+
{ authorization: `Bearer ${bearer}` },
|
|
399
|
+
{ channel: "latest" },
|
|
400
|
+
),
|
|
401
|
+
"vault",
|
|
402
|
+
{
|
|
403
|
+
db: h.db,
|
|
404
|
+
issuer: ISSUER,
|
|
405
|
+
manifestPath: h.manifestPath,
|
|
406
|
+
configDir: h.dir,
|
|
407
|
+
supervisor,
|
|
408
|
+
run,
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
412
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
413
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("body { channel: 'banana' } returns 400 invalid_channel (hub#337)", async () => {
|
|
417
|
+
// Operator-typed garbage in the SPA → don't silently fall through to
|
|
418
|
+
// the default; surface the typo immediately.
|
|
419
|
+
const { supervisor } = makeIdleSupervisor();
|
|
420
|
+
const { run } = alwaysOkRun();
|
|
421
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
422
|
+
const res = await handleInstall(
|
|
423
|
+
postReqJson(
|
|
424
|
+
"/api/modules/vault/install",
|
|
425
|
+
{ authorization: `Bearer ${bearer}` },
|
|
426
|
+
{ channel: "banana" },
|
|
427
|
+
),
|
|
428
|
+
"vault",
|
|
429
|
+
{
|
|
430
|
+
db: h.db,
|
|
431
|
+
issuer: ISSUER,
|
|
432
|
+
manifestPath: h.manifestPath,
|
|
433
|
+
configDir: h.dir,
|
|
434
|
+
supervisor,
|
|
435
|
+
run,
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
expect(res.status).toBe(400);
|
|
439
|
+
const body = (await res.json()) as { error: string; error_description: string };
|
|
440
|
+
expect(body.error).toBe("invalid_channel");
|
|
441
|
+
expect(body.error_description).toMatch(/banana/);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("missing body / empty body falls through to hub_settings channel (back-compat)", async () => {
|
|
445
|
+
// Pre-hub#337 callers don't send a JSON body. The existing SPA paths
|
|
446
|
+
// (and the first-boot wizard) keep working unchanged.
|
|
447
|
+
setModuleInstallChannel(h.db, "rc");
|
|
448
|
+
const { supervisor } = makeIdleSupervisor();
|
|
449
|
+
const { run, calls } = alwaysOkRun();
|
|
450
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
451
|
+
await handleInstall(
|
|
452
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
453
|
+
"vault",
|
|
454
|
+
{
|
|
455
|
+
db: h.db,
|
|
456
|
+
issuer: ISSUER,
|
|
457
|
+
manifestPath: h.manifestPath,
|
|
458
|
+
configDir: h.dir,
|
|
459
|
+
supervisor,
|
|
460
|
+
run,
|
|
461
|
+
},
|
|
462
|
+
);
|
|
463
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
464
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("PARACHUTE_INSTALL_CHANNEL env overrides hub_settings.module_install_channel (hub#337)", async () => {
|
|
468
|
+
// The Render-deploy cascade shape: the platform sets the env var to
|
|
469
|
+
// `rc`, hub's API path picks it up over the DB-stored default. Lets
|
|
470
|
+
// an operator-toggle override that the platform-team hasn't pinned
|
|
471
|
+
// still work via the SPA toggle below it — but with the env in
|
|
472
|
+
// play, the env wins.
|
|
473
|
+
setModuleInstallChannel(h.db, "latest");
|
|
474
|
+
const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
|
|
475
|
+
process.env.PARACHUTE_INSTALL_CHANNEL = "rc";
|
|
476
|
+
try {
|
|
477
|
+
const { supervisor } = makeIdleSupervisor();
|
|
478
|
+
const { run, calls } = alwaysOkRun();
|
|
479
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
480
|
+
await handleInstall(
|
|
481
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
482
|
+
"vault",
|
|
483
|
+
{
|
|
484
|
+
db: h.db,
|
|
485
|
+
issuer: ISSUER,
|
|
486
|
+
manifestPath: h.manifestPath,
|
|
487
|
+
configDir: h.dir,
|
|
488
|
+
supervisor,
|
|
489
|
+
run,
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
493
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
494
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
495
|
+
} finally {
|
|
496
|
+
// Bun's process.env supports the `[key]: undefined` shape
|
|
497
|
+
// (biome's noDelete rule preferred this over `delete`).
|
|
498
|
+
if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
|
|
499
|
+
else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("body channel wins over PARACHUTE_INSTALL_CHANNEL env (hub#337)", async () => {
|
|
504
|
+
// Per-request override beats the platform default — the SPA's
|
|
505
|
+
// "install this one at latest even though the cluster's on rc" path.
|
|
506
|
+
setModuleInstallChannel(h.db, "latest");
|
|
507
|
+
const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
|
|
508
|
+
process.env.PARACHUTE_INSTALL_CHANNEL = "rc";
|
|
509
|
+
try {
|
|
510
|
+
const { supervisor } = makeIdleSupervisor();
|
|
511
|
+
const { run, calls } = alwaysOkRun();
|
|
512
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
513
|
+
await handleInstall(
|
|
514
|
+
postReqJson(
|
|
515
|
+
"/api/modules/vault/install",
|
|
516
|
+
{ authorization: `Bearer ${bearer}` },
|
|
517
|
+
{ channel: "latest" },
|
|
518
|
+
),
|
|
519
|
+
"vault",
|
|
520
|
+
{
|
|
521
|
+
db: h.db,
|
|
522
|
+
issuer: ISSUER,
|
|
523
|
+
manifestPath: h.manifestPath,
|
|
524
|
+
configDir: h.dir,
|
|
525
|
+
supervisor,
|
|
526
|
+
run,
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
530
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
531
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
532
|
+
} finally {
|
|
533
|
+
// Bun's process.env supports the `[key]: undefined` shape
|
|
534
|
+
// (biome's noDelete rule preferred this over `delete`).
|
|
535
|
+
if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
|
|
536
|
+
else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
test("garbage PARACHUTE_INSTALL_CHANNEL env falls back to hub_settings (no crash)", async () => {
|
|
541
|
+
// Operator typo at the platform layer shouldn't crash installs.
|
|
542
|
+
// Warns + falls through to the DB-stored channel.
|
|
543
|
+
setModuleInstallChannel(h.db, "rc");
|
|
544
|
+
const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
|
|
545
|
+
process.env.PARACHUTE_INSTALL_CHANNEL = "banana";
|
|
546
|
+
try {
|
|
547
|
+
const { supervisor } = makeIdleSupervisor();
|
|
548
|
+
const { run, calls } = alwaysOkRun();
|
|
549
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
550
|
+
const res = await handleInstall(
|
|
551
|
+
postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
|
|
552
|
+
"vault",
|
|
553
|
+
{
|
|
554
|
+
db: h.db,
|
|
555
|
+
issuer: ISSUER,
|
|
556
|
+
manifestPath: h.manifestPath,
|
|
557
|
+
configDir: h.dir,
|
|
558
|
+
supervisor,
|
|
559
|
+
run,
|
|
560
|
+
},
|
|
561
|
+
);
|
|
562
|
+
expect(res.status).toBe(202);
|
|
563
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
564
|
+
// Falls back to the DB-stored rc, not "@latest".
|
|
565
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
566
|
+
} finally {
|
|
567
|
+
// Bun's process.env supports the `[key]: undefined` shape
|
|
568
|
+
// (biome's noDelete rule preferred this over `delete`).
|
|
569
|
+
if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
|
|
570
|
+
else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
350
574
|
test("failed bun-add surfaces failed status on the operation", async () => {
|
|
351
575
|
const { supervisor } = makeIdleSupervisor();
|
|
352
576
|
// Run returns 1 + findGlobalInstall returns null = real failure.
|
|
@@ -490,6 +714,39 @@ describe("POST /api/modules/:short/upgrade", () => {
|
|
|
490
714
|
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
491
715
|
});
|
|
492
716
|
|
|
717
|
+
test("PARACHUTE_INSTALL_CHANNEL env cascades to upgrade too (hub#339 symmetry)", async () => {
|
|
718
|
+
// The Render-deploy operator sets PARACHUTE_INSTALL_CHANNEL=rc cluster-
|
|
719
|
+
// wide expecting BOTH install and upgrade through the admin SPA to
|
|
720
|
+
// honor it. Asymmetry between the two paths would surprise them.
|
|
721
|
+
setModuleInstallChannel(h.db, "latest"); // DB says latest
|
|
722
|
+
const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
|
|
723
|
+
process.env.PARACHUTE_INSTALL_CHANNEL = "rc"; // env says rc — should win
|
|
724
|
+
try {
|
|
725
|
+
const { supervisor } = makeIdleSupervisor();
|
|
726
|
+
await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
|
|
727
|
+
const { run, calls } = alwaysOkRun();
|
|
728
|
+
const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
|
|
729
|
+
await handleUpgrade(
|
|
730
|
+
postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
|
|
731
|
+
"vault",
|
|
732
|
+
{
|
|
733
|
+
db: h.db,
|
|
734
|
+
issuer: ISSUER,
|
|
735
|
+
manifestPath: h.manifestPath,
|
|
736
|
+
configDir: h.dir,
|
|
737
|
+
supervisor,
|
|
738
|
+
run,
|
|
739
|
+
},
|
|
740
|
+
);
|
|
741
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
742
|
+
expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
|
|
743
|
+
expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
|
|
744
|
+
} finally {
|
|
745
|
+
if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
|
|
746
|
+
else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
493
750
|
test("fails with 'try install first' when module is installed but never supervised", async () => {
|
|
494
751
|
// Module has a services.json row (e.g. seeded by `parachute install`
|
|
495
752
|
// pre-supervisor era) but the supervisor never spawned it.
|
|
@@ -680,7 +937,6 @@ describe("well-known regen after module ops", () => {
|
|
|
680
937
|
manifest: {
|
|
681
938
|
name: string;
|
|
682
939
|
manifestName: string;
|
|
683
|
-
kind: "api" | "frontend" | "tool";
|
|
684
940
|
port: number;
|
|
685
941
|
paths: string[];
|
|
686
942
|
health: string;
|
|
@@ -708,7 +964,6 @@ describe("well-known regen after module ops", () => {
|
|
|
708
964
|
const install = fakeInstall("@openparachute/vault", {
|
|
709
965
|
name: "vault",
|
|
710
966
|
manifestName: "parachute-vault",
|
|
711
|
-
kind: "api",
|
|
712
967
|
port: 1940,
|
|
713
968
|
paths: ["/vault/default"],
|
|
714
969
|
health: "/vault/default/health",
|
|
@@ -812,7 +1067,6 @@ describe("well-known regen after module ops", () => {
|
|
|
812
1067
|
const install = fakeInstall("@openparachute/vault", {
|
|
813
1068
|
name: "vault",
|
|
814
1069
|
manifestName: "parachute-vault",
|
|
815
|
-
kind: "api",
|
|
816
1070
|
port: 1940,
|
|
817
1071
|
paths: ["/vault/default"],
|
|
818
1072
|
health: "/vault/default/health",
|
|
@@ -902,7 +1156,6 @@ describe("well-known regen after module ops", () => {
|
|
|
902
1156
|
const install = fakeInstall("@openparachute/vault", {
|
|
903
1157
|
name: "vault",
|
|
904
1158
|
manifestName: "parachute-vault",
|
|
905
|
-
kind: "api",
|
|
906
1159
|
port: 1940,
|
|
907
1160
|
paths: ["/vault/default"],
|
|
908
1161
|
health: "/vault/default/health",
|