@openparachute/hub 0.6.2 → 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__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -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__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +416 -1448
- package/src/__tests__/managed-unit.test.ts +575 -0
- 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__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +504 -0
- package/src/__tests__/status.test.ts +157 -708
- package/src/__tests__/supervisor.test.ts +471 -6
- 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 +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +97 -12
- package/src/cloudflare/connector-service.ts +117 -322
- 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/init.ts +225 -12
- package/src/commands/lifecycle.ts +455 -816
- package/src/commands/migrate-cutover.ts +837 -0
- package/src/commands/migrate.ts +71 -2
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +535 -235
- 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 +39 -0
- package/src/hub-unit.ts +735 -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 +692 -0
- package/src/migrate-offer.ts +186 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/process-state.ts +19 -3
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +389 -38
- 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
|
@@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import type { ExposeSupervisorOpts } from "../commands/expose-supervisor.ts";
|
|
5
6
|
import { exposePublic, exposeTailnet } from "../commands/expose.ts";
|
|
6
7
|
import { readEnvFileValues } from "../env-file.ts";
|
|
7
8
|
import { readExposeState, writeExposeState } from "../expose-state.ts";
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
9
|
+
import type { EnsureHubUnitOpts } from "../hub-unit.ts";
|
|
10
|
+
import { type ModuleOp, ModuleOpHttpError } from "../module-ops-client.ts";
|
|
10
11
|
import { upsertService } from "../services-manifest.ts";
|
|
11
12
|
import type { Runner } from "../tailscale/run.ts";
|
|
12
13
|
|
|
@@ -54,36 +55,16 @@ function makeRunner(): { runner: Runner; calls: string[][] } {
|
|
|
54
55
|
return { runner, calls };
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
/** Default hub overrides for expose tests — no real subprocess, no sleep. */
|
|
69
|
-
function hubEnsureOpts(
|
|
70
|
-
spawner: HubSpawner,
|
|
71
|
-
): Omit<EnsureHubOpts, "configDir" | "wellKnownDir" | "log"> {
|
|
72
|
-
return {
|
|
73
|
-
spawner,
|
|
74
|
-
alive: () => true,
|
|
75
|
-
probe: async () => true,
|
|
76
|
-
readyWaitMs: 0,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function hubStopOpts(): Omit<StopHubOpts, "configDir" | "log"> {
|
|
81
|
-
return {
|
|
82
|
-
kill: () => {},
|
|
83
|
-
alive: () => false,
|
|
84
|
-
sleep: async () => {},
|
|
85
|
-
now: () => 0,
|
|
86
|
-
};
|
|
58
|
+
/**
|
|
59
|
+
* A minimal expose `supervisor` seam that forces the unit-installed arm with a
|
|
60
|
+
* benign `ensureHubUnit → already-up` + no-op `driveModuleOp` / self-heal. Most
|
|
61
|
+
* expose tests just need the hub-bringup to succeed so they can assert the
|
|
62
|
+
* tailscale plan / expose-state / well-known behavior; this stub provides that
|
|
63
|
+
* without a real launchd/systemd or hub. (Phase 5b retired the detached hub
|
|
64
|
+
* spawner that the old `makeHubSpawner` / `hubEnsureOpts` stubs simulated.)
|
|
65
|
+
*/
|
|
66
|
+
function supervisorUp(): ExposeSupervisorOpts {
|
|
67
|
+
return makeExposeSupervisorStub().opts;
|
|
87
68
|
}
|
|
88
69
|
|
|
89
70
|
function seedServices(path: string): void {
|
|
@@ -122,7 +103,6 @@ describe("expose tailnet up", () => {
|
|
|
122
103
|
try {
|
|
123
104
|
seedServices(h.manifestPath);
|
|
124
105
|
const { runner, calls } = makeRunner();
|
|
125
|
-
const { spawner } = makeHubSpawner(1111);
|
|
126
106
|
const logs: string[] = [];
|
|
127
107
|
const code = await exposeTailnet("up", {
|
|
128
108
|
runner,
|
|
@@ -130,9 +110,8 @@ describe("expose tailnet up", () => {
|
|
|
130
110
|
statePath: h.statePath,
|
|
131
111
|
wellKnownPath: h.wellKnownPath,
|
|
132
112
|
hubPath: h.hubPath,
|
|
133
|
-
wellKnownDir: h.wellKnownDir,
|
|
134
113
|
configDir: h.configDir,
|
|
135
|
-
|
|
114
|
+
supervisor: supervisorUp(),
|
|
136
115
|
servicePortProbe: allServicesUp,
|
|
137
116
|
log: (l) => logs.push(l),
|
|
138
117
|
});
|
|
@@ -172,31 +151,31 @@ describe("expose tailnet up", () => {
|
|
|
172
151
|
}
|
|
173
152
|
});
|
|
174
153
|
|
|
175
|
-
test("
|
|
154
|
+
test("ensures the hub UNIT (not a detached spawn) before exposing", async () => {
|
|
155
|
+
// Phase 5b: "ensure the hub" = ensure the hub UNIT is up (§4.3a). The old
|
|
156
|
+
// detached `bun hub-server.ts --port …` spawn is retired — its bringup is now
|
|
157
|
+
// init's `installAndStartHubUnit` (asserted in init.test.ts / hub-unit.test.ts).
|
|
158
|
+
// Here we just confirm expose drives the unit-ensure seam, then proceeds.
|
|
176
159
|
const h = makeHarness();
|
|
177
160
|
try {
|
|
178
161
|
seedServices(h.manifestPath);
|
|
179
162
|
const { runner } = makeRunner();
|
|
180
|
-
const
|
|
163
|
+
const sup = makeExposeSupervisorStub();
|
|
164
|
+
const logs: string[] = [];
|
|
181
165
|
const code = await exposeTailnet("up", {
|
|
182
166
|
runner,
|
|
183
167
|
manifestPath: h.manifestPath,
|
|
184
168
|
statePath: h.statePath,
|
|
185
169
|
wellKnownPath: h.wellKnownPath,
|
|
186
170
|
hubPath: h.hubPath,
|
|
187
|
-
wellKnownDir: h.wellKnownDir,
|
|
188
171
|
configDir: h.configDir,
|
|
189
|
-
|
|
172
|
+
supervisor: sup.opts,
|
|
190
173
|
servicePortProbe: allServicesUp,
|
|
191
|
-
log: () =>
|
|
174
|
+
log: (l) => logs.push(l),
|
|
192
175
|
});
|
|
193
176
|
expect(code).toBe(0);
|
|
194
|
-
expect(
|
|
195
|
-
|
|
196
|
-
expect(cmd[0]).toBe("bun");
|
|
197
|
-
expect(cmd).toContain("--port");
|
|
198
|
-
expect(cmd).toContain("--well-known-dir");
|
|
199
|
-
expect(cmd).toContain(h.wellKnownDir);
|
|
177
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
178
|
+
expect(logs.join("\n")).toMatch(/hub unit up/);
|
|
200
179
|
} finally {
|
|
201
180
|
h.cleanup();
|
|
202
181
|
}
|
|
@@ -220,16 +199,14 @@ describe("expose tailnet up", () => {
|
|
|
220
199
|
h.manifestPath,
|
|
221
200
|
);
|
|
222
201
|
const { runner, calls } = makeRunner();
|
|
223
|
-
const { spawner } = makeHubSpawner(1111);
|
|
224
202
|
const code = await exposeTailnet("up", {
|
|
225
203
|
runner,
|
|
226
204
|
manifestPath: h.manifestPath,
|
|
227
205
|
statePath: h.statePath,
|
|
228
206
|
wellKnownPath: h.wellKnownPath,
|
|
229
207
|
hubPath: h.hubPath,
|
|
230
|
-
wellKnownDir: h.wellKnownDir,
|
|
231
208
|
configDir: h.configDir,
|
|
232
|
-
|
|
209
|
+
supervisor: supervisorUp(),
|
|
233
210
|
servicePortProbe: allServicesUp,
|
|
234
211
|
log: () => {},
|
|
235
212
|
});
|
|
@@ -258,7 +235,6 @@ describe("expose tailnet up", () => {
|
|
|
258
235
|
h.manifestPath,
|
|
259
236
|
);
|
|
260
237
|
const { runner, calls } = makeRunner();
|
|
261
|
-
const { spawner } = makeHubSpawner(1111);
|
|
262
238
|
const logs: string[] = [];
|
|
263
239
|
const code = await exposeTailnet("up", {
|
|
264
240
|
runner,
|
|
@@ -266,9 +242,8 @@ describe("expose tailnet up", () => {
|
|
|
266
242
|
statePath: h.statePath,
|
|
267
243
|
wellKnownPath: h.wellKnownPath,
|
|
268
244
|
hubPath: h.hubPath,
|
|
269
|
-
wellKnownDir: h.wellKnownDir,
|
|
270
245
|
configDir: h.configDir,
|
|
271
|
-
|
|
246
|
+
supervisor: supervisorUp(),
|
|
272
247
|
servicePortProbe: allServicesUp,
|
|
273
248
|
log: (l) => logs.push(l),
|
|
274
249
|
});
|
|
@@ -291,7 +266,6 @@ describe("expose tailnet up", () => {
|
|
|
291
266
|
const h = makeHarness();
|
|
292
267
|
try {
|
|
293
268
|
const { runner } = makeRunner();
|
|
294
|
-
const { spawner } = makeHubSpawner(1111);
|
|
295
269
|
const logs: string[] = [];
|
|
296
270
|
const code = await exposeTailnet("up", {
|
|
297
271
|
runner,
|
|
@@ -299,9 +273,8 @@ describe("expose tailnet up", () => {
|
|
|
299
273
|
statePath: h.statePath,
|
|
300
274
|
wellKnownPath: h.wellKnownPath,
|
|
301
275
|
hubPath: h.hubPath,
|
|
302
|
-
wellKnownDir: h.wellKnownDir,
|
|
303
276
|
configDir: h.configDir,
|
|
304
|
-
|
|
277
|
+
supervisor: supervisorUp(),
|
|
305
278
|
servicePortProbe: allServicesUp,
|
|
306
279
|
log: (l) => logs.push(l),
|
|
307
280
|
});
|
|
@@ -319,7 +292,6 @@ describe("expose tailnet up", () => {
|
|
|
319
292
|
const runner: Runner = async () => {
|
|
320
293
|
throw new Error("spawn tailscale ENOENT");
|
|
321
294
|
};
|
|
322
|
-
const { spawner } = makeHubSpawner(1111);
|
|
323
295
|
const logs: string[] = [];
|
|
324
296
|
const code = await exposeTailnet("up", {
|
|
325
297
|
runner,
|
|
@@ -327,9 +299,8 @@ describe("expose tailnet up", () => {
|
|
|
327
299
|
statePath: h.statePath,
|
|
328
300
|
wellKnownPath: h.wellKnownPath,
|
|
329
301
|
hubPath: h.hubPath,
|
|
330
|
-
wellKnownDir: h.wellKnownDir,
|
|
331
302
|
configDir: h.configDir,
|
|
332
|
-
|
|
303
|
+
supervisor: supervisorUp(),
|
|
333
304
|
servicePortProbe: allServicesUp,
|
|
334
305
|
log: (l) => logs.push(l),
|
|
335
306
|
});
|
|
@@ -364,16 +335,14 @@ describe("expose tailnet up", () => {
|
|
|
364
335
|
h.statePath,
|
|
365
336
|
);
|
|
366
337
|
const { runner, calls } = makeRunner();
|
|
367
|
-
const { spawner } = makeHubSpawner(1111);
|
|
368
338
|
const code = await exposeTailnet("up", {
|
|
369
339
|
runner,
|
|
370
340
|
manifestPath: h.manifestPath,
|
|
371
341
|
statePath: h.statePath,
|
|
372
342
|
wellKnownPath: h.wellKnownPath,
|
|
373
343
|
hubPath: h.hubPath,
|
|
374
|
-
wellKnownDir: h.wellKnownDir,
|
|
375
344
|
configDir: h.configDir,
|
|
376
|
-
|
|
345
|
+
supervisor: supervisorUp(),
|
|
377
346
|
servicePortProbe: allServicesUp,
|
|
378
347
|
log: () => {},
|
|
379
348
|
});
|
|
@@ -395,7 +364,6 @@ describe("expose tailnet up", () => {
|
|
|
395
364
|
try {
|
|
396
365
|
seedServices(h.manifestPath);
|
|
397
366
|
const { runner, calls } = makeRunner();
|
|
398
|
-
const { spawner } = makeHubSpawner(1111);
|
|
399
367
|
const logs: string[] = [];
|
|
400
368
|
const code = await exposeTailnet("up", {
|
|
401
369
|
runner,
|
|
@@ -403,9 +371,8 @@ describe("expose tailnet up", () => {
|
|
|
403
371
|
statePath: h.statePath,
|
|
404
372
|
wellKnownPath: h.wellKnownPath,
|
|
405
373
|
hubPath: h.hubPath,
|
|
406
|
-
wellKnownDir: h.wellKnownDir,
|
|
407
374
|
configDir: h.configDir,
|
|
408
|
-
|
|
375
|
+
supervisor: supervisorUp(),
|
|
409
376
|
// vault is up; notes is down.
|
|
410
377
|
servicePortProbe: async (port) => port === 1940,
|
|
411
378
|
log: (l) => logs.push(l),
|
|
@@ -444,7 +411,6 @@ describe("expose tailnet up", () => {
|
|
|
444
411
|
}
|
|
445
412
|
return { code: 0, stdout: "", stderr: "" };
|
|
446
413
|
};
|
|
447
|
-
const { spawner } = makeHubSpawner(1111);
|
|
448
414
|
const logs: string[] = [];
|
|
449
415
|
const code = await exposeTailnet("up", {
|
|
450
416
|
runner,
|
|
@@ -452,9 +418,8 @@ describe("expose tailnet up", () => {
|
|
|
452
418
|
statePath: h.statePath,
|
|
453
419
|
wellKnownPath: h.wellKnownPath,
|
|
454
420
|
hubPath: h.hubPath,
|
|
455
|
-
wellKnownDir: h.wellKnownDir,
|
|
456
421
|
configDir: h.configDir,
|
|
457
|
-
|
|
422
|
+
supervisor: supervisorUp(),
|
|
458
423
|
servicePortProbe: allServicesUp,
|
|
459
424
|
log: (l) => logs.push(l),
|
|
460
425
|
});
|
|
@@ -473,16 +438,14 @@ describe("expose tailnet up", () => {
|
|
|
473
438
|
try {
|
|
474
439
|
seedServices(h.manifestPath);
|
|
475
440
|
const { runner, calls } = makeRunner();
|
|
476
|
-
const { spawner } = makeHubSpawner(1111);
|
|
477
441
|
const code = await exposeTailnet("up", {
|
|
478
442
|
runner,
|
|
479
443
|
manifestPath: h.manifestPath,
|
|
480
444
|
statePath: h.statePath,
|
|
481
445
|
wellKnownPath: h.wellKnownPath,
|
|
482
446
|
hubPath: h.hubPath,
|
|
483
|
-
wellKnownDir: h.wellKnownDir,
|
|
484
447
|
configDir: h.configDir,
|
|
485
|
-
|
|
448
|
+
supervisor: supervisorUp(),
|
|
486
449
|
servicePortProbe: allServicesUp,
|
|
487
450
|
log: () => {},
|
|
488
451
|
});
|
|
@@ -520,16 +483,14 @@ describe("expose tailnet up", () => {
|
|
|
520
483
|
h.manifestPath,
|
|
521
484
|
);
|
|
522
485
|
const { runner, calls } = makeRunner();
|
|
523
|
-
const { spawner } = makeHubSpawner(1111);
|
|
524
486
|
const code = await exposeTailnet("up", {
|
|
525
487
|
runner,
|
|
526
488
|
manifestPath: h.manifestPath,
|
|
527
489
|
statePath: h.statePath,
|
|
528
490
|
wellKnownPath: h.wellKnownPath,
|
|
529
491
|
hubPath: h.hubPath,
|
|
530
|
-
wellKnownDir: h.wellKnownDir,
|
|
531
492
|
configDir: h.configDir,
|
|
532
|
-
|
|
493
|
+
supervisor: supervisorUp(),
|
|
533
494
|
servicePortProbe: allServicesUp,
|
|
534
495
|
log: () => {},
|
|
535
496
|
});
|
|
@@ -549,16 +510,14 @@ describe("expose tailnet up", () => {
|
|
|
549
510
|
try {
|
|
550
511
|
seedServices(h.manifestPath);
|
|
551
512
|
const { runner } = makeRunner();
|
|
552
|
-
const { spawner } = makeHubSpawner(1111);
|
|
553
513
|
const code = await exposeTailnet("up", {
|
|
554
514
|
runner,
|
|
555
515
|
manifestPath: h.manifestPath,
|
|
556
516
|
statePath: h.statePath,
|
|
557
517
|
wellKnownPath: h.wellKnownPath,
|
|
558
518
|
hubPath: h.hubPath,
|
|
559
|
-
wellKnownDir: h.wellKnownDir,
|
|
560
519
|
configDir: h.configDir,
|
|
561
|
-
|
|
520
|
+
supervisor: supervisorUp(),
|
|
562
521
|
servicePortProbe: allServicesUp,
|
|
563
522
|
hubOrigin: "https://hub.example.com/",
|
|
564
523
|
log: () => {},
|
|
@@ -580,7 +539,6 @@ describe("expose tailnet up", () => {
|
|
|
580
539
|
try {
|
|
581
540
|
seedServices(h.manifestPath);
|
|
582
541
|
const { runner } = makeRunner();
|
|
583
|
-
const { spawner } = makeHubSpawner(1111);
|
|
584
542
|
const logs: string[] = [];
|
|
585
543
|
const code = await exposeTailnet("up", {
|
|
586
544
|
runner,
|
|
@@ -588,9 +546,8 @@ describe("expose tailnet up", () => {
|
|
|
588
546
|
statePath: h.statePath,
|
|
589
547
|
wellKnownPath: h.wellKnownPath,
|
|
590
548
|
hubPath: h.hubPath,
|
|
591
|
-
wellKnownDir: h.wellKnownDir,
|
|
592
549
|
configDir: h.configDir,
|
|
593
|
-
|
|
550
|
+
supervisor: supervisorUp(),
|
|
594
551
|
servicePortProbe: allServicesUp,
|
|
595
552
|
log: (l) => logs.push(l),
|
|
596
553
|
vaultAuthStatus: {
|
|
@@ -619,9 +576,7 @@ describe("expose tailnet off", () => {
|
|
|
619
576
|
statePath: h.statePath,
|
|
620
577
|
wellKnownPath: h.wellKnownPath,
|
|
621
578
|
hubPath: h.hubPath,
|
|
622
|
-
wellKnownDir: h.wellKnownDir,
|
|
623
579
|
configDir: h.configDir,
|
|
624
|
-
hubStopOpts: hubStopOpts(),
|
|
625
580
|
log: (l) => logs.push(l),
|
|
626
581
|
});
|
|
627
582
|
expect(code).toBe(0);
|
|
@@ -632,7 +587,7 @@ describe("expose tailnet off", () => {
|
|
|
632
587
|
}
|
|
633
588
|
});
|
|
634
589
|
|
|
635
|
-
test("tears down every tracked entry,
|
|
590
|
+
test("tears down every tracked entry + clears state, leaves the hub running (D3)", async () => {
|
|
636
591
|
const h = makeHarness();
|
|
637
592
|
try {
|
|
638
593
|
writeExposeState(
|
|
@@ -662,27 +617,15 @@ describe("expose tailnet off", () => {
|
|
|
662
617
|
);
|
|
663
618
|
await Bun.write(h.wellKnownPath, "{}\n");
|
|
664
619
|
await Bun.write(h.hubPath, "<html/>\n");
|
|
665
|
-
writePid("hub", 4242, h.configDir);
|
|
666
620
|
const { runner, calls } = makeRunner();
|
|
667
|
-
const
|
|
668
|
-
let aliveNow = true;
|
|
621
|
+
const logs: string[] = [];
|
|
669
622
|
const code = await exposeTailnet("off", {
|
|
670
623
|
runner,
|
|
671
624
|
statePath: h.statePath,
|
|
672
625
|
wellKnownPath: h.wellKnownPath,
|
|
673
626
|
hubPath: h.hubPath,
|
|
674
|
-
wellKnownDir: h.wellKnownDir,
|
|
675
627
|
configDir: h.configDir,
|
|
676
|
-
|
|
677
|
-
kill: (_pid, sig) => {
|
|
678
|
-
signals.push(sig as NodeJS.Signals);
|
|
679
|
-
aliveNow = false;
|
|
680
|
-
},
|
|
681
|
-
alive: () => aliveNow,
|
|
682
|
-
sleep: async () => {},
|
|
683
|
-
now: () => 0,
|
|
684
|
-
},
|
|
685
|
-
log: () => {},
|
|
628
|
+
log: (l) => logs.push(l),
|
|
686
629
|
});
|
|
687
630
|
expect(code).toBe(0);
|
|
688
631
|
expect(calls.every((c) => c[c.length - 1] === "off")).toBe(true);
|
|
@@ -690,8 +633,10 @@ describe("expose tailnet off", () => {
|
|
|
690
633
|
expect(existsSync(h.statePath)).toBe(false);
|
|
691
634
|
expect(existsSync(h.wellKnownPath)).toBe(false);
|
|
692
635
|
expect(existsSync(h.hubPath)).toBe(false);
|
|
693
|
-
//
|
|
694
|
-
|
|
636
|
+
// D3 (Phase 5b): the hub is a persistent platform unit — `expose off` tears
|
|
637
|
+
// down only the exposure and leaves the hub running. No "hub stopped" line.
|
|
638
|
+
expect(logs.join("\n")).not.toMatch(/hub stopped/);
|
|
639
|
+
expect(logs.join("\n")).toMatch(/exposure removed/);
|
|
695
640
|
} finally {
|
|
696
641
|
h.cleanup();
|
|
697
642
|
}
|
|
@@ -729,7 +674,6 @@ describe("expose tailnet off", () => {
|
|
|
729
674
|
statePath: h.statePath,
|
|
730
675
|
wellKnownPath: h.wellKnownPath,
|
|
731
676
|
hubPath: h.hubPath,
|
|
732
|
-
wellKnownDir: h.wellKnownDir,
|
|
733
677
|
configDir: h.configDir,
|
|
734
678
|
skipHub: true,
|
|
735
679
|
log: () => {},
|
|
@@ -773,9 +717,7 @@ describe("expose tailnet off", () => {
|
|
|
773
717
|
statePath: h.statePath,
|
|
774
718
|
wellKnownPath: h.wellKnownPath,
|
|
775
719
|
hubPath: h.hubPath,
|
|
776
|
-
wellKnownDir: h.wellKnownDir,
|
|
777
720
|
configDir: h.configDir,
|
|
778
|
-
hubStopOpts: hubStopOpts(),
|
|
779
721
|
log: (l) => logs.push(l),
|
|
780
722
|
});
|
|
781
723
|
expect(code).toBe(5);
|
|
@@ -807,31 +749,19 @@ describe("expose tailnet off", () => {
|
|
|
807
749
|
},
|
|
808
750
|
h.statePath,
|
|
809
751
|
);
|
|
810
|
-
writePid("hub", 4242, h.configDir);
|
|
811
752
|
const { runner, calls } = makeRunner();
|
|
812
|
-
let killCalled = false;
|
|
813
753
|
const logs: string[] = [];
|
|
814
754
|
const code = await exposeTailnet("off", {
|
|
815
755
|
runner,
|
|
816
756
|
statePath: h.statePath,
|
|
817
757
|
wellKnownPath: h.wellKnownPath,
|
|
818
758
|
hubPath: h.hubPath,
|
|
819
|
-
wellKnownDir: h.wellKnownDir,
|
|
820
759
|
configDir: h.configDir,
|
|
821
|
-
hubStopOpts: {
|
|
822
|
-
kill: () => {
|
|
823
|
-
killCalled = true;
|
|
824
|
-
},
|
|
825
|
-
alive: () => false,
|
|
826
|
-
sleep: async () => {},
|
|
827
|
-
now: () => 0,
|
|
828
|
-
},
|
|
829
760
|
log: (l) => logs.push(l),
|
|
830
761
|
});
|
|
831
762
|
expect(code).toBe(0);
|
|
832
763
|
expect(calls).toHaveLength(0);
|
|
833
764
|
expect(existsSync(h.statePath)).toBe(true);
|
|
834
|
-
expect(killCalled).toBe(false);
|
|
835
765
|
expect(logs.join("\n")).toMatch(/Current exposure is Public/);
|
|
836
766
|
} finally {
|
|
837
767
|
h.cleanup();
|
|
@@ -845,7 +775,6 @@ describe("expose public up", () => {
|
|
|
845
775
|
try {
|
|
846
776
|
seedServices(h.manifestPath);
|
|
847
777
|
const { runner, calls } = makeRunner();
|
|
848
|
-
const { spawner } = makeHubSpawner(1111);
|
|
849
778
|
const logs: string[] = [];
|
|
850
779
|
const code = await exposePublic("up", {
|
|
851
780
|
runner,
|
|
@@ -853,9 +782,8 @@ describe("expose public up", () => {
|
|
|
853
782
|
statePath: h.statePath,
|
|
854
783
|
wellKnownPath: h.wellKnownPath,
|
|
855
784
|
hubPath: h.hubPath,
|
|
856
|
-
wellKnownDir: h.wellKnownDir,
|
|
857
785
|
configDir: h.configDir,
|
|
858
|
-
|
|
786
|
+
supervisor: supervisorUp(),
|
|
859
787
|
servicePortProbe: allServicesUp,
|
|
860
788
|
log: (l) => logs.push(l),
|
|
861
789
|
});
|
|
@@ -908,16 +836,14 @@ describe("expose public up", () => {
|
|
|
908
836
|
h.statePath,
|
|
909
837
|
);
|
|
910
838
|
const { runner, calls } = makeRunner();
|
|
911
|
-
const { spawner } = makeHubSpawner(1111);
|
|
912
839
|
const code = await exposeTailnet("up", {
|
|
913
840
|
runner,
|
|
914
841
|
manifestPath: h.manifestPath,
|
|
915
842
|
statePath: h.statePath,
|
|
916
843
|
wellKnownPath: h.wellKnownPath,
|
|
917
844
|
hubPath: h.hubPath,
|
|
918
|
-
wellKnownDir: h.wellKnownDir,
|
|
919
845
|
configDir: h.configDir,
|
|
920
|
-
|
|
846
|
+
supervisor: supervisorUp(),
|
|
921
847
|
servicePortProbe: allServicesUp,
|
|
922
848
|
log: () => {},
|
|
923
849
|
});
|
|
@@ -956,16 +882,14 @@ describe("expose public up", () => {
|
|
|
956
882
|
h.statePath,
|
|
957
883
|
);
|
|
958
884
|
const { runner, calls } = makeRunner();
|
|
959
|
-
const { spawner } = makeHubSpawner(1111);
|
|
960
885
|
const code = await exposePublic("up", {
|
|
961
886
|
runner,
|
|
962
887
|
manifestPath: h.manifestPath,
|
|
963
888
|
statePath: h.statePath,
|
|
964
889
|
wellKnownPath: h.wellKnownPath,
|
|
965
890
|
hubPath: h.hubPath,
|
|
966
|
-
wellKnownDir: h.wellKnownDir,
|
|
967
891
|
configDir: h.configDir,
|
|
968
|
-
|
|
892
|
+
supervisor: supervisorUp(),
|
|
969
893
|
servicePortProbe: allServicesUp,
|
|
970
894
|
log: () => {},
|
|
971
895
|
});
|
|
@@ -987,7 +911,6 @@ describe("expose public up", () => {
|
|
|
987
911
|
try {
|
|
988
912
|
seedServices(h.manifestPath);
|
|
989
913
|
const { runner } = makeRunner();
|
|
990
|
-
const { spawner } = makeHubSpawner(1111);
|
|
991
914
|
const logs: string[] = [];
|
|
992
915
|
const code = await exposePublic("up", {
|
|
993
916
|
runner,
|
|
@@ -995,9 +918,8 @@ describe("expose public up", () => {
|
|
|
995
918
|
statePath: h.statePath,
|
|
996
919
|
wellKnownPath: h.wellKnownPath,
|
|
997
920
|
hubPath: h.hubPath,
|
|
998
|
-
wellKnownDir: h.wellKnownDir,
|
|
999
921
|
configDir: h.configDir,
|
|
1000
|
-
|
|
922
|
+
supervisor: supervisorUp(),
|
|
1001
923
|
servicePortProbe: allServicesUp,
|
|
1002
924
|
log: (l) => logs.push(l),
|
|
1003
925
|
vaultAuthStatus: {
|
|
@@ -1025,7 +947,6 @@ describe("expose public up", () => {
|
|
|
1025
947
|
try {
|
|
1026
948
|
seedServices(h.manifestPath);
|
|
1027
949
|
const { runner } = makeRunner();
|
|
1028
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1029
950
|
const logs: string[] = [];
|
|
1030
951
|
const code = await exposePublic("up", {
|
|
1031
952
|
runner,
|
|
@@ -1033,9 +954,8 @@ describe("expose public up", () => {
|
|
|
1033
954
|
statePath: h.statePath,
|
|
1034
955
|
wellKnownPath: h.wellKnownPath,
|
|
1035
956
|
hubPath: h.hubPath,
|
|
1036
|
-
wellKnownDir: h.wellKnownDir,
|
|
1037
957
|
configDir: h.configDir,
|
|
1038
|
-
|
|
958
|
+
supervisor: supervisorUp(),
|
|
1039
959
|
servicePortProbe: allServicesUp,
|
|
1040
960
|
log: (l) => logs.push(l),
|
|
1041
961
|
vaultAuthStatus: {
|
|
@@ -1082,9 +1002,7 @@ describe("expose public off", () => {
|
|
|
1082
1002
|
statePath: h.statePath,
|
|
1083
1003
|
wellKnownPath: h.wellKnownPath,
|
|
1084
1004
|
hubPath: h.hubPath,
|
|
1085
|
-
wellKnownDir: h.wellKnownDir,
|
|
1086
1005
|
configDir: h.configDir,
|
|
1087
|
-
hubStopOpts: hubStopOpts(),
|
|
1088
1006
|
log: () => {},
|
|
1089
1007
|
});
|
|
1090
1008
|
expect(code).toBe(0);
|
|
@@ -1127,9 +1045,7 @@ describe("expose public off", () => {
|
|
|
1127
1045
|
statePath: h.statePath,
|
|
1128
1046
|
wellKnownPath: h.wellKnownPath,
|
|
1129
1047
|
hubPath: h.hubPath,
|
|
1130
|
-
wellKnownDir: h.wellKnownDir,
|
|
1131
1048
|
configDir: h.configDir,
|
|
1132
|
-
hubStopOpts: hubStopOpts(),
|
|
1133
1049
|
log: (l) => logs.push(l),
|
|
1134
1050
|
});
|
|
1135
1051
|
expect(code).toBe(0);
|
|
@@ -1180,16 +1096,14 @@ describe("expose plan is layer-agnostic — gating moved to hub", () => {
|
|
|
1180
1096
|
h.manifestPath,
|
|
1181
1097
|
);
|
|
1182
1098
|
const { runner, calls } = makeRunner();
|
|
1183
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1184
1099
|
const code = await exposeTailnet("up", {
|
|
1185
1100
|
runner,
|
|
1186
1101
|
manifestPath: h.manifestPath,
|
|
1187
1102
|
statePath: h.statePath,
|
|
1188
1103
|
wellKnownPath: h.wellKnownPath,
|
|
1189
1104
|
hubPath: h.hubPath,
|
|
1190
|
-
wellKnownDir: h.wellKnownDir,
|
|
1191
1105
|
configDir: h.configDir,
|
|
1192
|
-
|
|
1106
|
+
supervisor: supervisorUp(),
|
|
1193
1107
|
servicePortProbe: allServicesUp,
|
|
1194
1108
|
log: () => {},
|
|
1195
1109
|
});
|
|
@@ -1237,16 +1151,14 @@ describe("expose plan is layer-agnostic — gating moved to hub", () => {
|
|
|
1237
1151
|
h.manifestPath,
|
|
1238
1152
|
);
|
|
1239
1153
|
const { runner, calls } = makeRunner();
|
|
1240
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1241
1154
|
const code = await exposeTailnet("up", {
|
|
1242
1155
|
runner,
|
|
1243
1156
|
manifestPath: h.manifestPath,
|
|
1244
1157
|
statePath: h.statePath,
|
|
1245
1158
|
wellKnownPath: h.wellKnownPath,
|
|
1246
1159
|
hubPath: h.hubPath,
|
|
1247
|
-
wellKnownDir: h.wellKnownDir,
|
|
1248
1160
|
configDir: h.configDir,
|
|
1249
|
-
|
|
1161
|
+
supervisor: supervisorUp(),
|
|
1250
1162
|
servicePortProbe: allServicesUp,
|
|
1251
1163
|
log: () => {},
|
|
1252
1164
|
});
|
|
@@ -1265,114 +1177,49 @@ describe("expose auto-restart of hub-dependent services", () => {
|
|
|
1265
1177
|
// Launch-day bug (2026-04-23): `expose public` updated hubOrigin in
|
|
1266
1178
|
// expose-state.json, but a vault already running kept its stale
|
|
1267
1179
|
// PARACHUTE_HUB_ORIGIN in memory, so the OAuth issuer didn't match what
|
|
1268
|
-
// clients saw and claude.ai MCP failed to reach the server.
|
|
1269
|
-
//
|
|
1270
|
-
//
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
const restarted: string[] = [];
|
|
1279
|
-
const code = await exposePublic("up", {
|
|
1280
|
-
runner,
|
|
1281
|
-
manifestPath: h.manifestPath,
|
|
1282
|
-
statePath: h.statePath,
|
|
1283
|
-
wellKnownPath: h.wellKnownPath,
|
|
1284
|
-
hubPath: h.hubPath,
|
|
1285
|
-
wellKnownDir: h.wellKnownDir,
|
|
1286
|
-
configDir: h.configDir,
|
|
1287
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1288
|
-
servicePortProbe: allServicesUp,
|
|
1289
|
-
alive: () => true,
|
|
1290
|
-
restartService: async (short) => {
|
|
1291
|
-
restarted.push(short);
|
|
1292
|
-
return 0;
|
|
1293
|
-
},
|
|
1294
|
-
log: () => {},
|
|
1295
|
-
});
|
|
1296
|
-
expect(code).toBe(0);
|
|
1297
|
-
expect(restarted).toEqual(["vault"]);
|
|
1298
|
-
} finally {
|
|
1299
|
-
h.cleanup();
|
|
1300
|
-
}
|
|
1301
|
-
});
|
|
1302
|
-
|
|
1303
|
-
test("skips restart when vault is not running", async () => {
|
|
1180
|
+
// clients saw and claude.ai MCP failed to reach the server. Auto-restart the
|
|
1181
|
+
// service so it picks up the new origin.
|
|
1182
|
+
//
|
|
1183
|
+
// Phase 5b: the restart goes through the running supervisor
|
|
1184
|
+
// (`driveModuleOp("vault", "restart")`), NOT a pidfile-gated detached
|
|
1185
|
+
// `lifecycle.restart`. So the restart is unconditional (the supervisor decides
|
|
1186
|
+
// whether the module is live); the old "skips when not running / stale pidfile"
|
|
1187
|
+
// cases no longer apply — a not-supervised vault surfaces as a 404 the helper
|
|
1188
|
+
// treats as "nothing to restart" (asserted in the Phase 4 dual-dispatch suite).
|
|
1189
|
+
test("restarts vault via the supervisor after expose", async () => {
|
|
1304
1190
|
const h = makeHarness();
|
|
1305
1191
|
try {
|
|
1306
1192
|
seedServices(h.manifestPath);
|
|
1307
|
-
// No writePid → vault has no pidfile → processState returns "unknown".
|
|
1308
1193
|
const { runner } = makeRunner();
|
|
1309
|
-
const
|
|
1310
|
-
const restarted: string[] = [];
|
|
1194
|
+
const sup = makeExposeSupervisorStub();
|
|
1311
1195
|
const code = await exposePublic("up", {
|
|
1312
1196
|
runner,
|
|
1313
1197
|
manifestPath: h.manifestPath,
|
|
1314
1198
|
statePath: h.statePath,
|
|
1315
1199
|
wellKnownPath: h.wellKnownPath,
|
|
1316
1200
|
hubPath: h.hubPath,
|
|
1317
|
-
wellKnownDir: h.wellKnownDir,
|
|
1318
1201
|
configDir: h.configDir,
|
|
1319
|
-
|
|
1202
|
+
supervisor: sup.opts,
|
|
1320
1203
|
servicePortProbe: allServicesUp,
|
|
1321
|
-
alive: () => true,
|
|
1322
|
-
restartService: async (short) => {
|
|
1323
|
-
restarted.push(short);
|
|
1324
|
-
return 0;
|
|
1325
|
-
},
|
|
1326
1204
|
log: () => {},
|
|
1327
1205
|
});
|
|
1328
1206
|
expect(code).toBe(0);
|
|
1329
|
-
expect(
|
|
1207
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
1330
1208
|
} finally {
|
|
1331
1209
|
h.cleanup();
|
|
1332
1210
|
}
|
|
1333
1211
|
});
|
|
1334
1212
|
|
|
1335
|
-
test("
|
|
1213
|
+
test("restart failure logs a warning but expose still succeeds", async () => {
|
|
1336
1214
|
const h = makeHarness();
|
|
1337
1215
|
try {
|
|
1338
1216
|
seedServices(h.manifestPath);
|
|
1339
|
-
writePid("vault", 4242, h.configDir);
|
|
1340
1217
|
const { runner } = makeRunner();
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
const
|
|
1344
|
-
|
|
1345
|
-
manifestPath: h.manifestPath,
|
|
1346
|
-
statePath: h.statePath,
|
|
1347
|
-
wellKnownPath: h.wellKnownPath,
|
|
1348
|
-
hubPath: h.hubPath,
|
|
1349
|
-
wellKnownDir: h.wellKnownDir,
|
|
1350
|
-
configDir: h.configDir,
|
|
1351
|
-
hubEnsureOpts: hubEnsureOpts(spawner),
|
|
1352
|
-
servicePortProbe: allServicesUp,
|
|
1353
|
-
// Simulate pid-file-present-but-process-dead. processState returns
|
|
1354
|
-
// "stopped", not "running", so we should skip.
|
|
1355
|
-
alive: () => false,
|
|
1356
|
-
restartService: async (short) => {
|
|
1357
|
-
restarted.push(short);
|
|
1358
|
-
return 0;
|
|
1359
|
-
},
|
|
1360
|
-
log: () => {},
|
|
1218
|
+
// A non-404 module-op error → surfaced as a warning, non-zero rcode mapped
|
|
1219
|
+
// to a warn-and-continue (expose still exits 0).
|
|
1220
|
+
const sup = makeExposeSupervisorStub({
|
|
1221
|
+
driveThrows: () => new ModuleOpHttpError(500, "internal", "vault restart blew up"),
|
|
1361
1222
|
});
|
|
1362
|
-
expect(code).toBe(0);
|
|
1363
|
-
expect(restarted).toEqual([]);
|
|
1364
|
-
} finally {
|
|
1365
|
-
h.cleanup();
|
|
1366
|
-
}
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
test("restart failure logs warning but expose still succeeds", async () => {
|
|
1370
|
-
const h = makeHarness();
|
|
1371
|
-
try {
|
|
1372
|
-
seedServices(h.manifestPath);
|
|
1373
|
-
writePid("vault", 4242, h.configDir);
|
|
1374
|
-
const { runner } = makeRunner();
|
|
1375
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1376
1223
|
const logs: string[] = [];
|
|
1377
1224
|
const code = await exposePublic("up", {
|
|
1378
1225
|
runner,
|
|
@@ -1380,12 +1227,9 @@ describe("expose auto-restart of hub-dependent services", () => {
|
|
|
1380
1227
|
statePath: h.statePath,
|
|
1381
1228
|
wellKnownPath: h.wellKnownPath,
|
|
1382
1229
|
hubPath: h.hubPath,
|
|
1383
|
-
wellKnownDir: h.wellKnownDir,
|
|
1384
1230
|
configDir: h.configDir,
|
|
1385
|
-
|
|
1231
|
+
supervisor: sup.opts,
|
|
1386
1232
|
servicePortProbe: allServicesUp,
|
|
1387
|
-
alive: () => true,
|
|
1388
|
-
restartService: async () => 1,
|
|
1389
1233
|
log: (l) => logs.push(l),
|
|
1390
1234
|
});
|
|
1391
1235
|
expect(code).toBe(0);
|
|
@@ -1446,9 +1290,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1446
1290
|
statePath: h.statePath,
|
|
1447
1291
|
wellKnownPath: h.wellKnownPath,
|
|
1448
1292
|
hubPath: h.hubPath,
|
|
1449
|
-
wellKnownDir: h.wellKnownDir,
|
|
1450
1293
|
configDir: h.configDir,
|
|
1451
|
-
hubStopOpts: hubStopOpts(),
|
|
1452
1294
|
log: (l) => logs.push(l),
|
|
1453
1295
|
});
|
|
1454
1296
|
expect(code).toBe(0);
|
|
@@ -1485,9 +1327,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1485
1327
|
statePath: h.statePath,
|
|
1486
1328
|
wellKnownPath: h.wellKnownPath,
|
|
1487
1329
|
hubPath: h.hubPath,
|
|
1488
|
-
wellKnownDir: h.wellKnownDir,
|
|
1489
1330
|
configDir: h.configDir,
|
|
1490
|
-
hubStopOpts: hubStopOpts(),
|
|
1491
1331
|
log: () => {},
|
|
1492
1332
|
});
|
|
1493
1333
|
expect(code).toBe(0);
|
|
@@ -1517,9 +1357,7 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1517
1357
|
statePath: h.statePath,
|
|
1518
1358
|
wellKnownPath: h.wellKnownPath,
|
|
1519
1359
|
hubPath: h.hubPath,
|
|
1520
|
-
wellKnownDir: h.wellKnownDir,
|
|
1521
1360
|
configDir: h.configDir,
|
|
1522
|
-
hubStopOpts: hubStopOpts(),
|
|
1523
1361
|
log: (l) => logs.push(l),
|
|
1524
1362
|
});
|
|
1525
1363
|
expect(code).toBe(1);
|
|
@@ -1538,7 +1376,6 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1538
1376
|
try {
|
|
1539
1377
|
seedServices(h.manifestPath);
|
|
1540
1378
|
makePublicPriorState(h.statePath, 2);
|
|
1541
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1542
1379
|
const bringupCalls: string[][] = [];
|
|
1543
1380
|
const runner: Runner = async (cmd) => {
|
|
1544
1381
|
if (cmd[0] === "tailscale" && cmd[1] === "version") {
|
|
@@ -1569,9 +1406,8 @@ describe("expose teardown tolerance for already-gone entries", () => {
|
|
|
1569
1406
|
statePath: h.statePath,
|
|
1570
1407
|
wellKnownPath: h.wellKnownPath,
|
|
1571
1408
|
hubPath: h.hubPath,
|
|
1572
|
-
wellKnownDir: h.wellKnownDir,
|
|
1573
1409
|
configDir: h.configDir,
|
|
1574
|
-
|
|
1410
|
+
supervisor: supervisorUp(),
|
|
1575
1411
|
servicePortProbe: allServicesUp,
|
|
1576
1412
|
log: () => {},
|
|
1577
1413
|
});
|
|
@@ -1607,16 +1443,14 @@ describe("expose: vault routing fully internal to hub", () => {
|
|
|
1607
1443
|
h.manifestPath,
|
|
1608
1444
|
);
|
|
1609
1445
|
const { runner, calls } = makeRunner();
|
|
1610
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1611
1446
|
const code = await exposeTailnet("up", {
|
|
1612
1447
|
runner,
|
|
1613
1448
|
manifestPath: h.manifestPath,
|
|
1614
1449
|
statePath: h.statePath,
|
|
1615
1450
|
wellKnownPath: h.wellKnownPath,
|
|
1616
1451
|
hubPath: h.hubPath,
|
|
1617
|
-
wellKnownDir: h.wellKnownDir,
|
|
1618
1452
|
configDir: h.configDir,
|
|
1619
|
-
|
|
1453
|
+
supervisor: supervisorUp(),
|
|
1620
1454
|
servicePortProbe: allServicesUp,
|
|
1621
1455
|
log: () => {},
|
|
1622
1456
|
});
|
|
@@ -1657,16 +1491,14 @@ describe("expose: vault routing fully internal to hub", () => {
|
|
|
1657
1491
|
h.manifestPath,
|
|
1658
1492
|
);
|
|
1659
1493
|
const { runner, calls } = makeRunner();
|
|
1660
|
-
const { spawner } = makeHubSpawner(1111);
|
|
1661
1494
|
const code = await exposeTailnet("up", {
|
|
1662
1495
|
runner,
|
|
1663
1496
|
manifestPath: h.manifestPath,
|
|
1664
1497
|
statePath: h.statePath,
|
|
1665
1498
|
wellKnownPath: h.wellKnownPath,
|
|
1666
1499
|
hubPath: h.hubPath,
|
|
1667
|
-
wellKnownDir: h.wellKnownDir,
|
|
1668
1500
|
configDir: h.configDir,
|
|
1669
|
-
|
|
1501
|
+
supervisor: supervisorUp(),
|
|
1670
1502
|
servicePortProbe: allServicesUp,
|
|
1671
1503
|
log: () => {},
|
|
1672
1504
|
});
|
|
@@ -1680,3 +1512,191 @@ describe("expose: vault routing fully internal to hub", () => {
|
|
|
1680
1512
|
}
|
|
1681
1513
|
});
|
|
1682
1514
|
});
|
|
1515
|
+
|
|
1516
|
+
// ---------------------------------------------------------------------------
|
|
1517
|
+
// Phase 4 dual-dispatch (design §4.3): when a hub UNIT is installed, `expose`
|
|
1518
|
+
// ensures the hub UNIT (not a detached spawn), the post-expose vault restart
|
|
1519
|
+
// drives the running Supervisor over the loopback module-ops API (firing the
|
|
1520
|
+
// operator-token + vault `.env` self-heal), and `expose off` leaves the hub
|
|
1521
|
+
// RUNNING (D3 — a managed hub with Restart=always/KeepAlive would just respawn
|
|
1522
|
+
// a stopped one). The no-unit arm keeps today's behavior unchanged.
|
|
1523
|
+
// ---------------------------------------------------------------------------
|
|
1524
|
+
|
|
1525
|
+
interface ExposeSupervisorStub {
|
|
1526
|
+
opts: ExposeSupervisorOpts;
|
|
1527
|
+
ensureCalls: Array<{ port?: number }>;
|
|
1528
|
+
driveCalls: Array<{ short: string; op: ModuleOp }>;
|
|
1529
|
+
selfHealCalls: Array<{ issuer: string }>;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Build an expose `supervisor` seam that forces the unit-installed arm and
|
|
1534
|
+
* records the `ensureHubUnit` / `driveModuleOp` / operator-token-self-heal
|
|
1535
|
+
* calls. `driveThrows` makes a module-op throw a chosen error; `ensureOutcome`
|
|
1536
|
+
* controls the ensure-hub result.
|
|
1537
|
+
*/
|
|
1538
|
+
function makeExposeSupervisorStub(opts?: {
|
|
1539
|
+
ensureOutcome?: "already-up" | "started" | "no-unit" | "no-manager" | "timeout" | "start-failed";
|
|
1540
|
+
ensurePort?: number;
|
|
1541
|
+
driveThrows?: (short: string, op: ModuleOp) => unknown;
|
|
1542
|
+
}): ExposeSupervisorStub {
|
|
1543
|
+
const ensureCalls: Array<{ port?: number }> = [];
|
|
1544
|
+
const driveCalls: Array<{ short: string; op: ModuleOp }> = [];
|
|
1545
|
+
const selfHealCalls: Array<{ issuer: string }> = [];
|
|
1546
|
+
return {
|
|
1547
|
+
ensureCalls,
|
|
1548
|
+
driveCalls,
|
|
1549
|
+
selfHealCalls,
|
|
1550
|
+
opts: {
|
|
1551
|
+
// openDb is opened+closed around the drive/self-heal — hand back a no-op closer.
|
|
1552
|
+
openDb: () => ({ close() {} }) as unknown as import("bun:sqlite").Database,
|
|
1553
|
+
ensureHubUnit: async (o: EnsureHubUnitOpts) => {
|
|
1554
|
+
ensureCalls.push({ port: o.port });
|
|
1555
|
+
return {
|
|
1556
|
+
outcome: opts?.ensureOutcome ?? "already-up",
|
|
1557
|
+
port: opts?.ensurePort ?? o.port ?? 1939,
|
|
1558
|
+
messages: [],
|
|
1559
|
+
};
|
|
1560
|
+
},
|
|
1561
|
+
driveModuleOp: async (short, op) => {
|
|
1562
|
+
driveCalls.push({ short, op });
|
|
1563
|
+
if (opts?.driveThrows) throw opts.driveThrows(short, op);
|
|
1564
|
+
return { status: 200, body: { short, state: { status: "running" } } };
|
|
1565
|
+
},
|
|
1566
|
+
selfHealOperatorTokenIssuer: async (_db, o) => {
|
|
1567
|
+
selfHealCalls.push({ issuer: o.issuer });
|
|
1568
|
+
return { kind: "fresh" };
|
|
1569
|
+
},
|
|
1570
|
+
},
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
describe("Phase 4 expose dual-dispatch — unit-managed", () => {
|
|
1575
|
+
test("expose up unit-managed → ensureHubUnit (not detached spawn) + driveModuleOp(restart) for vault", async () => {
|
|
1576
|
+
const h = makeHarness();
|
|
1577
|
+
try {
|
|
1578
|
+
seedServices(h.manifestPath);
|
|
1579
|
+
const { runner } = makeRunner();
|
|
1580
|
+
const sup = makeExposeSupervisorStub();
|
|
1581
|
+
const logs: string[] = [];
|
|
1582
|
+
const code = await exposeTailnet("up", {
|
|
1583
|
+
runner,
|
|
1584
|
+
manifestPath: h.manifestPath,
|
|
1585
|
+
statePath: h.statePath,
|
|
1586
|
+
wellKnownPath: h.wellKnownPath,
|
|
1587
|
+
hubPath: h.hubPath,
|
|
1588
|
+
configDir: h.configDir,
|
|
1589
|
+
servicePortProbe: allServicesUp,
|
|
1590
|
+
supervisor: sup.opts,
|
|
1591
|
+
log: (l) => logs.push(l),
|
|
1592
|
+
});
|
|
1593
|
+
expect(code).toBe(0);
|
|
1594
|
+
// Ensured the hub UNIT (the detached hub spawner was never invoked).
|
|
1595
|
+
expect(sup.ensureCalls).toHaveLength(1);
|
|
1596
|
+
// Post-expose vault restart drove the supervisor, not lifecycle.restart.
|
|
1597
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
1598
|
+
// The operator-token issuer self-heal fired toward the public origin.
|
|
1599
|
+
expect(sup.selfHealCalls).toHaveLength(1);
|
|
1600
|
+
expect(sup.selfHealCalls[0]?.issuer).toMatch(/^https:\/\//);
|
|
1601
|
+
expect(logs.join("\n")).toMatch(/hub unit up/);
|
|
1602
|
+
} finally {
|
|
1603
|
+
h.cleanup();
|
|
1604
|
+
}
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
test("expose up unit-managed: ensureHubUnit failure aborts before exposing", async () => {
|
|
1608
|
+
const h = makeHarness();
|
|
1609
|
+
try {
|
|
1610
|
+
seedServices(h.manifestPath);
|
|
1611
|
+
const { runner, calls } = makeRunner();
|
|
1612
|
+
const sup = makeExposeSupervisorStub({ ensureOutcome: "no-manager" });
|
|
1613
|
+
const code = await exposeTailnet("up", {
|
|
1614
|
+
runner,
|
|
1615
|
+
manifestPath: h.manifestPath,
|
|
1616
|
+
statePath: h.statePath,
|
|
1617
|
+
wellKnownPath: h.wellKnownPath,
|
|
1618
|
+
hubPath: h.hubPath,
|
|
1619
|
+
configDir: h.configDir,
|
|
1620
|
+
servicePortProbe: allServicesUp,
|
|
1621
|
+
supervisor: sup.opts,
|
|
1622
|
+
log: () => {},
|
|
1623
|
+
});
|
|
1624
|
+
expect(code).toBe(1);
|
|
1625
|
+
// Never ran the tailscale bringup — we bailed on the failed ensure.
|
|
1626
|
+
expect(calls.filter((c) => c[0] === "tailscale" && c[1] === "serve")).toHaveLength(0);
|
|
1627
|
+
expect(sup.driveCalls).toHaveLength(0);
|
|
1628
|
+
} finally {
|
|
1629
|
+
h.cleanup();
|
|
1630
|
+
}
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
test("expose up unit-managed: a not_supervised vault (404) is not a failure", async () => {
|
|
1634
|
+
const h = makeHarness();
|
|
1635
|
+
try {
|
|
1636
|
+
seedServices(h.manifestPath);
|
|
1637
|
+
const { runner } = makeRunner();
|
|
1638
|
+
const sup = makeExposeSupervisorStub({
|
|
1639
|
+
driveThrows: () => new ModuleOpHttpError(404, "not_supervised", "vault is not supervised"),
|
|
1640
|
+
});
|
|
1641
|
+
const code = await exposeTailnet("up", {
|
|
1642
|
+
runner,
|
|
1643
|
+
manifestPath: h.manifestPath,
|
|
1644
|
+
statePath: h.statePath,
|
|
1645
|
+
wellKnownPath: h.wellKnownPath,
|
|
1646
|
+
hubPath: h.hubPath,
|
|
1647
|
+
configDir: h.configDir,
|
|
1648
|
+
servicePortProbe: allServicesUp,
|
|
1649
|
+
supervisor: sup.opts,
|
|
1650
|
+
log: () => {},
|
|
1651
|
+
});
|
|
1652
|
+
expect(code).toBe(0);
|
|
1653
|
+
expect(sup.driveCalls).toEqual([{ short: "vault", op: "restart" }]);
|
|
1654
|
+
} finally {
|
|
1655
|
+
h.cleanup();
|
|
1656
|
+
}
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test("expose off unit-managed → exposure torn down, hub NOT stopped, no 'hub stopped' line", async () => {
|
|
1660
|
+
const h = makeHarness();
|
|
1661
|
+
try {
|
|
1662
|
+
writeExposeState(
|
|
1663
|
+
{
|
|
1664
|
+
version: 1,
|
|
1665
|
+
layer: "tailnet",
|
|
1666
|
+
mode: "path",
|
|
1667
|
+
canonicalFqdn: "parachute.taildf9ce2.ts.net",
|
|
1668
|
+
port: 443,
|
|
1669
|
+
funnel: false,
|
|
1670
|
+
entries: [{ kind: "proxy", mount: "/", target: "http://127.0.0.1:1939", service: "hub" }],
|
|
1671
|
+
},
|
|
1672
|
+
h.statePath,
|
|
1673
|
+
);
|
|
1674
|
+
const { runner, calls } = makeRunner();
|
|
1675
|
+
const sup = makeExposeSupervisorStub();
|
|
1676
|
+
const logs: string[] = [];
|
|
1677
|
+
const code = await exposeTailnet("off", {
|
|
1678
|
+
runner,
|
|
1679
|
+
statePath: h.statePath,
|
|
1680
|
+
wellKnownPath: h.wellKnownPath,
|
|
1681
|
+
hubPath: h.hubPath,
|
|
1682
|
+
configDir: h.configDir,
|
|
1683
|
+
supervisor: sup.opts,
|
|
1684
|
+
log: (l) => logs.push(l),
|
|
1685
|
+
});
|
|
1686
|
+
expect(code).toBe(0);
|
|
1687
|
+
// The exposure layer was torn down…
|
|
1688
|
+
expect(calls.every((c) => c[c.length - 1] === "off" || c[1] === "version")).toBe(true);
|
|
1689
|
+
expect(existsSync(h.statePath)).toBe(false);
|
|
1690
|
+
// …but the hub was left running: no stop, no "hub stopped" line.
|
|
1691
|
+
expect(logs.join("\n")).not.toMatch(/hub stopped/);
|
|
1692
|
+
expect(logs.join("\n")).toMatch(/exposure removed/);
|
|
1693
|
+
} finally {
|
|
1694
|
+
h.cleanup();
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
// The "expose off NO unit → hub stopped (detached arm)" test was removed: Phase
|
|
1699
|
+
// 5b retired the detached arm, so `expose off` NEVER stops the hub (D3). The
|
|
1700
|
+
// "exposure torn down, hub NOT stopped" invariant is the test above + the
|
|
1701
|
+
// "leaves the hub running (D3)" test in the `expose tailnet off` suite.
|
|
1702
|
+
});
|