@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,8 +2,13 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import type { LifecycleOpts } from "../commands/lifecycle.ts";
|
|
6
|
+
import { restart as lifecycleRestart } from "../commands/lifecycle.ts";
|
|
5
7
|
import type { UpgradeRunner } from "../commands/upgrade.ts";
|
|
6
8
|
import { compareVersions, defaultRunner, detectChannel, upgrade } from "../commands/upgrade.ts";
|
|
9
|
+
import type { HubUnitDeps, HubUnitManagerOpResult } from "../hub-unit.ts";
|
|
10
|
+
import { defaultHubUnitDeps } from "../hub-unit.ts";
|
|
11
|
+
import type { MigrateOfferResult } from "../migrate-offer.ts";
|
|
7
12
|
import { upsertService } from "../services-manifest.ts";
|
|
8
13
|
|
|
9
14
|
interface RunCall {
|
|
@@ -84,6 +89,16 @@ function seedVault(manifestPath: string, installDir: string, version = "0.4.0"):
|
|
|
84
89
|
);
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Phase 5b: `upgrade hub` always restarts the hub UNIT via the platform manager
|
|
94
|
+
* (`restartHubUnit`) — the detached restart arm is retired. Hub-upgrade tests
|
|
95
|
+
* that aren't asserting the restart mechanism itself inject this benign seam so
|
|
96
|
+
* the manager op succeeds without a real systemd/launchd on the test host.
|
|
97
|
+
*/
|
|
98
|
+
const okHubUnitSupervisor = {
|
|
99
|
+
restartHubUnit: (): HubUnitManagerOpResult => ({ outcome: "ok" as const, messages: [] }),
|
|
100
|
+
};
|
|
101
|
+
|
|
87
102
|
describe("parachute upgrade", () => {
|
|
88
103
|
test("errors cleanly when targeting a service that's not installed", async () => {
|
|
89
104
|
const h = makeHarness();
|
|
@@ -587,6 +602,7 @@ describe("parachute upgrade", () => {
|
|
|
587
602
|
let restartedShort: string | undefined;
|
|
588
603
|
const logs: string[] = [];
|
|
589
604
|
const code = await upgrade("hub", {
|
|
605
|
+
supervisor: okHubUnitSupervisor,
|
|
590
606
|
manifestPath: h.manifestPath,
|
|
591
607
|
configDir: h.configDir,
|
|
592
608
|
runner,
|
|
@@ -599,7 +615,8 @@ describe("parachute upgrade", () => {
|
|
|
599
615
|
log: (l) => logs.push(l),
|
|
600
616
|
});
|
|
601
617
|
expect(code).toBe(0);
|
|
602
|
-
|
|
618
|
+
// Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
|
|
619
|
+
// not the detached restartFn — the unit-restart path has its own test.
|
|
603
620
|
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
604
621
|
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@latest"]);
|
|
605
622
|
const joined = logs.join("\n");
|
|
@@ -636,6 +653,7 @@ describe("parachute upgrade", () => {
|
|
|
636
653
|
|
|
637
654
|
let restartedShort: string | undefined;
|
|
638
655
|
const code = await upgrade("hub", {
|
|
656
|
+
supervisor: okHubUnitSupervisor,
|
|
639
657
|
manifestPath: h.manifestPath, // file doesn't exist
|
|
640
658
|
configDir: h.configDir,
|
|
641
659
|
runner,
|
|
@@ -648,7 +666,8 @@ describe("parachute upgrade", () => {
|
|
|
648
666
|
log: () => {},
|
|
649
667
|
});
|
|
650
668
|
expect(code).toBe(0);
|
|
651
|
-
|
|
669
|
+
// Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
|
|
670
|
+
// not the detached restartFn — the unit-restart path has its own test.
|
|
652
671
|
} finally {
|
|
653
672
|
h.cleanup();
|
|
654
673
|
}
|
|
@@ -683,6 +702,7 @@ describe("parachute upgrade", () => {
|
|
|
683
702
|
let restartedShort: string | undefined;
|
|
684
703
|
const logs: string[] = [];
|
|
685
704
|
const code = await upgrade("hub", {
|
|
705
|
+
supervisor: okHubUnitSupervisor,
|
|
686
706
|
manifestPath: h.manifestPath,
|
|
687
707
|
configDir: h.configDir,
|
|
688
708
|
runner,
|
|
@@ -695,7 +715,8 @@ describe("parachute upgrade", () => {
|
|
|
695
715
|
log: (l) => logs.push(l),
|
|
696
716
|
});
|
|
697
717
|
expect(code).toBe(0);
|
|
698
|
-
|
|
718
|
+
// Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
|
|
719
|
+
// not the detached restartFn — the unit-restart path has its own test.
|
|
699
720
|
expect(logs.join("\n")).toMatch(/hub: bun-linked checkout/);
|
|
700
721
|
} finally {
|
|
701
722
|
h.cleanup();
|
|
@@ -723,6 +744,7 @@ describe("parachute upgrade", () => {
|
|
|
723
744
|
};
|
|
724
745
|
|
|
725
746
|
await upgrade("hub", {
|
|
747
|
+
supervisor: okHubUnitSupervisor,
|
|
726
748
|
manifestPath: h.manifestPath,
|
|
727
749
|
configDir: h.configDir,
|
|
728
750
|
runner,
|
|
@@ -771,6 +793,15 @@ describe("parachute upgrade", () => {
|
|
|
771
793
|
|
|
772
794
|
const restartCalls: string[] = [];
|
|
773
795
|
const code = await upgrade(undefined, {
|
|
796
|
+
// Phase 5b: the hub restarts via the platform manager (restartHubUnit),
|
|
797
|
+
// modules via restartFn. Record both into one order list so the hub-first
|
|
798
|
+
// invariant is still asserted.
|
|
799
|
+
supervisor: {
|
|
800
|
+
restartHubUnit: (): HubUnitManagerOpResult => {
|
|
801
|
+
restartCalls.push("hub");
|
|
802
|
+
return { outcome: "ok", messages: [] };
|
|
803
|
+
},
|
|
804
|
+
},
|
|
774
805
|
manifestPath: h.manifestPath,
|
|
775
806
|
configDir: h.configDir,
|
|
776
807
|
runner,
|
|
@@ -786,7 +817,8 @@ describe("parachute upgrade", () => {
|
|
|
786
817
|
log: () => {},
|
|
787
818
|
});
|
|
788
819
|
expect(code).toBe(0);
|
|
789
|
-
// Hub goes first so its dispatcher upgrade isn't
|
|
820
|
+
// Hub goes first (manager restart) so its dispatcher upgrade isn't
|
|
821
|
+
// preempted, then the module restarts route through lifecycle.
|
|
790
822
|
expect(restartCalls).toEqual(["hub", "vault"]);
|
|
791
823
|
} finally {
|
|
792
824
|
h.cleanup();
|
|
@@ -841,6 +873,7 @@ describe("parachute upgrade", () => {
|
|
|
841
873
|
const restartCalls: string[] = [];
|
|
842
874
|
const logs: string[] = [];
|
|
843
875
|
const code = await upgrade(undefined, {
|
|
876
|
+
supervisor: okHubUnitSupervisor,
|
|
844
877
|
manifestPath: h.manifestPath,
|
|
845
878
|
configDir: h.configDir,
|
|
846
879
|
runner,
|
|
@@ -920,6 +953,7 @@ describe("parachute upgrade", () => {
|
|
|
920
953
|
};
|
|
921
954
|
|
|
922
955
|
const code = await upgrade("hub", {
|
|
956
|
+
supervisor: okHubUnitSupervisor,
|
|
923
957
|
manifestPath: h.manifestPath,
|
|
924
958
|
configDir: h.configDir,
|
|
925
959
|
runner,
|
|
@@ -961,6 +995,7 @@ describe("parachute upgrade", () => {
|
|
|
961
995
|
};
|
|
962
996
|
|
|
963
997
|
await upgrade("hub", {
|
|
998
|
+
supervisor: okHubUnitSupervisor,
|
|
964
999
|
manifestPath: h.manifestPath,
|
|
965
1000
|
configDir: h.configDir,
|
|
966
1001
|
runner,
|
|
@@ -1003,6 +1038,7 @@ describe("parachute upgrade", () => {
|
|
|
1003
1038
|
};
|
|
1004
1039
|
|
|
1005
1040
|
await upgrade("hub", {
|
|
1041
|
+
supervisor: okHubUnitSupervisor,
|
|
1006
1042
|
manifestPath: h.manifestPath,
|
|
1007
1043
|
configDir: h.configDir,
|
|
1008
1044
|
runner,
|
|
@@ -1045,6 +1081,7 @@ describe("parachute upgrade", () => {
|
|
|
1045
1081
|
const logs: string[] = [];
|
|
1046
1082
|
let restartCalled = false;
|
|
1047
1083
|
const code = await upgrade("hub", {
|
|
1084
|
+
supervisor: okHubUnitSupervisor,
|
|
1048
1085
|
manifestPath: h.manifestPath,
|
|
1049
1086
|
configDir: h.configDir,
|
|
1050
1087
|
runner,
|
|
@@ -1096,6 +1133,7 @@ describe("parachute upgrade", () => {
|
|
|
1096
1133
|
};
|
|
1097
1134
|
|
|
1098
1135
|
const code = await upgrade("hub", {
|
|
1136
|
+
supervisor: okHubUnitSupervisor,
|
|
1099
1137
|
manifestPath: h.manifestPath,
|
|
1100
1138
|
configDir: h.configDir,
|
|
1101
1139
|
runner,
|
|
@@ -1153,6 +1191,7 @@ describe("parachute upgrade", () => {
|
|
|
1153
1191
|
const logs: string[] = [];
|
|
1154
1192
|
let restartedShort: string | undefined;
|
|
1155
1193
|
const code = await upgrade("hub", {
|
|
1194
|
+
supervisor: okHubUnitSupervisor,
|
|
1156
1195
|
manifestPath: h.manifestPath,
|
|
1157
1196
|
configDir: h.configDir,
|
|
1158
1197
|
runner,
|
|
@@ -1168,7 +1207,8 @@ describe("parachute upgrade", () => {
|
|
|
1168
1207
|
log: (l) => logs.push(l),
|
|
1169
1208
|
});
|
|
1170
1209
|
expect(code).toBe(0);
|
|
1171
|
-
|
|
1210
|
+
// Phase 5b: the hub restarts via the platform manager (okHubUnitSupervisor),
|
|
1211
|
+
// not the detached restartFn — the unit-restart path has its own test.
|
|
1172
1212
|
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1173
1213
|
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
1174
1214
|
const joined = logs.join("\n");
|
|
@@ -1200,6 +1240,7 @@ describe("parachute upgrade", () => {
|
|
|
1200
1240
|
};
|
|
1201
1241
|
|
|
1202
1242
|
await upgrade("hub", {
|
|
1243
|
+
supervisor: okHubUnitSupervisor,
|
|
1203
1244
|
manifestPath: h.manifestPath,
|
|
1204
1245
|
configDir: h.configDir,
|
|
1205
1246
|
runner,
|
|
@@ -1218,3 +1259,308 @@ describe("parachute upgrade", () => {
|
|
|
1218
1259
|
}
|
|
1219
1260
|
});
|
|
1220
1261
|
});
|
|
1262
|
+
|
|
1263
|
+
// ---------------------------------------------------------------------------
|
|
1264
|
+
// Phase 4 dual-dispatch (design §5): when a hub UNIT is installed, `upgrade hub`
|
|
1265
|
+
// rewrites the binary as usual then RESTARTS THE UNIT via the platform manager
|
|
1266
|
+
// (`restartHubUnit`), NOT the detached `restartFn` (stopHub/ensureHubRunning).
|
|
1267
|
+
// The manager tears down the old hub (children die) and starts the new binary,
|
|
1268
|
+
// which re-boots every module. No-unit → the unchanged detached restart.
|
|
1269
|
+
// ---------------------------------------------------------------------------
|
|
1270
|
+
|
|
1271
|
+
describe("Phase 4 upgrade-hub dual-dispatch", () => {
|
|
1272
|
+
test("upgrade hub unit-managed → binary rewrite + restartHubUnit (manager), NOT detached restartFn", async () => {
|
|
1273
|
+
const h = makeHarness();
|
|
1274
|
+
try {
|
|
1275
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1276
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.1" });
|
|
1277
|
+
const seenCmd: string[][] = [];
|
|
1278
|
+
const runner: UpgradeRunner = {
|
|
1279
|
+
async run(cmd) {
|
|
1280
|
+
seenCmd.push([...cmd]);
|
|
1281
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1282
|
+
// Channel auto-detected as @rc from the installed -rc version.
|
|
1283
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.2" });
|
|
1284
|
+
}
|
|
1285
|
+
return 0;
|
|
1286
|
+
},
|
|
1287
|
+
async capture(cmd) {
|
|
1288
|
+
// Not a git checkout → npm-install branch.
|
|
1289
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1290
|
+
return { code: 128, stdout: "fatal: not a git repository\n" };
|
|
1291
|
+
}
|
|
1292
|
+
return { code: 0, stdout: "" };
|
|
1293
|
+
},
|
|
1294
|
+
};
|
|
1295
|
+
|
|
1296
|
+
let restartFnCalled = false;
|
|
1297
|
+
let restartHubUnitCalls = 0;
|
|
1298
|
+
const logs: string[] = [];
|
|
1299
|
+
const code = await upgrade("hub", {
|
|
1300
|
+
manifestPath: h.manifestPath,
|
|
1301
|
+
configDir: h.configDir,
|
|
1302
|
+
runner,
|
|
1303
|
+
findGlobalInstall: (pkg) =>
|
|
1304
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1305
|
+
// The detached restart path must NOT be taken on the unit arm.
|
|
1306
|
+
restartFn: async () => {
|
|
1307
|
+
restartFnCalled = true;
|
|
1308
|
+
return 0;
|
|
1309
|
+
},
|
|
1310
|
+
// Avoid a real registry round-trip in the downgrade guard.
|
|
1311
|
+
resolveChannelVersion: async () => null,
|
|
1312
|
+
supervisor: {
|
|
1313
|
+
restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => {
|
|
1314
|
+
restartHubUnitCalls++;
|
|
1315
|
+
return { outcome: "ok", messages: [] };
|
|
1316
|
+
},
|
|
1317
|
+
},
|
|
1318
|
+
log: (l) => logs.push(l),
|
|
1319
|
+
});
|
|
1320
|
+
expect(code).toBe(0);
|
|
1321
|
+
// The binary was rewritten via bun add -g @rc…
|
|
1322
|
+
const addCall = seenCmd.find((c) => c[0] === "bun" && c[1] === "add");
|
|
1323
|
+
expect(addCall).toEqual(["bun", "add", "-g", "@openparachute/hub@rc"]);
|
|
1324
|
+
// …and the restart went through the platform manager, not the detached PID path.
|
|
1325
|
+
expect(restartHubUnitCalls).toBe(1);
|
|
1326
|
+
expect(restartFnCalled).toBe(false);
|
|
1327
|
+
expect(logs.join("\n")).toMatch(/restarted the hub unit/);
|
|
1328
|
+
} finally {
|
|
1329
|
+
h.cleanup();
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test("upgrade hub NO unit → restartHubUnit reports no-unit, surfaced as a failure", async () => {
|
|
1334
|
+
// Phase 5b: the detached restart arm is retired. `upgrade hub` always
|
|
1335
|
+
// restarts the hub UNIT via the platform manager; on a box with no unit the
|
|
1336
|
+
// manager op returns `no-unit` (after the binary rewrite), which the verb
|
|
1337
|
+
// surfaces as a non-zero exit with the manager's message — never a detached
|
|
1338
|
+
// spawn.
|
|
1339
|
+
const h = makeHarness();
|
|
1340
|
+
try {
|
|
1341
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1342
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.8" });
|
|
1343
|
+
const runner: UpgradeRunner = {
|
|
1344
|
+
async run(cmd) {
|
|
1345
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1346
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.5.9" });
|
|
1347
|
+
}
|
|
1348
|
+
return 0;
|
|
1349
|
+
},
|
|
1350
|
+
async capture(cmd) {
|
|
1351
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1352
|
+
return { code: 128, stdout: "" };
|
|
1353
|
+
}
|
|
1354
|
+
return { code: 0, stdout: "" };
|
|
1355
|
+
},
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
const logs: string[] = [];
|
|
1359
|
+
const code = await upgrade("hub", {
|
|
1360
|
+
manifestPath: h.manifestPath,
|
|
1361
|
+
configDir: h.configDir,
|
|
1362
|
+
runner,
|
|
1363
|
+
findGlobalInstall: (pkg) =>
|
|
1364
|
+
pkg === "@openparachute/hub" ? join(hubInstallDir, "package.json") : null,
|
|
1365
|
+
resolveChannelVersion: async () => null,
|
|
1366
|
+
supervisor: {
|
|
1367
|
+
restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => ({
|
|
1368
|
+
outcome: "no-unit",
|
|
1369
|
+
messages: ["no hub unit installed — run `parachute migrate` to install it"],
|
|
1370
|
+
}),
|
|
1371
|
+
},
|
|
1372
|
+
log: (l) => logs.push(l),
|
|
1373
|
+
});
|
|
1374
|
+
expect(code).toBe(1);
|
|
1375
|
+
expect(logs.join("\n")).toMatch(/no hub unit installed/);
|
|
1376
|
+
} finally {
|
|
1377
|
+
h.cleanup();
|
|
1378
|
+
}
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
test("sweep stays hub-first; hub restart uses the manager, module restarts route through lifecycle", async () => {
|
|
1382
|
+
const h = makeHarness();
|
|
1383
|
+
try {
|
|
1384
|
+
// Hub install dir + a vault module in services.json — sweep upgrades both.
|
|
1385
|
+
const hubInstallDir = join(h.installRoot, "hub");
|
|
1386
|
+
writePackageJson(hubInstallDir, { name: "@openparachute/hub", version: "0.6.3-rc.1" });
|
|
1387
|
+
const vaultInstallDir = join(h.installRoot, "vault");
|
|
1388
|
+
writePackageJson(vaultInstallDir, { name: "@openparachute/vault", version: "0.6.3-rc.1" });
|
|
1389
|
+
seedVault(h.manifestPath, vaultInstallDir, "0.6.3-rc.1");
|
|
1390
|
+
|
|
1391
|
+
const runner: UpgradeRunner = {
|
|
1392
|
+
async run(cmd) {
|
|
1393
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1394
|
+
const spec = cmd[3] ?? "";
|
|
1395
|
+
if (spec.startsWith("@openparachute/hub")) {
|
|
1396
|
+
writePackageJson(hubInstallDir, {
|
|
1397
|
+
name: "@openparachute/hub",
|
|
1398
|
+
version: "0.6.3-rc.2",
|
|
1399
|
+
});
|
|
1400
|
+
} else if (spec.startsWith("@openparachute/vault")) {
|
|
1401
|
+
writePackageJson(vaultInstallDir, {
|
|
1402
|
+
name: "@openparachute/vault",
|
|
1403
|
+
version: "0.6.3-rc.2",
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return 0;
|
|
1408
|
+
},
|
|
1409
|
+
async capture(cmd) {
|
|
1410
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1411
|
+
return { code: 128, stdout: "" };
|
|
1412
|
+
}
|
|
1413
|
+
return { code: 0, stdout: "" };
|
|
1414
|
+
},
|
|
1415
|
+
};
|
|
1416
|
+
|
|
1417
|
+
const restartOrder: string[] = [];
|
|
1418
|
+
let restartHubUnitCalls = 0;
|
|
1419
|
+
const code = await upgrade(undefined, {
|
|
1420
|
+
manifestPath: h.manifestPath,
|
|
1421
|
+
configDir: h.configDir,
|
|
1422
|
+
runner,
|
|
1423
|
+
findGlobalInstall: (pkg) => {
|
|
1424
|
+
if (pkg === "@openparachute/hub") return join(hubInstallDir, "package.json");
|
|
1425
|
+
if (pkg === "@openparachute/vault") return join(vaultInstallDir, "package.json");
|
|
1426
|
+
return null;
|
|
1427
|
+
},
|
|
1428
|
+
// Module restarts (vault) go through lifecycle's restart with a
|
|
1429
|
+
// supervisor block; the stub records the order so we can assert hub-first.
|
|
1430
|
+
restartFn: async (svc) => {
|
|
1431
|
+
restartOrder.push(svc);
|
|
1432
|
+
return 0;
|
|
1433
|
+
},
|
|
1434
|
+
resolveChannelVersion: async () => null,
|
|
1435
|
+
supervisor: {
|
|
1436
|
+
restartHubUnit: (_deps: HubUnitDeps): HubUnitManagerOpResult => {
|
|
1437
|
+
restartHubUnitCalls++;
|
|
1438
|
+
restartOrder.push("hub-unit");
|
|
1439
|
+
return { outcome: "ok", messages: [] };
|
|
1440
|
+
},
|
|
1441
|
+
},
|
|
1442
|
+
log: () => {},
|
|
1443
|
+
});
|
|
1444
|
+
expect(code).toBe(0);
|
|
1445
|
+
// Hub-first: the hub unit restart precedes the vault module restart.
|
|
1446
|
+
expect(restartOrder[0]).toBe("hub-unit");
|
|
1447
|
+
expect(restartOrder).toContain("vault");
|
|
1448
|
+
expect(restartOrder.indexOf("hub-unit")).toBeLessThan(restartOrder.indexOf("vault"));
|
|
1449
|
+
expect(restartHubUnitCalls).toBe(1);
|
|
1450
|
+
} finally {
|
|
1451
|
+
h.cleanup();
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
// ---------------------------------------------------------------------------
|
|
1457
|
+
// Phase 5b nit: the module-target restart leg of `upgrade` must thread the SAME
|
|
1458
|
+
// opts a bare `parachute restart <svc>` does — `supervisor: {}` (real
|
|
1459
|
+
// `isHubUnitInstalled` probe, NOT a forced `unitInstalled: true`) +
|
|
1460
|
+
// `migrateOffer: { enabled: true }`. On a NO-UNIT box that means the module
|
|
1461
|
+
// restart surfaces the actionable "run `parachute migrate --to-supervised`"
|
|
1462
|
+
// error / fires the auto-offer, NOT a bare connection-refused from
|
|
1463
|
+
// `driveModuleOp`. The other upgrade tests stub `restartFn: async () => 0`, so
|
|
1464
|
+
// the supervised arm was never exercised here — this closes that gap by driving
|
|
1465
|
+
// the REAL `lifecycle.restart`.
|
|
1466
|
+
// ---------------------------------------------------------------------------
|
|
1467
|
+
|
|
1468
|
+
describe("Phase 5b: upgrade module-restart on a no-unit box", () => {
|
|
1469
|
+
test("module restart threads supervisor:{} + migrateOffer (no forced unitInstalled), surfaces actionable migrate error — NOT a bare connection-refused", async () => {
|
|
1470
|
+
const h = makeHarness();
|
|
1471
|
+
try {
|
|
1472
|
+
const installDir = join(h.installRoot, "vault");
|
|
1473
|
+
writePackageJson(installDir, { name: "@openparachute/vault", version: "0.4.0" });
|
|
1474
|
+
seedVault(h.manifestPath, installDir, "0.4.0");
|
|
1475
|
+
|
|
1476
|
+
const runner: UpgradeRunner = {
|
|
1477
|
+
async run(cmd) {
|
|
1478
|
+
// `bun add -g` rewrites the package.json with a new version → the
|
|
1479
|
+
// module restart leg (`restartTarget`) is reached.
|
|
1480
|
+
if (cmd[0] === "bun" && cmd[1] === "add" && cmd[2] === "-g") {
|
|
1481
|
+
writePackageJson(installDir, { name: "@openparachute/vault", version: "0.5.0" });
|
|
1482
|
+
}
|
|
1483
|
+
return 0;
|
|
1484
|
+
},
|
|
1485
|
+
async capture(cmd) {
|
|
1486
|
+
// Not a git checkout → npm-install branch.
|
|
1487
|
+
if (cmd[1] === "rev-parse" && cmd[2] === "--is-inside-work-tree") {
|
|
1488
|
+
return { code: 128, stdout: "fatal: not a git repository\n" };
|
|
1489
|
+
}
|
|
1490
|
+
return { code: 0, stdout: "" };
|
|
1491
|
+
},
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
// A NO-UNIT box: force `isHubUnitInstalled` → false deterministically
|
|
1495
|
+
// (regardless of the test host) by stubbing the unit-file existence probe.
|
|
1496
|
+
const noUnitDeps: HubUnitDeps = { ...defaultHubUnitDeps, exists: () => false };
|
|
1497
|
+
|
|
1498
|
+
// The supervised arm's loopback module-ops call MUST NOT run on a no-unit
|
|
1499
|
+
// box. If the regressed code forced `unitInstalled: true`, the verb would
|
|
1500
|
+
// skip the probe and hit this — surfacing a bare connection-refused.
|
|
1501
|
+
let driveModuleOpCalled = false;
|
|
1502
|
+
const refuse = async (): Promise<never> => {
|
|
1503
|
+
driveModuleOpCalled = true;
|
|
1504
|
+
throw new Error("connect ECONNREFUSED 127.0.0.1:1939");
|
|
1505
|
+
};
|
|
1506
|
+
|
|
1507
|
+
// Capture the opts `restartTarget` hands `lifecycle.restart`, then delegate
|
|
1508
|
+
// to the REAL `lifecycle.restart` so the no-unit handling actually runs.
|
|
1509
|
+
// The offer is overridden to a deterministic `declined` so the outcome
|
|
1510
|
+
// doesn't depend on the host's stdin-TTY / prior-detached state.
|
|
1511
|
+
let seenOpts: LifecycleOpts | undefined;
|
|
1512
|
+
let offerCalls = 0;
|
|
1513
|
+
const restartFn = async (svc: string, opts: LifecycleOpts): Promise<number> => {
|
|
1514
|
+
seenOpts = opts;
|
|
1515
|
+
return await lifecycleRestart(svc, {
|
|
1516
|
+
...opts,
|
|
1517
|
+
supervisor: {
|
|
1518
|
+
...opts.supervisor,
|
|
1519
|
+
hubUnitDeps: noUnitDeps,
|
|
1520
|
+
driveModuleOp: refuse,
|
|
1521
|
+
},
|
|
1522
|
+
migrateOffer: {
|
|
1523
|
+
enabled: opts.migrateOffer?.enabled ?? false,
|
|
1524
|
+
offer: async (): Promise<MigrateOfferResult> => {
|
|
1525
|
+
offerCalls++;
|
|
1526
|
+
return { outcome: "declined" };
|
|
1527
|
+
},
|
|
1528
|
+
},
|
|
1529
|
+
});
|
|
1530
|
+
};
|
|
1531
|
+
|
|
1532
|
+
const logs: string[] = [];
|
|
1533
|
+
const code = await upgrade("vault", {
|
|
1534
|
+
manifestPath: h.manifestPath,
|
|
1535
|
+
configDir: h.configDir,
|
|
1536
|
+
runner,
|
|
1537
|
+
findGlobalInstall: () => join(installDir, "package.json"),
|
|
1538
|
+
restartFn,
|
|
1539
|
+
// Upgrade-side supervisor seam: feeds `r.hubUnitDeps`, which
|
|
1540
|
+
// `restartTarget` threads into `lifecycle.restart`'s supervisor block.
|
|
1541
|
+
supervisor: { hubUnitDeps: noUnitDeps },
|
|
1542
|
+
log: (l) => logs.push(l),
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
// The package rewrite + restart attempt happened (non-zero because the box
|
|
1546
|
+
// is un-migrated and the offer was declined — a clean, actionable failure).
|
|
1547
|
+
expect(code).toBe(1);
|
|
1548
|
+
|
|
1549
|
+
// The contract fix: `restartTarget` passes `supervisor` WITHOUT forcing
|
|
1550
|
+
// `unitInstalled: true`, plus an ENABLED migrate offer — exactly what a
|
|
1551
|
+
// bare `parachute restart <svc>` threads.
|
|
1552
|
+
expect(seenOpts).toBeDefined();
|
|
1553
|
+
expect(seenOpts?.supervisor).toBeDefined();
|
|
1554
|
+
expect(seenOpts?.supervisor?.unitInstalled).toBeUndefined();
|
|
1555
|
+
expect(seenOpts?.migrateOffer?.enabled).toBe(true);
|
|
1556
|
+
|
|
1557
|
+
// The no-unit handling ran: the auto-offer fired (real probe → no unit),
|
|
1558
|
+
// and the supervised loopback call was NEVER reached (no connection-refused).
|
|
1559
|
+
expect(offerCalls).toBe(1);
|
|
1560
|
+
expect(driveModuleOpCalled).toBe(false);
|
|
1561
|
+
expect(logs.join("\n")).not.toMatch(/ECONNREFUSED|connection refused/i);
|
|
1562
|
+
} finally {
|
|
1563
|
+
h.cleanup();
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
});
|