@openparachute/hub 0.6.3-rc.1 → 0.6.3-rc.3
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__/api-modules-ops.test.ts +121 -0
- package/src/__tests__/api-modules.test.ts +67 -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__/host-admin-token-validation.test.ts +218 -0
- 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 +148 -0
- package/src/__tests__/operator-token.test.ts +277 -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/api-modules-ops.ts +28 -2
- package/src/api-modules.ts +25 -2
- 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/host-admin-token-validation.ts +96 -0
- package/src/hub-control.ts +23 -162
- package/src/hub-server.ts +47 -3
- 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/operator-token.ts +96 -5
- package/src/origin-check.ts +10 -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
|
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
70
70
|
return signed.token;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
|
|
75
|
+
* operator token on loopback; after `expose` its `iss` is the public origin
|
|
76
|
+
* while the loopback request resolves the loopback issuer.
|
|
77
|
+
*/
|
|
78
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
79
|
+
const signed = await signAccessToken(h.db, {
|
|
80
|
+
sub: h.userId,
|
|
81
|
+
scopes,
|
|
82
|
+
audience: "operator",
|
|
83
|
+
clientId: "parachute-hub",
|
|
84
|
+
issuer: iss,
|
|
85
|
+
ttlSeconds: 3600,
|
|
86
|
+
});
|
|
87
|
+
recordTokenMint(h.db, {
|
|
88
|
+
jti: signed.jti,
|
|
89
|
+
createdVia: "operator_mint",
|
|
90
|
+
subject: "operator",
|
|
91
|
+
clientId: "parachute-hub",
|
|
92
|
+
scopes,
|
|
93
|
+
expiresAt: signed.expiresAt,
|
|
94
|
+
});
|
|
95
|
+
return signed.token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
|
|
99
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
100
|
+
/** A foreign origin the hub never answers on. */
|
|
101
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
102
|
+
|
|
73
103
|
function postReq(path: string, headers: Record<string, string>): Request {
|
|
74
104
|
return new Request(`http://localhost${path}`, { method: "POST", headers });
|
|
75
105
|
}
|
|
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
|
|
|
967
997
|
});
|
|
968
998
|
});
|
|
969
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* hub#516 — the module-ops `authorize` accepts the operator token's `iss`
|
|
1002
|
+
* against the SET of origins the hub answers on (knownIssuers), not just the
|
|
1003
|
+
* single per-request issuer. Exercised through `handleStop` (the simplest sync
|
|
1004
|
+
* op that goes through `authorize`). The per-request `issuer` here is loopback
|
|
1005
|
+
* (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
|
|
1006
|
+
* public expose-state origin.
|
|
1007
|
+
*/
|
|
1008
|
+
describe("operator-token iss validation against knownIssuers (hub#516)", () => {
|
|
1009
|
+
let h: Harness;
|
|
1010
|
+
beforeEach(async () => {
|
|
1011
|
+
h = await makeHarness();
|
|
1012
|
+
_resetOperationsRegistryForTests();
|
|
1013
|
+
});
|
|
1014
|
+
afterEach(() => h.cleanup());
|
|
1015
|
+
|
|
1016
|
+
// The live repro: operator token's iss = PUBLIC origin, loopback request
|
|
1017
|
+
// (per-request issuer = loopback), knownIssuers includes the public origin
|
|
1018
|
+
// → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
|
|
1019
|
+
test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
|
|
1020
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1021
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1022
|
+
const res = await handleStop(
|
|
1023
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1024
|
+
"vault",
|
|
1025
|
+
{
|
|
1026
|
+
db: h.db,
|
|
1027
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
1028
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1029
|
+
manifestPath: h.manifestPath,
|
|
1030
|
+
configDir: h.dir,
|
|
1031
|
+
supervisor,
|
|
1032
|
+
},
|
|
1033
|
+
);
|
|
1034
|
+
// Not a 401 — authorize passed. (stopped:false because nothing's running.)
|
|
1035
|
+
expect(res.status).toBe(200);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
|
|
1039
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1040
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
|
|
1041
|
+
const res = await handleStop(
|
|
1042
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1043
|
+
"vault",
|
|
1044
|
+
{
|
|
1045
|
+
db: h.db,
|
|
1046
|
+
issuer: ISSUER,
|
|
1047
|
+
knownIssuers: [ISSUER, "http://localhost:1939"],
|
|
1048
|
+
manifestPath: h.manifestPath,
|
|
1049
|
+
configDir: h.dir,
|
|
1050
|
+
supervisor,
|
|
1051
|
+
},
|
|
1052
|
+
);
|
|
1053
|
+
expect(res.status).toBe(200);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
|
|
1057
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1058
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
1059
|
+
const res = await handleStop(
|
|
1060
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1061
|
+
"vault",
|
|
1062
|
+
{
|
|
1063
|
+
db: h.db,
|
|
1064
|
+
issuer: ISSUER,
|
|
1065
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1066
|
+
manifestPath: h.manifestPath,
|
|
1067
|
+
configDir: h.dir,
|
|
1068
|
+
supervisor,
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
expect(res.status).toBe(401);
|
|
1072
|
+
const body = (await res.json()) as { error_description: string };
|
|
1073
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
|
|
1077
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1078
|
+
// Public-iss token, loopback per-request issuer, NO knownIssuers wired →
|
|
1079
|
+
// the strict single-issuer fallback rejects (the pre-fix behavior the
|
|
1080
|
+
// non-HTTP install path relies on).
|
|
1081
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1082
|
+
const res = await handleStop(
|
|
1083
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1084
|
+
"vault",
|
|
1085
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
1086
|
+
);
|
|
1087
|
+
expect(res.status).toBe(401);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
970
1091
|
describe("POST /api/modules/:short/restart", () => {
|
|
971
1092
|
let h: Harness;
|
|
972
1093
|
beforeEach(async () => {
|
|
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
63
63
|
return signed.token;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
|
|
67
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
68
|
+
/** A foreign origin the hub never answers on (hub#516). */
|
|
69
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
70
|
+
|
|
71
|
+
/** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
|
|
72
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
73
|
+
const signed = await signAccessToken(h.db, {
|
|
74
|
+
sub: h.userId,
|
|
75
|
+
scopes,
|
|
76
|
+
audience: "operator",
|
|
77
|
+
clientId: "parachute-hub",
|
|
78
|
+
issuer: iss,
|
|
79
|
+
ttlSeconds: 3600,
|
|
80
|
+
});
|
|
81
|
+
recordTokenMint(h.db, {
|
|
82
|
+
jti: signed.jti,
|
|
83
|
+
createdVia: "operator_mint",
|
|
84
|
+
subject: "operator",
|
|
85
|
+
clientId: "parachute-hub",
|
|
86
|
+
scopes,
|
|
87
|
+
expiresAt: signed.expiresAt,
|
|
88
|
+
});
|
|
89
|
+
return signed.token;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
function writeManifest(path: string, services: unknown[]): void {
|
|
67
93
|
writeFileSync(path, JSON.stringify({ services }));
|
|
68
94
|
}
|
|
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
|
|
|
147
173
|
expect(body.error).toBe("insufficient_scope");
|
|
148
174
|
});
|
|
149
175
|
|
|
176
|
+
// hub#516: `parachute status` reads /api/modules on loopback presenting the
|
|
177
|
+
// operator token, whose `iss` is the PUBLIC origin after `expose`. The
|
|
178
|
+
// host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
|
|
179
|
+
// expose-state public ∪ env), not the single per-request loopback issuer.
|
|
180
|
+
test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
|
|
181
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
182
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
183
|
+
db: h.db,
|
|
184
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
185
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
186
|
+
manifestPath: h.manifestPath,
|
|
187
|
+
fetchLatestVersion: async () => null,
|
|
188
|
+
});
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
|
|
193
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
194
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
195
|
+
db: h.db,
|
|
196
|
+
issuer: ISSUER,
|
|
197
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
198
|
+
manifestPath: h.manifestPath,
|
|
199
|
+
fetchLatestVersion: async () => null,
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(401);
|
|
202
|
+
const body = (await res.json()) as { error_description: string };
|
|
203
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
|
|
207
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
208
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
209
|
+
db: h.db,
|
|
210
|
+
issuer: ISSUER, // no knownIssuers → falls back to [issuer]
|
|
211
|
+
manifestPath: h.manifestPath,
|
|
212
|
+
fetchLatestVersion: async () => null,
|
|
213
|
+
});
|
|
214
|
+
expect(res.status).toBe(401);
|
|
215
|
+
});
|
|
216
|
+
|
|
150
217
|
test("200 + curated list on fresh container (empty services.json)", async () => {
|
|
151
218
|
// The v0.6 hot path: brand-new Render container, no services.json
|
|
152
219
|
// yet. UI must render "install vault / scribe" cards even though
|
|
@@ -15,9 +15,12 @@ import {
|
|
|
15
15
|
exposeCloudflareOff,
|
|
16
16
|
exposeCloudflareUp,
|
|
17
17
|
} from "../commands/expose-cloudflare.ts";
|
|
18
|
+
import type { ExposeSupervisorOpts } from "../commands/expose-supervisor.ts";
|
|
18
19
|
import { readEnvFileValues } from "../env-file.ts";
|
|
19
20
|
import { readExposeState } from "../expose-state.ts";
|
|
20
21
|
import { writeHubPort } from "../hub-control.ts";
|
|
22
|
+
import type { EnsureHubUnitOpts } from "../hub-unit.ts";
|
|
23
|
+
import { type ModuleOp, ModuleOpHttpError } from "../module-ops-client.ts";
|
|
21
24
|
import type { CommandResult, Runner } from "../tailscale/run.ts";
|
|
22
25
|
|
|
23
26
|
// Default seeded hub port used by tests with `skipHub: true`. The cloudflared
|
|
@@ -283,23 +286,22 @@ describe("exposeCloudflareUp", () => {
|
|
|
283
286
|
}
|
|
284
287
|
});
|
|
285
288
|
|
|
286
|
-
test("persists the public hub origin to vault/.env + restarts vault (Cloudflare 401 fix)", async () => {
|
|
289
|
+
test("persists the public hub origin to vault/.env + restarts vault via the supervisor (Cloudflare 401 fix)", async () => {
|
|
287
290
|
// The Cloudflare 401 P0: the cloudflare path wrote expose-state.json but —
|
|
288
291
|
// unlike the Tailscale path, which auto-restarts vault and so flows the
|
|
289
|
-
// public origin into vault/.env
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
292
|
+
// public origin into vault/.env — never touched vault's .env or restarted
|
|
293
|
+
// it. The launchd/systemd daemon kept booting vault with NO
|
|
294
|
+
// PARACHUTE_HUB_ORIGIN → vault fell back to loopback as its expected issuer →
|
|
295
|
+
// every hub-minted token (iss=public) failed the iss check → 401.
|
|
296
|
+
//
|
|
297
|
+
// Phase 5b: the restart goes through the running Supervisor
|
|
298
|
+
// (`driveModuleOp("vault", "restart")`) — the helper also persists the
|
|
299
|
+
// durable .env. This asserts the durable .env write + the supervised restart
|
|
300
|
+
// (no longer a pidfile-gated detached restart; the supervisor decides
|
|
301
|
+
// liveness, and a not-supervised vault surfaces as a tolerated 404 — see the
|
|
302
|
+
// Phase 4 dual-dispatch suite).
|
|
295
303
|
const env = makeEnv();
|
|
296
304
|
try {
|
|
297
|
-
// Seed vault as "running" so the restart branch fires. PID lives at
|
|
298
|
-
// <configDir>/vault/run/vault.pid (see process-state.ts:pidPath).
|
|
299
|
-
const vaultRun = join(env.configDir, "vault", "run");
|
|
300
|
-
require("node:fs").mkdirSync(vaultRun, { recursive: true });
|
|
301
|
-
writeFileSync(join(vaultRun, "vault.pid"), "99001");
|
|
302
|
-
|
|
303
305
|
const uuid = "ffffffff-0000-0000-0000-000000000006";
|
|
304
306
|
const { runner } = queueRunner([
|
|
305
307
|
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
@@ -312,14 +314,12 @@ describe("exposeCloudflareUp", () => {
|
|
|
312
314
|
{ code: 0, stdout: "", stderr: "" },
|
|
313
315
|
]);
|
|
314
316
|
const { spawner } = fakeSpawner(42300);
|
|
315
|
-
const
|
|
317
|
+
const sup = makeCfSupervisorStub();
|
|
316
318
|
|
|
317
319
|
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
318
320
|
runner,
|
|
319
321
|
spawner,
|
|
320
|
-
|
|
321
|
-
// "running" and the restart branch executes.
|
|
322
|
-
alive: (pid) => pid === 99001,
|
|
322
|
+
alive: () => false,
|
|
323
323
|
kill: () => {},
|
|
324
324
|
log: () => {},
|
|
325
325
|
manifestPath: env.manifestPath,
|
|
@@ -330,10 +330,7 @@ describe("exposeCloudflareUp", () => {
|
|
|
330
330
|
cloudflaredHome: env.cloudflaredHome,
|
|
331
331
|
configDir: env.configDir,
|
|
332
332
|
skipHub: true,
|
|
333
|
-
|
|
334
|
-
restarted.push(short);
|
|
335
|
-
return 0;
|
|
336
|
-
},
|
|
333
|
+
supervisor: sup.opts,
|
|
337
334
|
});
|
|
338
335
|
|
|
339
336
|
expect(code).toBe(0);
|
|
@@ -342,57 +339,8 @@ describe("exposeCloudflareUp", () => {
|
|
|
342
339
|
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
343
340
|
"https://gitcoin-parachute.unforced.dev",
|
|
344
341
|
);
|
|
345
|
-
// Live half:
|
|
346
|
-
expect(
|
|
347
|
-
} finally {
|
|
348
|
-
env.cleanup();
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
test("persists vault/.env but does NOT restart when vault isn't running", async () => {
|
|
353
|
-
// No vault pidfile → processState() !== "running" → no restart, but the
|
|
354
|
-
// durable .env write still happens so the next daemon boot is correct.
|
|
355
|
-
const env = makeEnv();
|
|
356
|
-
try {
|
|
357
|
-
const uuid = "ffffffff-0000-0000-0000-000000000007";
|
|
358
|
-
const { runner } = queueRunner([
|
|
359
|
-
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
360
|
-
{ code: 0, stdout: "[]", stderr: "" },
|
|
361
|
-
{
|
|
362
|
-
code: 0,
|
|
363
|
-
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
364
|
-
stderr: "",
|
|
365
|
-
},
|
|
366
|
-
{ code: 0, stdout: "", stderr: "" },
|
|
367
|
-
]);
|
|
368
|
-
const { spawner } = fakeSpawner(42301);
|
|
369
|
-
const restarted: string[] = [];
|
|
370
|
-
|
|
371
|
-
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
372
|
-
runner,
|
|
373
|
-
spawner,
|
|
374
|
-
alive: () => false,
|
|
375
|
-
kill: () => {},
|
|
376
|
-
log: () => {},
|
|
377
|
-
manifestPath: env.manifestPath,
|
|
378
|
-
statePath: env.statePath,
|
|
379
|
-
exposeStatePath: env.exposeStatePath,
|
|
380
|
-
configPath: env.configPath,
|
|
381
|
-
logPath: env.logPath,
|
|
382
|
-
cloudflaredHome: env.cloudflaredHome,
|
|
383
|
-
configDir: env.configDir,
|
|
384
|
-
skipHub: true,
|
|
385
|
-
restartService: async (short) => {
|
|
386
|
-
restarted.push(short);
|
|
387
|
-
return 0;
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
expect(code).toBe(0);
|
|
392
|
-
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
393
|
-
"https://gitcoin-parachute.unforced.dev",
|
|
394
|
-
);
|
|
395
|
-
expect(restarted).toEqual([]);
|
|
342
|
+
// Live half: vault is restarted via the supervisor to re-read the new origin.
|
|
343
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
396
344
|
} finally {
|
|
397
345
|
env.cleanup();
|
|
398
346
|
}
|
|
@@ -2017,3 +1965,146 @@ describe("reboot-persistent connector service wiring", () => {
|
|
|
2017
1965
|
}
|
|
2018
1966
|
});
|
|
2019
1967
|
});
|
|
1968
|
+
|
|
1969
|
+
// ---------------------------------------------------------------------------
|
|
1970
|
+
// Phase 4 dual-dispatch (design §4.3): unit-managed → "ensure the hub" ensures
|
|
1971
|
+
// the UNIT (not a detached spawn) and the post-route vault restart drives the
|
|
1972
|
+
// running Supervisor over the loopback module-ops API (firing the operator-
|
|
1973
|
+
// token self-heal). The cloudflared CONNECTOR unit is unchanged. The no-unit
|
|
1974
|
+
// arm keeps today's `persistVaultHubOrigin` + `restartService` behavior.
|
|
1975
|
+
// ---------------------------------------------------------------------------
|
|
1976
|
+
|
|
1977
|
+
interface CfExposeSupervisorStub {
|
|
1978
|
+
opts: ExposeSupervisorOpts;
|
|
1979
|
+
ensureCalls: number;
|
|
1980
|
+
driveCalls: Array<{ short: string; op: ModuleOp }>;
|
|
1981
|
+
selfHealCalls: Array<{ issuer: string }>;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
function makeCfSupervisorStub(opts?: {
|
|
1985
|
+
ensureOutcome?: "already-up" | "started" | "no-manager";
|
|
1986
|
+
driveThrows?: () => unknown;
|
|
1987
|
+
}): CfExposeSupervisorStub {
|
|
1988
|
+
const stub: CfExposeSupervisorStub = {
|
|
1989
|
+
ensureCalls: 0,
|
|
1990
|
+
driveCalls: [],
|
|
1991
|
+
selfHealCalls: [],
|
|
1992
|
+
opts: {
|
|
1993
|
+
openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
|
|
1994
|
+
ensureHubUnit: async (o: EnsureHubUnitOpts) => {
|
|
1995
|
+
stub.ensureCalls++;
|
|
1996
|
+
return { outcome: opts?.ensureOutcome ?? "already-up", port: o.port ?? 1939, messages: [] };
|
|
1997
|
+
},
|
|
1998
|
+
driveModuleOp: async (short, op) => {
|
|
1999
|
+
stub.driveCalls.push({ short, op });
|
|
2000
|
+
if (opts?.driveThrows) throw opts.driveThrows();
|
|
2001
|
+
return { status: 200, body: { short, state: { status: "running" } } };
|
|
2002
|
+
},
|
|
2003
|
+
selfHealOperatorTokenIssuer: async (_db, o) => {
|
|
2004
|
+
stub.selfHealCalls.push({ issuer: o.issuer });
|
|
2005
|
+
return { kind: "fresh" };
|
|
2006
|
+
},
|
|
2007
|
+
},
|
|
2008
|
+
};
|
|
2009
|
+
return stub;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
describe("Phase 4 cloudflare expose dual-dispatch — unit-managed", () => {
|
|
2013
|
+
test("unit-managed → ensureHubUnit (not detached) + supervised vault restart + operator-token self-heal", async () => {
|
|
2014
|
+
const env = makeEnv();
|
|
2015
|
+
try {
|
|
2016
|
+
const uuid = "ffffffff-0000-0000-0000-0000000000a4";
|
|
2017
|
+
const { runner } = queueRunner([
|
|
2018
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" }, // --version
|
|
2019
|
+
{ code: 0, stdout: "[]", stderr: "" }, // tunnel list
|
|
2020
|
+
{
|
|
2021
|
+
code: 0,
|
|
2022
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
2023
|
+
stderr: "",
|
|
2024
|
+
}, // tunnel create
|
|
2025
|
+
{ code: 0, stdout: "", stderr: "" }, // route dns
|
|
2026
|
+
]);
|
|
2027
|
+
const { spawner } = fakeSpawner(42400);
|
|
2028
|
+
const sup = makeCfSupervisorStub();
|
|
2029
|
+
const logs: string[] = [];
|
|
2030
|
+
|
|
2031
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
2032
|
+
runner,
|
|
2033
|
+
spawner,
|
|
2034
|
+
alive: () => false,
|
|
2035
|
+
kill: () => {},
|
|
2036
|
+
log: (l) => logs.push(l),
|
|
2037
|
+
manifestPath: env.manifestPath,
|
|
2038
|
+
statePath: env.statePath,
|
|
2039
|
+
exposeStatePath: env.exposeStatePath,
|
|
2040
|
+
configPath: env.configPath,
|
|
2041
|
+
logPath: env.logPath,
|
|
2042
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
2043
|
+
configDir: env.configDir,
|
|
2044
|
+
// No skipHub → the hub-unit ensure runs (via the stub, no real hub).
|
|
2045
|
+
// Inert connector install so the cloudflared connector path is unchanged.
|
|
2046
|
+
installService: () => ({ outcome: "fallback", messages: [] }),
|
|
2047
|
+
supervisor: sup.opts,
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
expect(code).toBe(0);
|
|
2051
|
+
// Ensured the hub UNIT (the stub), never a detached spawn.
|
|
2052
|
+
expect(sup.ensureCalls).toBe(1);
|
|
2053
|
+
// Vault restart drove the running Supervisor.
|
|
2054
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
2055
|
+
// Operator-token issuer self-heal fired toward the public origin.
|
|
2056
|
+
expect(sup.selfHealCalls).toHaveLength(1);
|
|
2057
|
+
expect(sup.selfHealCalls[0]?.issuer).toBe("https://gitcoin-parachute.unforced.dev");
|
|
2058
|
+
// Durable .env still written (the helper persists it for vault).
|
|
2059
|
+
expect(readEnvFileValues(join(env.configDir, "vault", ".env")).PARACHUTE_HUB_ORIGIN).toBe(
|
|
2060
|
+
"https://gitcoin-parachute.unforced.dev",
|
|
2061
|
+
);
|
|
2062
|
+
expect(logs.join("\n")).toMatch(/hub unit up/);
|
|
2063
|
+
} finally {
|
|
2064
|
+
env.cleanup();
|
|
2065
|
+
}
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
test("unit-managed: a not_supervised vault (404) is not a failure", async () => {
|
|
2069
|
+
const env = makeEnv();
|
|
2070
|
+
try {
|
|
2071
|
+
const uuid = "ffffffff-0000-0000-0000-0000000000a5";
|
|
2072
|
+
const { runner } = queueRunner([
|
|
2073
|
+
{ code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
|
|
2074
|
+
{ code: 0, stdout: "[]", stderr: "" },
|
|
2075
|
+
{
|
|
2076
|
+
code: 0,
|
|
2077
|
+
stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
|
|
2078
|
+
stderr: "",
|
|
2079
|
+
},
|
|
2080
|
+
{ code: 0, stdout: "", stderr: "" },
|
|
2081
|
+
]);
|
|
2082
|
+
const { spawner } = fakeSpawner(42401);
|
|
2083
|
+
const sup = makeCfSupervisorStub({
|
|
2084
|
+
driveThrows: () => new ModuleOpHttpError(404, "not_supervised", "vault is not supervised"),
|
|
2085
|
+
});
|
|
2086
|
+
|
|
2087
|
+
const code = await exposeCloudflareUp("gitcoin-parachute.unforced.dev", {
|
|
2088
|
+
runner,
|
|
2089
|
+
spawner,
|
|
2090
|
+
alive: () => false,
|
|
2091
|
+
kill: () => {},
|
|
2092
|
+
log: () => {},
|
|
2093
|
+
manifestPath: env.manifestPath,
|
|
2094
|
+
statePath: env.statePath,
|
|
2095
|
+
exposeStatePath: env.exposeStatePath,
|
|
2096
|
+
configPath: env.configPath,
|
|
2097
|
+
logPath: env.logPath,
|
|
2098
|
+
cloudflaredHome: env.cloudflaredHome,
|
|
2099
|
+
configDir: env.configDir,
|
|
2100
|
+
installService: () => ({ outcome: "fallback", messages: [] }),
|
|
2101
|
+
supervisor: sup.opts,
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
expect(code).toBe(0);
|
|
2105
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
2106
|
+
} finally {
|
|
2107
|
+
env.cleanup();
|
|
2108
|
+
}
|
|
2109
|
+
});
|
|
2110
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import type { CloudflaredState } from "../cloudflare/state.ts";
|
|
3
|
+
import type { ExposeCloudflareOpts } from "../commands/expose-cloudflare.ts";
|
|
3
4
|
import {
|
|
4
5
|
type ExposePublicOffAutoOpts,
|
|
5
6
|
runExposePublicOffAutoDetect,
|
|
@@ -50,6 +51,7 @@ interface Harness {
|
|
|
50
51
|
prompts: string[];
|
|
51
52
|
tailscaleCalls: number;
|
|
52
53
|
cloudflareCalls: number;
|
|
54
|
+
cloudflareOpts: ExposeCloudflareOpts[];
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
function makeHarness(
|
|
@@ -70,6 +72,7 @@ function makeHarness(
|
|
|
70
72
|
prompts: [],
|
|
71
73
|
tailscaleCalls: 0,
|
|
72
74
|
cloudflareCalls: 0,
|
|
75
|
+
cloudflareOpts: [],
|
|
73
76
|
};
|
|
74
77
|
const answers = [...(input.promptAnswers ?? [])];
|
|
75
78
|
let i = 0;
|
|
@@ -88,8 +91,9 @@ function makeHarness(
|
|
|
88
91
|
harness.tailscaleCalls++;
|
|
89
92
|
return input.tsExitCode ?? 0;
|
|
90
93
|
},
|
|
91
|
-
exposeCloudflareOffImpl: async () => {
|
|
94
|
+
exposeCloudflareOffImpl: async (cfOpts) => {
|
|
92
95
|
harness.cloudflareCalls++;
|
|
96
|
+
harness.cloudflareOpts.push(cfOpts);
|
|
93
97
|
return input.cfExitCode ?? 0;
|
|
94
98
|
},
|
|
95
99
|
};
|
|
@@ -273,3 +277,24 @@ describe("runExposePublicOffAutoDetect — both live (non-TTY)", () => {
|
|
|
273
277
|
expect(harness.logs).toContain("(non-TTY: tearing down both.)");
|
|
274
278
|
});
|
|
275
279
|
});
|
|
280
|
+
|
|
281
|
+
describe("runExposePublicOffAutoDetect — supervisor threading (Phase 4 consistency)", () => {
|
|
282
|
+
test("cloudflareOffOpts.supervisor reaches the cloudflare teardown leg", async () => {
|
|
283
|
+
// cli.ts threads `cloudflareOffOpts: { supervisor: {} }` into the
|
|
284
|
+
// auto-detect off path so the Phase 4 supervisor resolution is consistent
|
|
285
|
+
// across both providers (matching the explicit `--cloudflare off` branch).
|
|
286
|
+
// Assert the supervisor block survives the spread-with-tunnelName wrapper
|
|
287
|
+
// and arrives at the leaf cloudflare-off impl.
|
|
288
|
+
const { harness, opts } = makeHarness({ cfState: cloudflaredState() });
|
|
289
|
+
const code = await runExposePublicOffAutoDetect({
|
|
290
|
+
...opts,
|
|
291
|
+
cloudflareOffOpts: { supervisor: {} },
|
|
292
|
+
});
|
|
293
|
+
expect(code).toBe(0);
|
|
294
|
+
expect(harness.cloudflareCalls).toBe(1);
|
|
295
|
+
expect(harness.cloudflareOpts).toHaveLength(1);
|
|
296
|
+
expect(harness.cloudflareOpts[0]?.supervisor).toEqual({});
|
|
297
|
+
// The per-record wrapper still stamps the tunnelName onto the leaf opts.
|
|
298
|
+
expect(harness.cloudflareOpts[0]?.tunnelName).toBe("vault-tunnel");
|
|
299
|
+
});
|
|
300
|
+
});
|