@serfab/cadre-host 1.12.0 → 1.14.0
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 +332 -423
- package/dist/bin/host.d.ts +6 -3
- package/dist/bin/host.js +301 -505
- package/dist/bin/host.js.map +1 -1
- package/dist/bin/join-output.d.ts +56 -0
- package/dist/bin/join-output.js +92 -0
- package/dist/bin/join-output.js.map +1 -0
- package/dist/bin/nat-output.d.ts +49 -0
- package/dist/bin/nat-output.js +127 -0
- package/dist/bin/nat-output.js.map +1 -0
- package/dist/hosted/claim-details.d.ts +44 -0
- package/dist/hosted/claim-details.js +66 -0
- package/dist/hosted/claim-details.js.map +1 -0
- package/dist/hosted/hosted-node-service.d.ts +318 -0
- package/dist/hosted/hosted-node-service.js +666 -0
- package/dist/hosted/hosted-node-service.js.map +1 -0
- package/dist/hosted/hosted-node-store.d.ts +40 -0
- package/dist/hosted/hosted-node-store.js +126 -0
- package/dist/hosted/hosted-node-store.js.map +1 -0
- package/dist/{donation/donation-supervisor.d.ts → hosted/hosted-node-supervisor.d.ts} +70 -53
- package/dist/hosted/hosted-node-supervisor.js +396 -0
- package/dist/hosted/hosted-node-supervisor.js.map +1 -0
- package/dist/hosted/hosted-node-watcher.d.ts +83 -0
- package/dist/hosted/hosted-node-watcher.js +224 -0
- package/dist/hosted/hosted-node-watcher.js.map +1 -0
- package/dist/hosted/index.d.ts +16 -0
- package/dist/hosted/index.js +13 -0
- package/dist/hosted/index.js.map +1 -0
- package/dist/hosted/node-status.d.ts +35 -0
- package/dist/hosted/node-status.js +61 -0
- package/dist/hosted/node-status.js.map +1 -0
- package/dist/hosted/types.d.ts +134 -0
- package/dist/hosted/types.js +22 -0
- package/dist/hosted/types.js.map +1 -0
- package/dist/index.d.ts +12 -24
- package/dist/index.js +9 -19
- package/dist/index.js.map +1 -1
- package/dist/installer/config.d.ts +4 -34
- package/dist/installer/config.js +3 -18
- package/dist/installer/config.js.map +1 -1
- package/dist/installer/index.d.ts +7 -19
- package/dist/installer/index.js +14 -69
- package/dist/installer/index.js.map +1 -1
- package/dist/installer/paths.d.ts +0 -2
- package/dist/installer/paths.js +0 -4
- package/dist/installer/paths.js.map +1 -1
- package/dist/installer/wizard.d.ts +0 -7
- package/dist/installer/wizard.js +1 -8
- package/dist/installer/wizard.js.map +1 -1
- package/dist/nat/address-resolver.d.ts +18 -17
- package/dist/nat/address-resolver.js +79 -25
- package/dist/nat/address-resolver.js.map +1 -1
- package/dist/nat/address-watch.d.ts +37 -0
- package/dist/nat/address-watch.js +84 -0
- package/dist/nat/address-watch.js.map +1 -0
- package/dist/nat/ddns/duckdns.d.ts +2 -1
- package/dist/nat/ddns/duckdns.js +5 -1
- package/dist/nat/ddns/duckdns.js.map +1 -1
- package/dist/nat/ddns/index.d.ts +6 -0
- package/dist/nat/ddns/index.js.map +1 -1
- package/dist/nat/index.d.ts +12 -9
- package/dist/nat/index.js +6 -5
- package/dist/nat/index.js.map +1 -1
- package/dist/nat/nat-service.d.ts +203 -77
- package/dist/nat/nat-service.js +602 -237
- package/dist/nat/nat-service.js.map +1 -1
- package/dist/nat/nat-store.d.ts +12 -5
- package/dist/nat/nat-store.js +114 -30
- package/dist/nat/nat-store.js.map +1 -1
- package/dist/nat/port-mapper.d.ts +83 -79
- package/dist/nat/port-mapper.js +148 -189
- package/dist/nat/port-mapper.js.map +1 -1
- package/dist/nat/reachability.d.ts +31 -15
- package/dist/nat/reachability.js +97 -22
- package/dist/nat/reachability.js.map +1 -1
- package/dist/nat/types.d.ts +78 -27
- package/dist/nat/types.js +8 -5
- package/dist/nat/types.js.map +1 -1
- package/dist/orchestrator/host-process-orchestrator.d.ts +29 -71
- package/dist/orchestrator/host-process-orchestrator.js +78 -218
- package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
- package/dist/{installer/identity.d.ts → orchestrator/identity-file.d.ts} +5 -5
- package/dist/{installer/identity.js → orchestrator/identity-file.js} +6 -6
- package/dist/orchestrator/identity-file.js.map +1 -0
- package/dist/orchestrator/index.d.ts +2 -3
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/node-identity.d.ts +6 -6
- package/dist/orchestrator/node-identity.js +7 -7
- package/dist/orchestrator/node-identity.js.map +1 -1
- package/dist/orchestrator/port-allocator.d.ts +5 -6
- package/dist/orchestrator/port-allocator.js +9 -11
- package/dist/orchestrator/port-allocator.js.map +1 -1
- package/dist/orchestrator/state-store.d.ts +3 -5
- package/dist/orchestrator/state-store.js +2 -10
- package/dist/orchestrator/state-store.js.map +1 -1
- package/dist/orchestrator/types.d.ts +33 -22
- package/dist/orchestrator/types.js.map +1 -1
- package/dist/server/error-handler.d.ts +5 -25
- package/dist/server/error-handler.js +11 -74
- package/dist/server/error-handler.js.map +1 -1
- package/dist/server/events/types.d.ts +7 -11
- package/dist/server/events/types.js +2 -1
- package/dist/server/events/types.js.map +1 -1
- package/dist/server/index.d.ts +11 -37
- package/dist/server/index.js +30 -48
- package/dist/server/index.js.map +1 -1
- package/dist/server/routes/hosted-nodes.d.ts +28 -0
- package/dist/server/routes/hosted-nodes.js +68 -0
- package/dist/server/routes/hosted-nodes.js.map +1 -0
- package/dist/server/routes/nat.d.ts +4 -1
- package/dist/server/routes/nat.js +13 -2
- package/dist/server/routes/nat.js.map +1 -1
- package/dist/server/routes/nodes.d.ts +6 -20
- package/dist/server/routes/nodes.js +7 -86
- package/dist/server/routes/nodes.js.map +1 -1
- package/dist/server/routes/settings.d.ts +2 -7
- package/dist/server/routes/settings.js +3 -7
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/status.d.ts +2 -25
- package/dist/server/routes/status.js +1 -11
- package/dist/server/routes/status.js.map +1 -1
- package/dist/server/server.js +8 -8
- package/dist/server/settings-store.d.ts +2 -2
- package/dist/server/settings-store.js +2 -2
- package/dist/server/static.js +1 -2
- package/dist/server/static.js.map +1 -1
- package/dist/ui/assets/index-4FWuU8ye.js +19 -0
- package/dist/ui/assets/index-4FWuU8ye.js.map +1 -0
- package/dist/ui/assets/index-Di9Q4d01.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/update/index.d.ts +2 -2
- package/dist/update/index.js +2 -2
- package/dist/update/store.d.ts +1 -1
- package/dist/update/store.js +1 -1
- package/dist/update/types.d.ts +1 -1
- package/dist/update/types.js +1 -1
- package/package.json +5 -6
- package/src/__tests__/cli-nat.smoke.test.ts +219 -212
- package/src/__tests__/cli.smoke.test.ts +1 -1
- package/src/__tests__/global-setup.ts +2 -3
- package/src/__tests__/orchestrator-claim-secret.test.ts +112 -0
- package/src/__tests__/orchestrator-handle-contract.ts +134 -134
- package/src/__tests__/orchestrator-node-identity.test.ts +182 -203
- package/src/__tests__/orchestrator-ports.test.ts +124 -143
- package/src/__tests__/orchestrator-push.test.ts +138 -146
- package/src/__tests__/orchestrator.test.ts +1019 -1063
- package/src/bin/host.ts +368 -553
- package/src/bin/join-output.ts +120 -0
- package/src/bin/nat-output.ts +177 -0
- package/src/{donation → hosted}/__tests__/fake-orchestrator.test.ts +173 -173
- package/src/{donation → hosted}/__tests__/fake-orchestrator.ts +265 -243
- package/src/hosted/__tests__/hosted-node-service.test.ts +609 -0
- package/src/hosted/__tests__/hosted-node-supervisor.test.ts +474 -0
- package/src/hosted/__tests__/test-invitation.ts +33 -0
- package/src/hosted/claim-details.ts +96 -0
- package/src/hosted/hosted-node-service.ts +795 -0
- package/src/hosted/hosted-node-store.ts +144 -0
- package/src/hosted/hosted-node-supervisor.ts +439 -0
- package/src/hosted/hosted-node-watcher.ts +250 -0
- package/src/hosted/index.ts +53 -0
- package/src/hosted/node-status.ts +71 -0
- package/src/hosted/types.ts +153 -0
- package/src/index.ts +72 -91
- package/src/installer/__tests__/config.test.ts +15 -40
- package/src/installer/__tests__/installer.smoke.test.ts +5 -86
- package/src/installer/__tests__/wizard.test.ts +56 -64
- package/src/installer/config.ts +135 -175
- package/src/installer/index.ts +15 -83
- package/src/installer/paths.ts +104 -109
- package/src/installer/wizard.ts +87 -103
- package/src/nat/__tests__/address-resolver.test.ts +107 -82
- package/src/nat/__tests__/nat-service.test.ts +433 -408
- package/src/nat/__tests__/nat-store.test.ts +127 -93
- package/src/nat/__tests__/port-mapper.test.ts +30 -191
- package/src/nat/__tests__/reachability.test.ts +96 -13
- package/src/nat/address-resolver.ts +94 -52
- package/src/nat/address-watch.ts +92 -0
- package/src/nat/ddns/duckdns.ts +5 -1
- package/src/nat/ddns/index.ts +6 -0
- package/src/nat/index.ts +87 -71
- package/src/nat/nat-service.ts +889 -501
- package/src/nat/nat-store.ts +237 -154
- package/src/nat/port-mapper.ts +241 -329
- package/src/nat/reachability.ts +134 -25
- package/src/nat/types.ts +209 -159
- package/src/{installer/__tests__/identity.test.ts → orchestrator/__tests__/identity-file.test.ts} +34 -34
- package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
- package/src/orchestrator/host-process-orchestrator.ts +81 -243
- package/src/{installer/identity.ts → orchestrator/identity-file.ts} +56 -56
- package/src/orchestrator/index.ts +9 -10
- package/src/orchestrator/node-identity.ts +41 -41
- package/src/orchestrator/port-allocator.ts +71 -73
- package/src/orchestrator/state-store.ts +76 -90
- package/src/orchestrator/types.ts +34 -23
- package/src/server/__tests__/bus.test.ts +38 -38
- package/src/server/__tests__/error-handler.test.ts +81 -118
- package/src/server/__tests__/fakes.ts +40 -44
- package/src/server/__tests__/hosted-nodes-route.test.ts +160 -0
- package/src/server/__tests__/nodes-route.test.ts +114 -283
- package/src/server/__tests__/publishers.test.ts +193 -180
- package/src/server/__tests__/server.smoke.test.ts +134 -223
- package/src/server/__tests__/settings-route.test.ts +179 -195
- package/src/server/__tests__/sse-route.test.ts +119 -119
- package/src/server/__tests__/status-route.test.ts +82 -121
- package/src/server/error-handler.ts +112 -178
- package/src/server/events/types.ts +20 -24
- package/src/server/index.ts +223 -267
- package/src/server/routes/hosted-nodes.ts +83 -0
- package/src/server/routes/nat.ts +75 -63
- package/src/server/routes/nodes.ts +96 -188
- package/src/server/routes/settings.ts +115 -123
- package/src/server/routes/status.ts +102 -134
- package/src/server/server.ts +8 -8
- package/src/server/settings-store.ts +2 -2
- package/src/server/static.ts +1 -2
- package/src/update/index.ts +2 -2
- package/src/update/store.ts +1 -1
- package/src/update/types.ts +1 -1
- package/dist/auth/duration.d.ts +0 -1
- package/dist/auth/duration.js +0 -32
- package/dist/auth/duration.js.map +0 -1
- package/dist/auth/index.d.ts +0 -11
- package/dist/auth/index.js +0 -9
- package/dist/auth/index.js.map +0 -1
- package/dist/auth/self-label.d.ts +0 -36
- package/dist/auth/self-label.js +0 -57
- package/dist/auth/self-label.js.map +0 -1
- package/dist/auth/trust-circle-store.d.ts +0 -26
- package/dist/auth/trust-circle-store.js +0 -129
- package/dist/auth/trust-circle-store.js.map +0 -1
- package/dist/auth/trust-circle.d.ts +0 -156
- package/dist/auth/trust-circle.js +0 -307
- package/dist/auth/trust-circle.js.map +0 -1
- package/dist/auth/types.d.ts +0 -84
- package/dist/auth/types.js +0 -24
- package/dist/auth/types.js.map +0 -1
- package/dist/donation/donation-service.d.ts +0 -389
- package/dist/donation/donation-service.js +0 -828
- package/dist/donation/donation-service.js.map +0 -1
- package/dist/donation/donation-store.d.ts +0 -51
- package/dist/donation/donation-store.js +0 -152
- package/dist/donation/donation-store.js.map +0 -1
- package/dist/donation/donation-supervisor.js +0 -364
- package/dist/donation/donation-supervisor.js.map +0 -1
- package/dist/donation/grant-service.d.ts +0 -80
- package/dist/donation/grant-service.js +0 -210
- package/dist/donation/grant-service.js.map +0 -1
- package/dist/donation/grant-store.d.ts +0 -38
- package/dist/donation/grant-store.js +0 -135
- package/dist/donation/grant-store.js.map +0 -1
- package/dist/donation/index.d.ts +0 -20
- package/dist/donation/index.js +0 -17
- package/dist/donation/index.js.map +0 -1
- package/dist/donation/types.d.ts +0 -224
- package/dist/donation/types.js +0 -40
- package/dist/donation/types.js.map +0 -1
- package/dist/installer/identity.js.map +0 -1
- package/dist/owner/index.d.ts +0 -11
- package/dist/owner/index.js +0 -11
- package/dist/owner/index.js.map +0 -1
- package/dist/owner/owner-node-client.d.ts +0 -110
- package/dist/owner/owner-node-client.js +0 -184
- package/dist/owner/owner-node-client.js.map +0 -1
- package/dist/server/routes/bootstrap-node-validation.d.ts +0 -115
- package/dist/server/routes/bootstrap-node-validation.js +0 -197
- package/dist/server/routes/bootstrap-node-validation.js.map +0 -1
- package/dist/server/routes/grants-admin.d.ts +0 -29
- package/dist/server/routes/grants-admin.js +0 -58
- package/dist/server/routes/grants-admin.js.map +0 -1
- package/dist/server/routes/grants.d.ts +0 -28
- package/dist/server/routes/grants.js +0 -165
- package/dist/server/routes/grants.js.map +0 -1
- package/dist/server/routes/provision-request-validation.d.ts +0 -45
- package/dist/server/routes/provision-request-validation.js +0 -97
- package/dist/server/routes/provision-request-validation.js.map +0 -1
- package/dist/server/routes/strands.d.ts +0 -20
- package/dist/server/routes/strands.js +0 -38
- package/dist/server/routes/strands.js.map +0 -1
- package/dist/server/routes/trust-circle.d.ts +0 -17
- package/dist/server/routes/trust-circle.js +0 -35
- package/dist/server/routes/trust-circle.js.map +0 -1
- package/dist/strands/index.d.ts +0 -7
- package/dist/strands/index.js +0 -6
- package/dist/strands/index.js.map +0 -1
- package/dist/strands/strand-service.d.ts +0 -50
- package/dist/strands/strand-service.js +0 -103
- package/dist/strands/strand-service.js.map +0 -1
- package/dist/strands/types.d.ts +0 -93
- package/dist/strands/types.js +0 -23
- package/dist/strands/types.js.map +0 -1
- package/dist/ui/assets/index-C6nELNfC.js +0 -23
- package/dist/ui/assets/index-C6nELNfC.js.map +0 -1
- package/dist/ui/assets/index-TtIYSeZO.css +0 -1
- package/src/__tests__/cli-invite.smoke.test.ts +0 -192
- package/src/__tests__/host-owner.smoke.test.ts +0 -123
- package/src/__tests__/orchestrator-owner.test.ts +0 -234
- package/src/__tests__/orchestrator-pin-keys.test.ts +0 -152
- package/src/auth/__tests__/duration.test.ts +0 -28
- package/src/auth/__tests__/self-label.test.ts +0 -85
- package/src/auth/__tests__/trust-circle-integration.test.ts +0 -152
- package/src/auth/__tests__/trust-circle-store.test.ts +0 -115
- package/src/auth/__tests__/trust-circle.test.ts +0 -445
- package/src/auth/duration.ts +0 -33
- package/src/auth/index.ts +0 -21
- package/src/auth/self-label.ts +0 -74
- package/src/auth/trust-circle-store.ts +0 -155
- package/src/auth/trust-circle.ts +0 -395
- package/src/auth/types.ts +0 -99
- package/src/donation/__tests__/donation-service.test.ts +0 -1193
- package/src/donation/__tests__/donation-supervisor.test.ts +0 -548
- package/src/donation/__tests__/grant-service.test.ts +0 -213
- package/src/donation/__tests__/grant-store.test.ts +0 -113
- package/src/donation/donation-service.ts +0 -1033
- package/src/donation/donation-store.ts +0 -176
- package/src/donation/donation-supervisor.ts +0 -411
- package/src/donation/grant-service.ts +0 -252
- package/src/donation/grant-store.ts +0 -152
- package/src/donation/index.ts +0 -65
- package/src/donation/types.ts +0 -260
- package/src/owner/__tests__/owner-node-client.test.ts +0 -194
- package/src/owner/index.ts +0 -12
- package/src/owner/owner-node-client.ts +0 -259
- package/src/server/__tests__/bootstrap-node-validation.test.ts +0 -138
- package/src/server/__tests__/grants-admin-route.test.ts +0 -171
- package/src/server/__tests__/grants-route.test.ts +0 -394
- package/src/server/__tests__/strands-route.test.ts +0 -234
- package/src/server/routes/bootstrap-node-validation.ts +0 -206
- package/src/server/routes/grants-admin.ts +0 -74
- package/src/server/routes/grants.ts +0 -193
- package/src/server/routes/provision-request-validation.ts +0 -105
- package/src/server/routes/strands.ts +0 -54
- package/src/server/routes/trust-circle.ts +0 -49
- package/src/strands/__tests__/strand-service.test.ts +0 -165
- package/src/strands/index.ts +0 -14
- package/src/strands/strand-service.ts +0 -135
- package/src/strands/types.ts +0 -102
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable output of the `cadre-host join` and `cadre-host node` commands:
|
|
3
|
+
* the claim payload as a terminal QR code and as text, the warning when the node
|
|
4
|
+
* is not reachable from outside, the claimed line, the outcome of a join by
|
|
5
|
+
* invitation, and the node list.
|
|
6
|
+
*
|
|
7
|
+
* The types are loose mirrors of `ClaimDetails` and `HostedNodeView`
|
|
8
|
+
* (`../hosted/`): the body arrives over HTTP from whichever cadre-host version is
|
|
9
|
+
* running, so every field is optional and a missing one prints as `?`.
|
|
10
|
+
*
|
|
11
|
+
* Streams: the payload text goes to stdout ALONE, so `cadre-host join --no-qr`
|
|
12
|
+
* can be piped or copied; everything else (the QR, the instructions, the
|
|
13
|
+
* warning, the claimed line) goes to stderr. The payload carries the claim
|
|
14
|
+
* secret by design; nothing else printed here contains it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
export interface ClaimDetailsLike {
|
|
20
|
+
payload?: string;
|
|
21
|
+
peerId?: string;
|
|
22
|
+
multiaddrs?: string[];
|
|
23
|
+
reachability?: { verdict?: string; reason?: string | null } | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface HostedNodeLike {
|
|
27
|
+
id?: string;
|
|
28
|
+
join?: { kind?: string };
|
|
29
|
+
status?: string;
|
|
30
|
+
partyId?: string;
|
|
31
|
+
ownerKey?: string;
|
|
32
|
+
memberPeerId?: string;
|
|
33
|
+
connected?: boolean;
|
|
34
|
+
error?: string;
|
|
35
|
+
retryable?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const requireForQr = createRequire(import.meta.url);
|
|
39
|
+
|
|
40
|
+
/** Print the QR code (unless `qr` is false) and the instructions on stderr, then the payload text on stdout. */
|
|
41
|
+
export function printClaimPayload(details: ClaimDetailsLike, opts: { qr: boolean }): void {
|
|
42
|
+
console.error('Scan this with the Sereus app on the phone that owns the cadre, or paste the text into it.');
|
|
43
|
+
if (opts.qr && details.payload) renderQr(details.payload);
|
|
44
|
+
console.error(`Node ${details.peerId ?? '?'} at:`);
|
|
45
|
+
for (const addr of details.multiaddrs ?? []) console.error(` ${addr}`);
|
|
46
|
+
printReachabilityWarning(details);
|
|
47
|
+
console.log(details.payload ?? '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One line on stderr when the NAT layer says the node cannot be reached from
|
|
52
|
+
* outside: the claim still works from the home network over the LAN address.
|
|
53
|
+
*/
|
|
54
|
+
export function printReachabilityWarning(details: ClaimDetailsLike): void {
|
|
55
|
+
const reachability = details.reachability;
|
|
56
|
+
if (!reachability || reachability.verdict !== 'unreachable') return;
|
|
57
|
+
console.error(
|
|
58
|
+
'Warning: this node cannot be reached from outside your home network yet'
|
|
59
|
+
+ `${reachability.reason ? ` — ${reachability.reason}` : ''}. `
|
|
60
|
+
+ 'A phone on your home network can still claim it; run `cadre-host nat status` for the ports to forward.',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The line printed once the watcher reports the claim. */
|
|
65
|
+
export function printClaimed(node: HostedNodeLike): void {
|
|
66
|
+
console.error(`✓ Claimed by owner ${ownerFingerprint(node.ownerKey)} into cadre ${node.partyId ?? '?'}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* How a join by invitation ended: the member that admitted the node, or the failure. A
|
|
71
|
+
* retryable failure means no member could be reached, which is the usual case for a cadre's
|
|
72
|
+
* first always-on node: its only members are phones, which nothing can dial.
|
|
73
|
+
*/
|
|
74
|
+
export function printInvitationOutcome(node: HostedNodeLike): void {
|
|
75
|
+
if (node.status === 'joined') {
|
|
76
|
+
console.error(`✓ Joined cadre ${node.partyId ?? '?'} at member ${node.memberPeerId ?? '(unnamed)'}`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
console.error(`✗ Could not join: ${node.error ?? 'unknown error'}`);
|
|
80
|
+
if (!node.retryable) return;
|
|
81
|
+
console.error(
|
|
82
|
+
'If this is the cadre\'s first always-on node, no member can be reached yet: use `cadre-host join` without '
|
|
83
|
+
+ '`--invitation` and scan the QR code from the phone instead. Otherwise, once a member is online, run '
|
|
84
|
+
+ `\`cadre-host node retry ${node.id ?? '<id>'}\`.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One line per hosted node: id, status, party, owner fingerprint, connected. */
|
|
89
|
+
export function printNodeList(nodes: HostedNodeLike[]): void {
|
|
90
|
+
if (nodes.length === 0) {
|
|
91
|
+
console.log('Hosted nodes: none. Run `cadre-host join` to start one.');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
console.log('Hosted nodes:');
|
|
95
|
+
for (const node of nodes) {
|
|
96
|
+
// An invitation node names its cadre from the start; a claim node only once claimed.
|
|
97
|
+
const party = node.status === 'joined' || node.join?.kind === 'invitation' ? `cadre ${node.partyId ?? '?'}` : (node.status ?? '?');
|
|
98
|
+
const owner = node.ownerKey ? ` owner ${ownerFingerprint(node.ownerKey)}` : '';
|
|
99
|
+
const connected = node.status === 'joined' ? ` ${node.connected ? 'connected' : 'not connected'}` : '';
|
|
100
|
+
const error = node.error ? ` (${node.error})` : '';
|
|
101
|
+
console.log(` ${node.id ?? '?'} ${node.status ?? '?'} ${party}${owner}${connected}${error}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The first 8 characters of an owner key, as the UI shows it. */
|
|
106
|
+
function ownerFingerprint(ownerKey: string | undefined): string {
|
|
107
|
+
return ownerKey ? ownerKey.slice(0, 8) : '?';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Best-effort QR render on stderr — fall back to the bare text if the lib chokes. */
|
|
111
|
+
function renderQr(text: string): void {
|
|
112
|
+
try {
|
|
113
|
+
const qr = requireForQr('qrcode-terminal') as {
|
|
114
|
+
generate: (text: string, opts?: { small?: boolean }, cb?: (s: string) => void) => void;
|
|
115
|
+
};
|
|
116
|
+
qr.generate(text, { small: true }, (rendered) => { console.error(rendered); });
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`(qrcode-terminal unavailable: ${(err as Error).message})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable output of the `cadre-host nat` commands: the host-level NAT
|
|
3
|
+
* state, then one block per hosted node, with the forward to make on the
|
|
4
|
+
* router when a node cannot be reached from outside.
|
|
5
|
+
*
|
|
6
|
+
* The types are loose mirrors of `NatStatusSnapshot` (`../nat/types.ts`): the
|
|
7
|
+
* body arrives over HTTP from whichever cadre-host version is running, so every
|
|
8
|
+
* field is optional and a missing one prints as `?`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface NatPortRouteLike {
|
|
12
|
+
internalPort?: number;
|
|
13
|
+
externalPort?: number | null;
|
|
14
|
+
source?: string | null;
|
|
15
|
+
error?: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NatNodeLike {
|
|
19
|
+
nodeId?: string;
|
|
20
|
+
running?: boolean;
|
|
21
|
+
verdict?: string;
|
|
22
|
+
reason?: string | null;
|
|
23
|
+
tcp?: NatPortRouteLike;
|
|
24
|
+
ws?: NatPortRouteLike | null;
|
|
25
|
+
publicAddrs?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface NatStatusLike {
|
|
29
|
+
upnpEnabled?: boolean;
|
|
30
|
+
gateway?: {
|
|
31
|
+
found?: boolean;
|
|
32
|
+
lanAddress?: string | null;
|
|
33
|
+
routerExternalIp?: string | null;
|
|
34
|
+
lastError?: string | null;
|
|
35
|
+
};
|
|
36
|
+
externalIp?: string | null;
|
|
37
|
+
cgnatDetected?: boolean;
|
|
38
|
+
directReachability?: string;
|
|
39
|
+
lastTestedAt?: string | null;
|
|
40
|
+
nodes?: NatNodeLike[];
|
|
41
|
+
ddns?: {
|
|
42
|
+
providerId?: string | null;
|
|
43
|
+
hostname?: string | null;
|
|
44
|
+
externallyManaged?: boolean;
|
|
45
|
+
lastUpdateAt?: string | null;
|
|
46
|
+
lastUpdateOk?: boolean | null;
|
|
47
|
+
lastError?: string | null;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type PortKind = 'tcp' | 'ws';
|
|
52
|
+
|
|
53
|
+
interface PortEntry {
|
|
54
|
+
kind: PortKind;
|
|
55
|
+
route: NatPortRouteLike;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const KIND_LABEL: Record<PortKind, string> = { tcp: 'TCP', ws: 'WebSocket' };
|
|
59
|
+
|
|
60
|
+
/** Width of the label column inside a node block. */
|
|
61
|
+
const FIELD_WIDTH = 11;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A forward changes the node's public addresses only when it changes the
|
|
65
|
+
* external port or makes a port routable, and the restart that follows is
|
|
66
|
+
* rate-limited per node (`NAT_ADDRESS_RESTART_MIN_INTERVAL_MS`).
|
|
67
|
+
*/
|
|
68
|
+
const FORWARD_RESTART_NOTE =
|
|
69
|
+
'If its public addresses changed, the node restarts to announce them; a node already restarted ' +
|
|
70
|
+
'for an address change in the last 10 minutes waits until those 10 minutes are up.';
|
|
71
|
+
|
|
72
|
+
export function printNatStatus(s: NatStatusLike): void {
|
|
73
|
+
printHostLines(s);
|
|
74
|
+
printDdns(s);
|
|
75
|
+
const nodes = s.nodes ?? [];
|
|
76
|
+
console.log(nodes.length === 0 ? '\nNodes: none' : '\nNodes:');
|
|
77
|
+
for (const n of nodes) printNodeReachability(n, s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The block for the node a forward was saved for, then what happens next. */
|
|
81
|
+
export function printForwardResult(nodeId: string, s: NatStatusLike): void {
|
|
82
|
+
const node = s.nodes?.find((n) => n.nodeId === nodeId);
|
|
83
|
+
if (node) printNodeReachability(node, s);
|
|
84
|
+
console.log(`\n${FORWARD_RESTART_NOTE}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function printHostLines(s: NatStatusLike): void {
|
|
88
|
+
const gateway = s.gateway ?? {};
|
|
89
|
+
const gatewayLine = gateway.found
|
|
90
|
+
? `found (this machine is ${gateway.lanAddress ?? '?'} on its network)`
|
|
91
|
+
: `not found${gateway.lastError ? ` — ${gateway.lastError}` : ''}`;
|
|
92
|
+
console.log(`UPnP: ${s.upnpEnabled === false ? 'off' : 'on'}; router ${gatewayLine}`);
|
|
93
|
+
if (gateway.routerExternalIp) {
|
|
94
|
+
console.log(`Router IP: ${gateway.routerExternalIp}`);
|
|
95
|
+
}
|
|
96
|
+
console.log(`External IP: ${s.externalIp ?? '(unknown)'}${s.cgnatDetected ? ' [CGNAT detected]' : ''}`);
|
|
97
|
+
console.log(`Reachability: ${s.directReachability ?? 'unknown'}${s.lastTestedAt ? ` (tested ${s.lastTestedAt})` : ''}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printDdns(s: NatStatusLike): void {
|
|
101
|
+
const ddns = s.ddns ?? {};
|
|
102
|
+
if (!ddns.providerId && !ddns.hostname) {
|
|
103
|
+
console.log('DDNS: not configured');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
console.log(`DDNS: ${ddns.hostname ?? '(no hostname)'} via ${ddns.providerId ?? '(no provider)'}${ddns.externallyManaged ? ' [externally managed]' : ''}`);
|
|
107
|
+
if (ddns.lastUpdateAt) {
|
|
108
|
+
const ok = ddns.lastUpdateOk ? 'OK' : 'FAIL';
|
|
109
|
+
console.log(`DDNS update: ${ok} at ${ddns.lastUpdateAt}${ddns.lastError ? ` — ${ddns.lastError}` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function printNodeReachability(n: NatNodeLike, s: NatStatusLike): void {
|
|
114
|
+
console.log(` ${n.nodeId ?? '?'}: ${n.verdict ?? '?'}${n.running === false ? ' [stopped]' : ''}`);
|
|
115
|
+
printField('TCP', [formatRoute(n.tcp)]);
|
|
116
|
+
printField('WebSocket', [n.ws ? formatRoute(n.ws) : 'not available']);
|
|
117
|
+
printField('Public', n.publicAddrs?.length ? n.publicAddrs : ['none']);
|
|
118
|
+
if (n.reason) printField('Why', [n.reason]);
|
|
119
|
+
const advice = forwardAdvice(n, s);
|
|
120
|
+
if (advice.length > 0) printField('To fix', advice);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function printField(label: string, values: string[]): void {
|
|
124
|
+
values.forEach((value, i) => console.log(` ${(i === 0 ? label : '').padEnd(FIELD_WIDTH)}${value}`));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatRoute(r: NatPortRouteLike | undefined): string {
|
|
128
|
+
if (!r) return '?';
|
|
129
|
+
const internal = `internal ${r.internalPort ?? '?'}`;
|
|
130
|
+
if (r.externalPort == null) return `${internal} → not mapped${r.error ? ` (${r.error})` : ''}`;
|
|
131
|
+
const failed = r.error ? `; last attempt failed: ${r.error}` : '';
|
|
132
|
+
return `${internal} → external ${r.externalPort} (${r.source === 'manual' ? 'forwarded by hand' : 'UPnP'}${failed})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What to do for an unreachable node, one line per array entry. Behind CGNAT a
|
|
137
|
+
* router forward does not help, but the detection can misfire, so the forward
|
|
138
|
+
* command is still offered for every port. With every port routed there is
|
|
139
|
+
* nothing to forward: what is missing is the host's public address, which the
|
|
140
|
+
* reason already explains.
|
|
141
|
+
*
|
|
142
|
+
* NOTE: the UI builds the same sentence (`forwardInstruction` in
|
|
143
|
+
* `ui/src/lib/reachability.ts`); if the rule for which ports to forward
|
|
144
|
+
* changes, have the server send the port list in `NodeReachability` instead
|
|
145
|
+
* of changing both copies.
|
|
146
|
+
*/
|
|
147
|
+
function forwardAdvice(n: NatNodeLike, s: NatStatusLike): string[] {
|
|
148
|
+
if (n.verdict !== 'unreachable') return [];
|
|
149
|
+
const nodeId = n.nodeId ?? '<node-id>';
|
|
150
|
+
const ports = portsOf(n);
|
|
151
|
+
if (s.cgnatDetected) {
|
|
152
|
+
return [
|
|
153
|
+
'Carrier-grade NAT detected: a forward on your router will not help, and cadre-host cannot reserve a relay for its nodes yet.',
|
|
154
|
+
'If the detection is wrong and you did forward ports, tell cadre-host with:',
|
|
155
|
+
` ${forwardCommand(nodeId, ports)}`,
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
const unrouted = ports.filter(({ route }) => route.externalPort == null);
|
|
159
|
+
if (unrouted.length === 0) return [];
|
|
160
|
+
const lan = s.gateway?.lanAddress ?? null;
|
|
161
|
+
const forwards = unrouted.map(({ kind, route }) => {
|
|
162
|
+
const port = route.internalPort ?? '?';
|
|
163
|
+
return `${KIND_LABEL[kind]} port ${port} to ${lan ? `${lan}:${port}` : `port ${port} on this machine's LAN address`}`;
|
|
164
|
+
});
|
|
165
|
+
return [`On your router, forward ${forwards.join(' and ')}, then run:`, ` ${forwardCommand(nodeId, unrouted)}`];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function portsOf(n: NatNodeLike): PortEntry[] {
|
|
169
|
+
const out: PortEntry[] = [];
|
|
170
|
+
if (n.tcp) out.push({ kind: 'tcp', route: n.tcp });
|
|
171
|
+
if (n.ws) out.push({ kind: 'ws', route: n.ws });
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function forwardCommand(nodeId: string, ports: PortEntry[]): string {
|
|
176
|
+
return ['cadre-host nat forward', nodeId, ...ports.map(({ kind }) => `--${kind} <external port>`)].join(' ');
|
|
177
|
+
}
|
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
|
|
3
|
-
* from `HostProcessOrchestrator`. The
|
|
4
|
-
* express races that only exist because the real class drops and rejects
|
|
5
|
-
* handles, so this file exists to stop a later relaxation of the fake from
|
|
6
|
-
* quietly turning one of those tests green.
|
|
7
|
-
*
|
|
8
|
-
* The rules the fake shares with the real class live in
|
|
9
|
-
* `src/__tests__/orchestrator-handle-contract.ts` and run against both — here
|
|
10
|
-
* and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the
|
|
11
|
-
* two fails a test. The cases in this file add what only the fake has: its
|
|
12
|
-
* recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks.
|
|
13
|
-
* Each names the real behaviour it mirrors.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, expect, it } from 'vitest';
|
|
17
|
-
|
|
18
|
-
import type {
|
|
19
|
-
|
|
20
|
-
import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
|
|
21
|
-
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
22
|
-
|
|
23
|
-
const request = (containerId: string):
|
|
24
|
-
containerId,
|
|
25
|
-
partyId: 'party-P',
|
|
26
|
-
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
27
|
-
profile: 'storage',
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describeHandleContract<FakeOrchestrator>('FakeOrchestrator', {
|
|
31
|
-
make: () => new FakeOrchestrator(),
|
|
32
|
-
request,
|
|
33
|
-
// No process to wait for: a fake child is running as soon as it exists.
|
|
34
|
-
started: () => Promise.resolve(),
|
|
35
|
-
failNextCreate: (orch) => { orch.failCreate = true; },
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
describe('FakeOrchestrator handle lifecycle', () => {
|
|
39
|
-
it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
|
|
40
|
-
const orch = new FakeOrchestrator();
|
|
41
|
-
const first = await orch.createContainer(request('
|
|
42
|
-
orch.crash(first.dockerId);
|
|
43
|
-
const second = await orch.createContainer(request('
|
|
44
|
-
|
|
45
|
-
await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
|
|
46
|
-
`Container not found: ${first.dockerId}`,
|
|
47
|
-
);
|
|
48
|
-
// The rejected call left no trace: `stopped` records handles acted on, not
|
|
49
|
-
// calls attempted.
|
|
50
|
-
expect(orch.stopped).toEqual([]);
|
|
51
|
-
// …and the container now resolves only to the new spawn.
|
|
52
|
-
expect(orch.resolveDockerId('
|
|
53
|
-
|
|
54
|
-
await orch.stopContainer(second.dockerId);
|
|
55
|
-
expect(orch.stopped).toEqual([second.dockerId]);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('keeps the prior handle live across the whole pre-drop await window', async () => {
|
|
59
|
-
const orch = new FakeOrchestrator();
|
|
60
|
-
const first = await orch.createContainer(request('
|
|
61
|
-
|
|
62
|
-
// The invariant the
|
|
63
|
-
// from `onCreate` still finds the old handle. Deferring it onto a microtask
|
|
64
|
-
// puts it strictly after `onCreate` returns and strictly before the
|
|
65
|
-
// `createDelayMs` timer, so this one case pins the drop behind BOTH — with
|
|
66
|
-
// no dependence on wall-clock timing. The outcome is captured rather than
|
|
67
|
-
// rethrown so a regression fails this assertion instead of surfacing as an
|
|
68
|
-
// unhandled rejection.
|
|
69
|
-
orch.createDelayMs = 1;
|
|
70
|
-
let outcome: Promise<string> | undefined;
|
|
71
|
-
orch.onCreate = () => {
|
|
72
|
-
outcome ??= Promise.resolve()
|
|
73
|
-
.then(() => orch.stopContainer(first.dockerId))
|
|
74
|
-
.then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const second = await orch.createContainer(request('
|
|
78
|
-
|
|
79
|
-
expect(await outcome).toBe('stopped');
|
|
80
|
-
expect(orch.stopped).toEqual([first.dockerId]);
|
|
81
|
-
expect(orch.resolveDockerId('
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
|
|
85
|
-
const orch = new FakeOrchestrator();
|
|
86
|
-
const first = await orch.createContainer(request('
|
|
87
|
-
|
|
88
|
-
orch.failCreate = true;
|
|
89
|
-
await expect(orch.createContainer(request('
|
|
90
|
-
|
|
91
|
-
// Host state is exactly as the failed attempt found it — which is what lets
|
|
92
|
-
// `
|
|
93
|
-
await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
|
|
94
|
-
expect(orch.stopped).toEqual([first.dockerId]);
|
|
95
|
-
expect(orch.resolveDockerId('
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
|
|
99
|
-
const orch = new FakeOrchestrator();
|
|
100
|
-
|
|
101
|
-
await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
|
|
102
|
-
'Container not found: dock_nope',
|
|
103
|
-
);
|
|
104
|
-
expect(orch.removed).toEqual([]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
|
|
108
|
-
const orch = new FakeOrchestrator();
|
|
109
|
-
const { dockerId } = await orch.createContainer(request('
|
|
110
|
-
|
|
111
|
-
await orch.removeContainer(dockerId);
|
|
112
|
-
expect(orch.removed).toEqual([dockerId]);
|
|
113
|
-
|
|
114
|
-
await expect(orch.stopContainer(dockerId)).rejects.toThrow(
|
|
115
|
-
`Container not found: ${dockerId}`,
|
|
116
|
-
);
|
|
117
|
-
expect(orch.stopped).toEqual([]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
|
|
121
|
-
const orch = new FakeOrchestrator();
|
|
122
|
-
const attempted: string[] = [];
|
|
123
|
-
orch.onStop = (id) => { attempted.push(id); };
|
|
124
|
-
|
|
125
|
-
await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
|
|
126
|
-
expect(attempted).toEqual(['dock_nope']);
|
|
127
|
-
expect(orch.stopped).toEqual([]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
|
|
131
|
-
const orch = new FakeOrchestrator();
|
|
132
|
-
await orch.createContainer(request('
|
|
133
|
-
|
|
134
|
-
// A live handle owns that directory; only `removeContainer` — which stops
|
|
135
|
-
// the child first — may delete it.
|
|
136
|
-
expect(orch.reclaimWorkdir('
|
|
137
|
-
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('reclaims the workdir of a container no handle owns', async () => {
|
|
141
|
-
const orch = new FakeOrchestrator();
|
|
142
|
-
|
|
143
|
-
// Never spawned — the stuck-`
|
|
144
|
-
// handle ever existed.
|
|
145
|
-
expect(orch.reclaimWorkdir('
|
|
146
|
-
expect(orch.reclaimedWorkdirs).toEqual(['
|
|
147
|
-
|
|
148
|
-
// …and once the container's own handle is removed, its id becomes
|
|
149
|
-
// reclaimable too.
|
|
150
|
-
const { dockerId } = await orch.createContainer(request('
|
|
151
|
-
expect(orch.reclaimWorkdir('
|
|
152
|
-
await orch.removeContainer(dockerId);
|
|
153
|
-
expect(orch.reclaimWorkdir('
|
|
154
|
-
expect(orch.reclaimedWorkdirs).toEqual(['
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
|
|
158
|
-
const orch = new FakeOrchestrator();
|
|
159
|
-
const first = await orch.createContainer(request('
|
|
160
|
-
orch.crash(first.dockerId);
|
|
161
|
-
|
|
162
|
-
let observed: { spawned: string; resolves: string | undefined } | undefined;
|
|
163
|
-
orch.onSpawned = (dockerId) => {
|
|
164
|
-
observed = { spawned: dockerId, resolves: orch.resolveDockerId('
|
|
165
|
-
};
|
|
166
|
-
const second = await orch.createContainer(request('
|
|
167
|
-
|
|
168
|
-
// The post-drop world: the container already resolves to the new handle, so
|
|
169
|
-
// work driven from here sees what `abandonRespawn`'s caller would see.
|
|
170
|
-
expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
|
|
171
|
-
expect(second.dockerId).not.toBe(first.dockerId);
|
|
172
|
-
});
|
|
173
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* Contract tests for `FakeOrchestrator` itself — the handle lifecycle it borrows
|
|
3
|
+
* from `HostProcessOrchestrator`. The hosted-node suites lean on these semantics to
|
|
4
|
+
* express races that only exist because the real class drops and rejects
|
|
5
|
+
* handles, so this file exists to stop a later relaxation of the fake from
|
|
6
|
+
* quietly turning one of those tests green.
|
|
7
|
+
*
|
|
8
|
+
* The rules the fake shares with the real class live in
|
|
9
|
+
* `src/__tests__/orchestrator-handle-contract.ts` and run against both — here
|
|
10
|
+
* and in `src/__tests__/orchestrator.test.ts` — so a disagreement between the
|
|
11
|
+
* two fails a test. The cases in this file add what only the fake has: its
|
|
12
|
+
* recording arrays (`stopped`, `removed`, `reclaimedWorkdirs`) and its hooks.
|
|
13
|
+
* Each names the real behaviour it mirrors.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
import type { HostedSpawnRequest } from '../../orchestrator/types.js';
|
|
19
|
+
|
|
20
|
+
import { describeHandleContract } from '../../__tests__/orchestrator-handle-contract.js';
|
|
21
|
+
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
22
|
+
|
|
23
|
+
const request = (containerId: string): HostedSpawnRequest => ({
|
|
24
|
+
containerId,
|
|
25
|
+
partyId: 'party-P',
|
|
26
|
+
bootstrapNodes: ['/ip4/127.0.0.1/tcp/4001/p2p/12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp'],
|
|
27
|
+
profile: 'storage',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describeHandleContract<FakeOrchestrator>('FakeOrchestrator', {
|
|
31
|
+
make: () => new FakeOrchestrator(),
|
|
32
|
+
request,
|
|
33
|
+
// No process to wait for: a fake child is running as soon as it exists.
|
|
34
|
+
started: () => Promise.resolve(),
|
|
35
|
+
failNextCreate: (orch) => { orch.failCreate = true; },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('FakeOrchestrator handle lifecycle', () => {
|
|
39
|
+
it('drops the prior handle when a re-spawn succeeds (mirrors dropStaleHandle)', async () => {
|
|
40
|
+
const orch = new FakeOrchestrator();
|
|
41
|
+
const first = await orch.createContainer(request('hn_1'));
|
|
42
|
+
orch.crash(first.dockerId);
|
|
43
|
+
const second = await orch.createContainer(request('hn_1'));
|
|
44
|
+
|
|
45
|
+
await expect(orch.stopContainer(first.dockerId)).rejects.toThrow(
|
|
46
|
+
`Container not found: ${first.dockerId}`,
|
|
47
|
+
);
|
|
48
|
+
// The rejected call left no trace: `stopped` records handles acted on, not
|
|
49
|
+
// calls attempted.
|
|
50
|
+
expect(orch.stopped).toEqual([]);
|
|
51
|
+
// …and the container now resolves only to the new spawn.
|
|
52
|
+
expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId);
|
|
53
|
+
|
|
54
|
+
await orch.stopContainer(second.dockerId);
|
|
55
|
+
expect(orch.stopped).toEqual([second.dockerId]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps the prior handle live across the whole pre-drop await window', async () => {
|
|
59
|
+
const orch = new FakeOrchestrator();
|
|
60
|
+
const first = await orch.createContainer(request('hn_1'));
|
|
61
|
+
|
|
62
|
+
// The invariant the hosted-node race tests rest on: a concurrent stop started
|
|
63
|
+
// from `onCreate` still finds the old handle. Deferring it onto a microtask
|
|
64
|
+
// puts it strictly after `onCreate` returns and strictly before the
|
|
65
|
+
// `createDelayMs` timer, so this one case pins the drop behind BOTH — with
|
|
66
|
+
// no dependence on wall-clock timing. The outcome is captured rather than
|
|
67
|
+
// rethrown so a regression fails this assertion instead of surfacing as an
|
|
68
|
+
// unhandled rejection.
|
|
69
|
+
orch.createDelayMs = 1;
|
|
70
|
+
let outcome: Promise<string> | undefined;
|
|
71
|
+
orch.onCreate = () => {
|
|
72
|
+
outcome ??= Promise.resolve()
|
|
73
|
+
.then(() => orch.stopContainer(first.dockerId))
|
|
74
|
+
.then(() => 'stopped', (err: Error) => `rejected: ${err.message}`);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const second = await orch.createContainer(request('hn_1'));
|
|
78
|
+
|
|
79
|
+
expect(await outcome).toBe('stopped');
|
|
80
|
+
expect(orch.stopped).toEqual([first.dockerId]);
|
|
81
|
+
expect(orch.resolveDockerId('hn_1')).toBe(second.dockerId);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('leaves the prior handle in place when the re-spawn fails (mirrors restoreDroppedHandles)', async () => {
|
|
85
|
+
const orch = new FakeOrchestrator();
|
|
86
|
+
const first = await orch.createContainer(request('hn_1'));
|
|
87
|
+
|
|
88
|
+
orch.failCreate = true;
|
|
89
|
+
await expect(orch.createContainer(request('hn_1'))).rejects.toThrow('spawn boom');
|
|
90
|
+
|
|
91
|
+
// Host state is exactly as the failed attempt found it — which is what lets
|
|
92
|
+
// `HostedNodeSupervisor.giveUp` still stop the child it knows about.
|
|
93
|
+
await expect(orch.stopContainer(first.dockerId)).resolves.toBeUndefined();
|
|
94
|
+
expect(orch.stopped).toEqual([first.dockerId]);
|
|
95
|
+
expect(orch.resolveDockerId('hn_1')).toBe(first.dockerId);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('rejects a never-issued dockerId (mirrors requireHandle)', async () => {
|
|
99
|
+
const orch = new FakeOrchestrator();
|
|
100
|
+
|
|
101
|
+
await expect(orch.removeContainer('dock_nope')).rejects.toThrow(
|
|
102
|
+
'Container not found: dock_nope',
|
|
103
|
+
);
|
|
104
|
+
expect(orch.removed).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('rejects a second call once a handle has been removed (mirrors removeContainer deleting it)', async () => {
|
|
108
|
+
const orch = new FakeOrchestrator();
|
|
109
|
+
const { dockerId } = await orch.createContainer(request('hn_1'));
|
|
110
|
+
|
|
111
|
+
await orch.removeContainer(dockerId);
|
|
112
|
+
expect(orch.removed).toEqual([dockerId]);
|
|
113
|
+
|
|
114
|
+
await expect(orch.stopContainer(dockerId)).rejects.toThrow(
|
|
115
|
+
`Container not found: ${dockerId}`,
|
|
116
|
+
);
|
|
117
|
+
expect(orch.stopped).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('fires onStop for a rejected stop, so a test can still observe the attempt', async () => {
|
|
121
|
+
const orch = new FakeOrchestrator();
|
|
122
|
+
const attempted: string[] = [];
|
|
123
|
+
orch.onStop = (id) => { attempted.push(id); };
|
|
124
|
+
|
|
125
|
+
await expect(orch.stopContainer('dock_nope')).rejects.toThrow('Container not found');
|
|
126
|
+
expect(attempted).toEqual(['dock_nope']);
|
|
127
|
+
expect(orch.stopped).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('refuses to reclaim the workdir of a container that still resolves (mirrors the live-handle guard)', async () => {
|
|
131
|
+
const orch = new FakeOrchestrator();
|
|
132
|
+
await orch.createContainer(request('hn_1'));
|
|
133
|
+
|
|
134
|
+
// A live handle owns that directory; only `removeContainer` — which stops
|
|
135
|
+
// the child first — may delete it.
|
|
136
|
+
expect(orch.reclaimWorkdir('hn_1')).toBe(false);
|
|
137
|
+
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('reclaims the workdir of a container no handle owns', async () => {
|
|
141
|
+
const orch = new FakeOrchestrator();
|
|
142
|
+
|
|
143
|
+
// Never spawned — the stuck-`spawning` record whose host died before a
|
|
144
|
+
// handle ever existed.
|
|
145
|
+
expect(orch.reclaimWorkdir('hn_orphan')).toBe(true);
|
|
146
|
+
expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan']);
|
|
147
|
+
|
|
148
|
+
// …and once the container's own handle is removed, its id becomes
|
|
149
|
+
// reclaimable too.
|
|
150
|
+
const { dockerId } = await orch.createContainer(request('hn_1'));
|
|
151
|
+
expect(orch.reclaimWorkdir('hn_1')).toBe(false);
|
|
152
|
+
await orch.removeContainer(dockerId);
|
|
153
|
+
expect(orch.reclaimWorkdir('hn_1')).toBe(true);
|
|
154
|
+
expect(orch.reclaimedWorkdirs).toEqual(['hn_orphan', 'hn_1']);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('fires onSpawned after the drop, with the new handle live and the old one gone', async () => {
|
|
158
|
+
const orch = new FakeOrchestrator();
|
|
159
|
+
const first = await orch.createContainer(request('hn_1'));
|
|
160
|
+
orch.crash(first.dockerId);
|
|
161
|
+
|
|
162
|
+
let observed: { spawned: string; resolves: string | undefined } | undefined;
|
|
163
|
+
orch.onSpawned = (dockerId) => {
|
|
164
|
+
observed = { spawned: dockerId, resolves: orch.resolveDockerId('hn_1') };
|
|
165
|
+
};
|
|
166
|
+
const second = await orch.createContainer(request('hn_1'));
|
|
167
|
+
|
|
168
|
+
// The post-drop world: the container already resolves to the new handle, so
|
|
169
|
+
// work driven from here sees what `abandonRespawn`'s caller would see.
|
|
170
|
+
expect(observed).toEqual({ spawned: second.dockerId, resolves: second.dockerId });
|
|
171
|
+
expect(second.dockerId).not.toBe(first.dockerId);
|
|
172
|
+
});
|
|
173
|
+
});
|