@serfab/cadre-host 1.13.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,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostedNodeService unit tests — "Join a cadre" and what follows, on the host
|
|
3
|
+
* side, exercised against a fake orchestrator (no real child processes), a real
|
|
4
|
+
* on-disk HostedNodeStore, a stub NAT layer and a stubbed `globalThis.fetch` for
|
|
5
|
+
* the child's `/status`.
|
|
6
|
+
*
|
|
7
|
+
* Covers: join → unclaimed (claim secret threaded to the child, persisted,
|
|
8
|
+
* redacted), the claim details, the status watcher (claim → joined, liveness, and
|
|
9
|
+
* a row removed mid-poll), a join by invitation through a failure, Retry and the
|
|
10
|
+
* member's acceptance, respawn (same party, same secret, same ports), remove,
|
|
11
|
+
* reset, and the stuck-spawning reap.
|
|
12
|
+
*
|
|
13
|
+
* A recurring theme: **an ending that lands mid-operation wins.** `join`,
|
|
14
|
+
* `respawn` and the watcher each hold an entry-time copy of the record across a
|
|
15
|
+
* slow `await`, and `HostedNodeStore.put` replaces the whole row — so each must
|
|
16
|
+
* re-read before writing or it resurrects a node the admin just removed. The
|
|
17
|
+
* suites drive that race through `FakeOrchestrator.onCreate` / `onSpawned` (the
|
|
18
|
+
* spawn window) and the `fetch` stub (the status window).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
22
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
import { decodeNodeClaimPayload } from '@serfab/cadre-core';
|
|
27
|
+
|
|
28
|
+
import type { NodeReachability } from '../../nat/types.js';
|
|
29
|
+
import type { HostedNodeAddressSource } from '../claim-details.js';
|
|
30
|
+
import { HostedNodeService, HOSTED_NODE_SPAWNING_TTL_MS } from '../hosted-node-service.js';
|
|
31
|
+
import { HostedNodeStore } from '../hosted-node-store.js';
|
|
32
|
+
import type { HostedNode, HostedNodeChange } from '../types.js';
|
|
33
|
+
import { HostedNodeError } from '../types.js';
|
|
34
|
+
import { FakeOrchestrator } from './fake-orchestrator.js';
|
|
35
|
+
import { ISSUER_KEY, MEMBER_PEER_ID, encodedTestInvitation } from './test-invitation.js';
|
|
36
|
+
|
|
37
|
+
/** A peer id the child reports; the payload codec checks it parses. */
|
|
38
|
+
const PEER_ID = '12D3KooWA9hbnKrRnPRSPTRkzXqTHzGE8YpJ3JHZmQ5tGwLRTMmp';
|
|
39
|
+
/** The owner key `/status.node.claimedBy` reports after a claim. */
|
|
40
|
+
const OWNER_KEY = Buffer.alloc(32, 7).toString('base64url');
|
|
41
|
+
|
|
42
|
+
/** A NAT layer with no public address and no verdicts. */
|
|
43
|
+
const NO_ADDRESSES: HostedNodeAddressSource = {
|
|
44
|
+
publicAddressesFor: () => [],
|
|
45
|
+
getStatus: () => ({ nodes: [] }),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A store whose next `put` fails once — the post-spawn write-failure path. */
|
|
49
|
+
class FlakyStore extends HostedNodeStore {
|
|
50
|
+
failNextPut = false;
|
|
51
|
+
|
|
52
|
+
override put(node: HostedNode): void {
|
|
53
|
+
if (this.failNextPut) {
|
|
54
|
+
this.failNextPut = false;
|
|
55
|
+
throw new HostedNodeError('storage_error', 'disk full');
|
|
56
|
+
}
|
|
57
|
+
super.put(node);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let tmpRoot: string;
|
|
62
|
+
const originalFetch = globalThis.fetch;
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-hosted-svc-'));
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
globalThis.fetch = originalFetch;
|
|
70
|
+
try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** What a child's `/status` answers, in the parts the service reads. */
|
|
74
|
+
interface StatusAnswer {
|
|
75
|
+
peerId?: string | null;
|
|
76
|
+
multiaddrs?: string[];
|
|
77
|
+
claim?: 'awaiting' | 'claimed';
|
|
78
|
+
partyId?: string;
|
|
79
|
+
claimedBy?: string;
|
|
80
|
+
connections?: number;
|
|
81
|
+
invitation?: { state: 'pending' | 'accepted' | 'failed'; memberPeerId?: string; error?: string; retryable?: boolean };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function statusBody(answer: StatusAnswer): unknown {
|
|
85
|
+
const peerId = answer.peerId === undefined ? PEER_ID : answer.peerId;
|
|
86
|
+
return {
|
|
87
|
+
status: 'healthy',
|
|
88
|
+
peerId,
|
|
89
|
+
multiaddrs: answer.multiaddrs ?? [`/ip4/127.0.0.1/tcp/4101/ws/p2p/${peerId}`, `/ip4/192.168.1.20/tcp/4101/ws/p2p/${peerId}`],
|
|
90
|
+
node: {
|
|
91
|
+
running: true,
|
|
92
|
+
peerId,
|
|
93
|
+
partyId: answer.partyId ?? 'unclaimed',
|
|
94
|
+
profile: 'storage',
|
|
95
|
+
strands: { total: 0, syncing: 0, active: 0, idle: 0, hibernating: 0 },
|
|
96
|
+
connectionPaths: { total: answer.connections ?? 0, relayed: 0, direct: answer.connections ?? 0, stuckOnRelay: 0, byTransport: {} },
|
|
97
|
+
claim: answer.claim ?? 'awaiting',
|
|
98
|
+
...(answer.claimedBy ? { claimedBy: answer.claimedBy } : {}),
|
|
99
|
+
...(answer.invitation ? { invitation: answer.invitation } : {}),
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stub the child's `/status` with `answer`. `duringRequest` — when given — runs
|
|
106
|
+
* exactly once and is awaited before the response resolves, so whatever it starts
|
|
107
|
+
* lands *inside* the status window. It is the `fetch` analogue of
|
|
108
|
+
* `FakeOrchestrator.onCreate`.
|
|
109
|
+
*/
|
|
110
|
+
function stubStatusFetch(answer: StatusAnswer | 'down', duringRequest?: () => unknown): void {
|
|
111
|
+
let ran: Promise<unknown> | undefined;
|
|
112
|
+
globalThis.fetch = (async () => {
|
|
113
|
+
ran ??= (async () => duringRequest?.())();
|
|
114
|
+
await ran;
|
|
115
|
+
if (answer === 'down') throw new Error('ECONNREFUSED');
|
|
116
|
+
return { ok: true, json: async () => statusBody(answer) } as unknown as Response;
|
|
117
|
+
}) as typeof globalThis.fetch;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Harness {
|
|
121
|
+
orch: FakeOrchestrator;
|
|
122
|
+
store: HostedNodeStore;
|
|
123
|
+
svc: HostedNodeService;
|
|
124
|
+
changes: HostedNodeChange[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeHarness(opts: { store?: HostedNodeStore; addresses?: HostedNodeAddressSource; now?: () => Date } = {}): Harness {
|
|
128
|
+
const orch = new FakeOrchestrator();
|
|
129
|
+
const store = opts.store ?? new HostedNodeStore(join(tmpRoot, 'hosted'));
|
|
130
|
+
const svc = new HostedNodeService({
|
|
131
|
+
orchestrator: orch,
|
|
132
|
+
store,
|
|
133
|
+
addresses: opts.addresses ?? NO_ADDRESSES,
|
|
134
|
+
...(opts.now ? { now: opts.now } : {}),
|
|
135
|
+
});
|
|
136
|
+
const changes: HostedNodeChange[] = [];
|
|
137
|
+
svc.onChange((c) => changes.push(c));
|
|
138
|
+
return { orch, store, svc, changes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function requireNode(store: HostedNodeStore, id: string): HostedNode {
|
|
142
|
+
const node = store.get(id);
|
|
143
|
+
if (!node) throw new Error(`no hosted node ${id}`);
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function secretOf(node: HostedNode): string {
|
|
148
|
+
if (node.join.kind !== 'claim') throw new Error(`hosted node ${node.id} joins by ${node.join.kind}, not by claim`);
|
|
149
|
+
return node.join.secret;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
describe('HostedNodeService.join', () => {
|
|
153
|
+
it('spawns a storage node under the placeholder party with a fresh claim secret, then records it unclaimed', async () => {
|
|
154
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
155
|
+
|
|
156
|
+
const view = await svc.join();
|
|
157
|
+
|
|
158
|
+
expect(view.id).toMatch(/^hn_[A-Za-z0-9_-]{16}$/);
|
|
159
|
+
expect(view).toMatchObject({ status: 'unclaimed', partyId: 'unclaimed', profile: 'storage', join: { kind: 'claim' }, dockerId: 'dock_1' });
|
|
160
|
+
// The secret never crosses the boundary.
|
|
161
|
+
expect((view.join as Record<string, unknown>).secret).toBeUndefined();
|
|
162
|
+
|
|
163
|
+
const stored = requireNode(store, view.id);
|
|
164
|
+
expect(secretOf(stored)).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
165
|
+
expect(stored.statusEndpoint).toBe('http://127.0.0.1:9001/status');
|
|
166
|
+
// The child got exactly the record's secret, through the request and nothing else.
|
|
167
|
+
expect(orch.createCalls).toEqual([{
|
|
168
|
+
containerId: view.id,
|
|
169
|
+
partyId: 'unclaimed',
|
|
170
|
+
bootstrapNodes: [],
|
|
171
|
+
profile: 'storage',
|
|
172
|
+
claimSecret: secretOf(stored),
|
|
173
|
+
}]);
|
|
174
|
+
expect(changes).toEqual([{ kind: 'added', id: view.id }]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('gives two joins at once their own id, secret and child', async () => {
|
|
178
|
+
const { orch, store, svc } = makeHarness();
|
|
179
|
+
orch.createDelayMs = 10;
|
|
180
|
+
|
|
181
|
+
const [a, b] = await Promise.all([svc.join(), svc.join()]);
|
|
182
|
+
|
|
183
|
+
expect(a.id).not.toBe(b.id);
|
|
184
|
+
expect(secretOf(requireNode(store, a.id))).not.toBe(secretOf(requireNode(store, b.id)));
|
|
185
|
+
expect(orch.createCalls.map((c) => c.containerId).sort()).toEqual([a.id, b.id].sort());
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('lets a remove that lands mid-spawn win, and reclaims the new child', async () => {
|
|
189
|
+
const { orch, store, svc } = makeHarness();
|
|
190
|
+
|
|
191
|
+
// The admin's DELETE lands inside the spawn window. The record names no
|
|
192
|
+
// dockerId yet, so remove's own cleanup finds nothing to stop — the abandon
|
|
193
|
+
// path here is the only thing that can reclaim this child.
|
|
194
|
+
orch.createDelayMs = 20;
|
|
195
|
+
let removed: Promise<void> | undefined;
|
|
196
|
+
orch.onCreate = (request) => { removed ??= svc.remove(request.containerId); };
|
|
197
|
+
|
|
198
|
+
await expect(svc.join()).rejects.toMatchObject({ code: 'not_found' });
|
|
199
|
+
await removed;
|
|
200
|
+
|
|
201
|
+
expect(store.list()).toEqual([]);
|
|
202
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('marks the record error and reclaims nothing when the spawn itself throws', async () => {
|
|
206
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
207
|
+
orch.failCreate = true;
|
|
208
|
+
|
|
209
|
+
await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
210
|
+
|
|
211
|
+
const [stored] = store.list();
|
|
212
|
+
expect(stored?.status).toBe('error');
|
|
213
|
+
expect(stored?.error).toContain('spawn boom');
|
|
214
|
+
expect(orch.removed).toEqual([]);
|
|
215
|
+
// `createContainer`'s own unwind removed the directory its failed spawn created.
|
|
216
|
+
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
217
|
+
expect(changes).toEqual([{ kind: 'changed', id: stored!.id }]);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('reclaims the child and marks error when the post-spawn write fails', async () => {
|
|
221
|
+
const store = new FlakyStore(join(tmpRoot, 'hosted'));
|
|
222
|
+
const { orch, svc } = makeHarness({ store });
|
|
223
|
+
// The first put (the spawning row) succeeds; the second (unclaimed) fails.
|
|
224
|
+
orch.onSpawned = () => { store.failNextPut = true; };
|
|
225
|
+
|
|
226
|
+
await expect(svc.join()).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
227
|
+
|
|
228
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
229
|
+
expect(store.list()[0]?.status).toBe('error');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('HostedNodeService.claimDetails', () => {
|
|
234
|
+
it('answers the QR payload: public addresses with the peer id first, then the LAN addresses, loopback dropped', async () => {
|
|
235
|
+
const reachability: NodeReachability = {
|
|
236
|
+
nodeId: '', running: true, verdict: 'mapped', reason: null,
|
|
237
|
+
tcp: { internalPort: 4001, externalPort: 4001, source: 'upnp', leaseExpiresAt: null, error: null },
|
|
238
|
+
ws: { internalPort: 4101, externalPort: 4101, source: 'upnp', leaseExpiresAt: null, error: null },
|
|
239
|
+
publicAddrs: [],
|
|
240
|
+
};
|
|
241
|
+
const asked: Array<{ nodeId: string; ports: { p2p: number; ws?: number } }> = [];
|
|
242
|
+
const { store, svc } = makeHarness({
|
|
243
|
+
addresses: {
|
|
244
|
+
publicAddressesFor: (nodeId, ports) => {
|
|
245
|
+
asked.push({ nodeId, ports });
|
|
246
|
+
return ['/ip4/203.0.113.5/tcp/4001', '/ip4/203.0.113.5/tcp/4101/ws'];
|
|
247
|
+
},
|
|
248
|
+
getStatus: () => ({ nodes: [{ ...reachability, nodeId: asked[0]?.nodeId ?? '' }] }),
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const view = await svc.join();
|
|
252
|
+
stubStatusFetch({});
|
|
253
|
+
|
|
254
|
+
const details = await svc.claimDetails(view.id);
|
|
255
|
+
|
|
256
|
+
// The fake's ports for dock_1, straight from the orchestrator handle.
|
|
257
|
+
expect(asked).toEqual([{ nodeId: view.id, ports: { health: 9001, metrics: 9101, p2p: 4001, ws: 4101 } }]);
|
|
258
|
+
expect(details.peerId).toBe(PEER_ID);
|
|
259
|
+
expect(details.multiaddrs).toEqual([
|
|
260
|
+
`/ip4/203.0.113.5/tcp/4001/p2p/${PEER_ID}`,
|
|
261
|
+
`/ip4/203.0.113.5/tcp/4101/ws/p2p/${PEER_ID}`,
|
|
262
|
+
`/ip4/192.168.1.20/tcp/4101/ws/p2p/${PEER_ID}`,
|
|
263
|
+
]);
|
|
264
|
+
expect(details.reachability?.verdict).toBe('mapped');
|
|
265
|
+
expect(decodeNodeClaimPayload(details.payload)).toEqual({
|
|
266
|
+
peerId: PEER_ID,
|
|
267
|
+
multiaddrs: details.multiaddrs,
|
|
268
|
+
secret: secretOf(requireNode(store, view.id)),
|
|
269
|
+
});
|
|
270
|
+
// The peer id is cached on the record; the view carries it.
|
|
271
|
+
expect(svc.get(view.id)?.peerId).toBe(PEER_ID);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('is 503 until the child answers, and 409 once the node is no longer unclaimed', async () => {
|
|
275
|
+
const { store, svc } = makeHarness();
|
|
276
|
+
const view = await svc.join();
|
|
277
|
+
|
|
278
|
+
stubStatusFetch('down');
|
|
279
|
+
await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
|
|
280
|
+
stubStatusFetch({ peerId: null });
|
|
281
|
+
await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'node_unavailable' });
|
|
282
|
+
|
|
283
|
+
store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P' });
|
|
284
|
+
await expect(svc.claimDetails(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
|
|
285
|
+
await expect(svc.claimDetails('hn_nobody')).rejects.toMatchObject({ code: 'not_found' });
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('HostedNodeService status watcher', () => {
|
|
290
|
+
it('writes joined with the party and owner the claim named, once the node is in that party', async () => {
|
|
291
|
+
const { store, svc, changes } = makeHarness();
|
|
292
|
+
const view = await svc.join();
|
|
293
|
+
changes.length = 0;
|
|
294
|
+
|
|
295
|
+
// The node records the claim before it restarts into the party: `claimed`
|
|
296
|
+
// with the placeholder party is a claim in progress, not a joined node.
|
|
297
|
+
stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'unclaimed' });
|
|
298
|
+
await svc.pollStatuses();
|
|
299
|
+
expect(requireNode(store, view.id).status).toBe('unclaimed');
|
|
300
|
+
expect(changes).toEqual([]);
|
|
301
|
+
|
|
302
|
+
stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 1 });
|
|
303
|
+
await svc.pollStatuses();
|
|
304
|
+
expect(requireNode(store, view.id)).toMatchObject({
|
|
305
|
+
status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, peerId: PEER_ID, connected: true,
|
|
306
|
+
});
|
|
307
|
+
expect(changes).toEqual([{ kind: 'claimed', id: view.id }]);
|
|
308
|
+
|
|
309
|
+
// A joined node's `connected` follows the count, published only on a change.
|
|
310
|
+
await svc.pollStatuses();
|
|
311
|
+
expect(changes).toHaveLength(1);
|
|
312
|
+
stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P', connections: 0 });
|
|
313
|
+
void svc.pollStatuses(); // the 15 s cadence: a second poll in the same tick is skipped
|
|
314
|
+
await svc.pollStatuses();
|
|
315
|
+
expect(requireNode(store, view.id).connected).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('writes nothing for a row removed while its poll was in flight', async () => {
|
|
319
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
320
|
+
const view = await svc.join();
|
|
321
|
+
changes.length = 0;
|
|
322
|
+
|
|
323
|
+
let removed: Promise<void> | undefined;
|
|
324
|
+
stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' }, () => {
|
|
325
|
+
removed = svc.remove(view.id);
|
|
326
|
+
return removed;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await svc.pollStatuses();
|
|
330
|
+
await removed;
|
|
331
|
+
|
|
332
|
+
expect(store.get(view.id)).toBeUndefined();
|
|
333
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
334
|
+
expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('retries a node that does not answer on the next pass', async () => {
|
|
338
|
+
const { store, svc } = makeHarness();
|
|
339
|
+
const view = await svc.join();
|
|
340
|
+
|
|
341
|
+
stubStatusFetch('down');
|
|
342
|
+
await svc.pollStatuses();
|
|
343
|
+
expect(requireNode(store, view.id).status).toBe('unclaimed');
|
|
344
|
+
|
|
345
|
+
stubStatusFetch({ claim: 'claimed', claimedBy: OWNER_KEY, partyId: 'party-P' });
|
|
346
|
+
await svc.pollStatuses();
|
|
347
|
+
expect(requireNode(store, view.id).status).toBe('joined');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('HostedNodeService join by invitation', () => {
|
|
352
|
+
it('follows the node from joining through a failure no member answered, a Retry, and the member\'s acceptance', async () => {
|
|
353
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
354
|
+
const invitation = encodedTestInvitation('party-P');
|
|
355
|
+
|
|
356
|
+
const view = await svc.join({ invitation });
|
|
357
|
+
|
|
358
|
+
// The invitation's party from the start; the bundle reaches the child and nothing else.
|
|
359
|
+
expect(view).toMatchObject({ status: 'joining', partyId: 'party-P', join: { kind: 'invitation' } });
|
|
360
|
+
expect(JSON.stringify(view)).not.toContain(invitation);
|
|
361
|
+
expect(orch.createCalls).toEqual([{ containerId: view.id, partyId: 'party-P', bootstrapNodes: [], profile: 'storage', invitation }]);
|
|
362
|
+
|
|
363
|
+
stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
|
|
364
|
+
await svc.pollStatuses();
|
|
365
|
+
expect(requireNode(store, view.id).status).toBe('joining');
|
|
366
|
+
|
|
367
|
+
// No member reachable: `error`, kept for Retry, its child stopped but not reclaimed.
|
|
368
|
+
stubStatusFetch({ partyId: 'party-P', invitation: { state: 'failed', error: 'No member named in the invitation could be reached.', retryable: true } });
|
|
369
|
+
await svc.pollStatuses();
|
|
370
|
+
expect(requireNode(store, view.id)).toMatchObject({ status: 'error', error: 'No member named in the invitation could be reached.', retryable: true });
|
|
371
|
+
expect(orch.stopped).toEqual(['dock_1']);
|
|
372
|
+
expect(orch.removed).toEqual([]);
|
|
373
|
+
|
|
374
|
+
// The new child starts its redemption over.
|
|
375
|
+
stubStatusFetch({ partyId: 'party-P', invitation: { state: 'pending' } });
|
|
376
|
+
const retried = await svc.retry(view.id);
|
|
377
|
+
expect(retried).toMatchObject({ status: 'joining', dockerId: 'dock_2' });
|
|
378
|
+
expect(retried.error).toBeUndefined();
|
|
379
|
+
expect(retried.retryable).toBeUndefined();
|
|
380
|
+
expect(orch.createCalls[1]).toMatchObject({ containerId: view.id, invitation });
|
|
381
|
+
// An explicit Retry spends no respawn attempt.
|
|
382
|
+
expect(requireNode(store, view.id).respawn).toBeUndefined();
|
|
383
|
+
|
|
384
|
+
stubStatusFetch({ partyId: 'party-P', connections: 1, invitation: { state: 'accepted', memberPeerId: MEMBER_PEER_ID } });
|
|
385
|
+
await svc.pollStatuses();
|
|
386
|
+
expect(requireNode(store, view.id)).toMatchObject({
|
|
387
|
+
status: 'joined', partyId: 'party-P', ownerKey: ISSUER_KEY, memberPeerId: MEMBER_PEER_ID, peerId: PEER_ID, connected: true,
|
|
388
|
+
});
|
|
389
|
+
expect(changes.map((c) => c.kind)).toEqual(['added', 'changed', 'changed', 'changed', 'joined']);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// The orchestrator refuses to re-spawn a container whose child is still running, so
|
|
394
|
+
// each case that expects a spawn has the first child (`dock_1`) go down first.
|
|
395
|
+
describe('HostedNodeService.respawn', () => {
|
|
396
|
+
it('replays the record\'s party and secret, comes back on the same ports, swaps the handles and leaves status alone', async () => {
|
|
397
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
398
|
+
const view = await svc.join();
|
|
399
|
+
const secret = secretOf(requireNode(store, view.id));
|
|
400
|
+
store.put({ ...requireNode(store, view.id), status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, connected: true });
|
|
401
|
+
const portsBefore = orch.getNode(view.id)!.ports;
|
|
402
|
+
changes.length = 0;
|
|
403
|
+
|
|
404
|
+
orch.crash('dock_1');
|
|
405
|
+
const result = await svc.respawn(view.id);
|
|
406
|
+
|
|
407
|
+
expect(result).toMatchObject({ outcome: 'respawned', node: { status: 'joined', dockerId: 'dock_2' } });
|
|
408
|
+
// The claimed party now, and the same secret: cadre-cli honours claim.json and
|
|
409
|
+
// answers a rival `already-claimed` with the secret still set.
|
|
410
|
+
expect(orch.createCalls[1]).toEqual({
|
|
411
|
+
containerId: view.id, partyId: 'party-P', bootstrapNodes: [], profile: 'storage', claimSecret: secret,
|
|
412
|
+
});
|
|
413
|
+
expect(orch.getNode(view.id)!.ports).toEqual(portsBefore);
|
|
414
|
+
|
|
415
|
+
const stored = requireNode(store, view.id);
|
|
416
|
+
expect(stored).toMatchObject({ status: 'joined', partyId: 'party-P', ownerKey: OWNER_KEY, dockerId: 'dock_2', statusEndpoint: 'http://127.0.0.1:9001/status' });
|
|
417
|
+
expect(stored.respawn?.attempts).toBe(1);
|
|
418
|
+
// Unknown until the watcher reads the new child.
|
|
419
|
+
expect(stored.connected).toBeUndefined();
|
|
420
|
+
// Nothing was stopped or reclaimed — the workdir (identity key, node-local
|
|
421
|
+
// stores) has to survive for the respawn to be the same node.
|
|
422
|
+
expect(orch.stopped).toEqual([]);
|
|
423
|
+
expect(orch.removed).toEqual([]);
|
|
424
|
+
expect(changes).toEqual([{ kind: 'changed', id: view.id }]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('refuses an error or spawning record', async () => {
|
|
428
|
+
const { orch, store, svc } = makeHarness();
|
|
429
|
+
const view = await svc.join();
|
|
430
|
+
|
|
431
|
+
store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
|
|
432
|
+
await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
|
|
433
|
+
// A host that died mid-join leaves exactly this row; replaying it would race the join.
|
|
434
|
+
store.put({ ...requireNode(store, view.id), status: 'spawning' });
|
|
435
|
+
await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'invalid_state' });
|
|
436
|
+
expect(orch.createCalls).toHaveLength(1);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('records the attempt and throws when the spawn fails, keeping the old handles', async () => {
|
|
440
|
+
const { orch, store, svc } = makeHarness();
|
|
441
|
+
const view = await svc.join();
|
|
442
|
+
orch.failCreate = true;
|
|
443
|
+
|
|
444
|
+
await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
445
|
+
|
|
446
|
+
const stored = requireNode(store, view.id);
|
|
447
|
+
expect(stored.respawn?.attempts).toBe(1);
|
|
448
|
+
expect(stored.dockerId).toBe('dock_1');
|
|
449
|
+
expect(stored.status).toBe('unclaimed');
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('stops — but never reclaims — the new child when the post-spawn write fails', async () => {
|
|
453
|
+
const store = new FlakyStore(join(tmpRoot, 'hosted'));
|
|
454
|
+
const { orch, svc } = makeHarness({ store });
|
|
455
|
+
const view = await svc.join();
|
|
456
|
+
store.failNextPut = true;
|
|
457
|
+
|
|
458
|
+
orch.crash('dock_1');
|
|
459
|
+
await expect(svc.respawn(view.id)).rejects.toMatchObject({ code: 'orchestrator_error' });
|
|
460
|
+
|
|
461
|
+
// Stopped, not removed: `removeContainer` deletes the workdir, and the
|
|
462
|
+
// workdir is the identity key that makes a later respawn the SAME node.
|
|
463
|
+
expect(orch.stopped).toEqual(['dock_2']);
|
|
464
|
+
expect(orch.removed).toEqual([]);
|
|
465
|
+
// The record names the child that actually exists; the attempt failed, so
|
|
466
|
+
// status and updatedAt are left alone.
|
|
467
|
+
const stored = requireNode(store, view.id);
|
|
468
|
+
expect(stored).toMatchObject({ dockerId: 'dock_2', status: 'unclaimed', updatedAt: view.updatedAt });
|
|
469
|
+
expect(stored.respawn?.attempts).toBe(1);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it('lets a remove that lands after the spawn dropped the old handle win, and reclaims the new child', async () => {
|
|
473
|
+
const { orch, store, svc } = makeHarness();
|
|
474
|
+
const view = await svc.join();
|
|
475
|
+
|
|
476
|
+
// The admin's DELETE lands in the window *after* the spawn dropped the old
|
|
477
|
+
// handle — the window `abandonRespawn` was written for. The remove aimed its
|
|
478
|
+
// stop and reclaim at dock_1, which the spawn had already dropped, so it
|
|
479
|
+
// cleaned up nothing at all; dock_2 is the only thing holding the ports and workdir.
|
|
480
|
+
let removed: Promise<void> | undefined;
|
|
481
|
+
orch.onSpawned = () => { removed ??= svc.remove(view.id); };
|
|
482
|
+
|
|
483
|
+
orch.crash('dock_1');
|
|
484
|
+
const result = await svc.respawn(view.id);
|
|
485
|
+
await removed;
|
|
486
|
+
|
|
487
|
+
expect(result).toEqual({ outcome: 'abandoned' });
|
|
488
|
+
expect(orch.stopped).toEqual(['dock_2']);
|
|
489
|
+
expect(orch.removed).toEqual(['dock_2']);
|
|
490
|
+
expect(store.get(view.id)).toBeUndefined();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('stops but does not reclaim the new child when the record goes error mid-spawn', async () => {
|
|
494
|
+
const { orch, store, svc } = makeHarness();
|
|
495
|
+
const view = await svc.join();
|
|
496
|
+
|
|
497
|
+
// An `error` write landing inside the spawn window. Both spawns share one
|
|
498
|
+
// workdir, so reclaiming here would delete the identity key `error` keeps;
|
|
499
|
+
// the record is pointed at the new child instead, so a later remove reclaims it.
|
|
500
|
+
orch.createDelayMs = 20;
|
|
501
|
+
orch.onCreate = () => {
|
|
502
|
+
store.put({ ...requireNode(store, view.id), status: 'error', error: 'gave up' });
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
orch.crash('dock_1');
|
|
506
|
+
const result = await svc.respawn(view.id);
|
|
507
|
+
|
|
508
|
+
expect(result).toEqual({ outcome: 'abandoned', status: 'error' });
|
|
509
|
+
expect(orch.stopped).toContain('dock_2');
|
|
510
|
+
expect(orch.removed).toEqual([]);
|
|
511
|
+
expect(requireNode(store, view.id)).toMatchObject({ status: 'error', dockerId: 'dock_2' });
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe('HostedNodeService.remove', () => {
|
|
516
|
+
it('deletes the row BEFORE stopping the child, then reclaims it', async () => {
|
|
517
|
+
const { orch, store, svc, changes } = makeHarness();
|
|
518
|
+
const view = await svc.join();
|
|
519
|
+
changes.length = 0;
|
|
520
|
+
// The stop fires the orchestrator's state-change; the supervisor's pass must
|
|
521
|
+
// already find no record, or it brings back a node the admin just removed.
|
|
522
|
+
let rowAtStop: HostedNode | undefined;
|
|
523
|
+
orch.onStop = () => { rowAtStop = store.get(view.id); };
|
|
524
|
+
|
|
525
|
+
await svc.remove(view.id);
|
|
526
|
+
|
|
527
|
+
expect(rowAtStop).toBeUndefined();
|
|
528
|
+
expect(orch.stopped).toEqual(['dock_1']);
|
|
529
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
530
|
+
expect(orch.reclaimedWorkdirs).toEqual([]);
|
|
531
|
+
expect(changes).toEqual([{ kind: 'removed', id: view.id }]);
|
|
532
|
+
// Gone for good: the second remove finds neither a row nor a handle.
|
|
533
|
+
await expect(svc.remove(view.id)).rejects.toMatchObject({ code: 'not_found' });
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('reclaims the workdir by name for a record that never got a dockerId', async () => {
|
|
537
|
+
const { orch, store, svc } = makeHarness();
|
|
538
|
+
const at = '2025-01-01T00:00:00.000Z';
|
|
539
|
+
store.put({
|
|
540
|
+
id: 'hn_stuck', join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
|
|
541
|
+
status: 'spawning', createdAt: at, updatedAt: at,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
await svc.remove('hn_stuck');
|
|
545
|
+
|
|
546
|
+
expect(store.get('hn_stuck')).toBeUndefined();
|
|
547
|
+
expect(orch.reclaimedWorkdirs).toEqual(['hn_stuck']);
|
|
548
|
+
expect(orch.stopped).toEqual([]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('removes a handle no record names, so a lost hosted-nodes.json cannot strand a child', async () => {
|
|
552
|
+
const { orch, svc, changes } = makeHarness();
|
|
553
|
+
const spawn = await orch.createContainer({ containerId: 'hn_orphan', partyId: 'party-P', bootstrapNodes: [], profile: 'storage' });
|
|
554
|
+
|
|
555
|
+
await svc.remove('hn_orphan');
|
|
556
|
+
|
|
557
|
+
expect(orch.stopped).toEqual([spawn.dockerId]);
|
|
558
|
+
expect(orch.removed).toEqual([spawn.dockerId]);
|
|
559
|
+
expect(changes).toEqual([{ kind: 'removed', id: 'hn_orphan' }]);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe('HostedNodeService.reset', () => {
|
|
564
|
+
it('removes the node and starts a fresh one with a new id and secret', async () => {
|
|
565
|
+
const { orch, store, svc } = makeHarness();
|
|
566
|
+
const first = await svc.join();
|
|
567
|
+
const firstSecret = secretOf(requireNode(store, first.id));
|
|
568
|
+
|
|
569
|
+
const second = await svc.reset(first.id);
|
|
570
|
+
|
|
571
|
+
expect(second.id).not.toBe(first.id);
|
|
572
|
+
expect(second.status).toBe('unclaimed');
|
|
573
|
+
expect(store.get(first.id)).toBeUndefined();
|
|
574
|
+
expect(secretOf(requireNode(store, second.id))).not.toBe(firstSecret);
|
|
575
|
+
expect(orch.removed).toEqual(['dock_1']);
|
|
576
|
+
expect(orch.createCalls[1]?.containerId).toBe(second.id);
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe('HostedNodeService.reapStuckSpawning', () => {
|
|
581
|
+
/** A record whose host died right after writing the `spawning` row. */
|
|
582
|
+
const stuckRecord = (id: string, at: string): HostedNode => ({
|
|
583
|
+
id, join: { kind: 'claim', secret: 's' }, partyId: 'unclaimed', profile: 'storage',
|
|
584
|
+
status: 'spawning', createdAt: at, updatedAt: at,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('marks a record stuck past the TTL error and reclaims what the orchestrator can find', async () => {
|
|
588
|
+
let clock = new Date('2025-01-01T00:00:00.000Z');
|
|
589
|
+
const { orch, store, svc, changes } = makeHarness({ now: () => clock });
|
|
590
|
+
// One whose child was spawned before the host died (the orchestrator knows it
|
|
591
|
+
// by dockerId), one that never got that far.
|
|
592
|
+
const spawn = await orch.createContainer({ containerId: 'hn_spawned', partyId: 'unclaimed', bootstrapNodes: [], profile: 'storage' });
|
|
593
|
+
store.put(stuckRecord('hn_spawned', clock.toISOString()));
|
|
594
|
+
store.put(stuckRecord('hn_never', clock.toISOString()));
|
|
595
|
+
clock = new Date(clock.getTime() + HOSTED_NODE_SPAWNING_TTL_MS + 60_000);
|
|
596
|
+
store.put(stuckRecord('hn_fresh', clock.toISOString()));
|
|
597
|
+
|
|
598
|
+
const reaped = await svc.reapStuckSpawning();
|
|
599
|
+
|
|
600
|
+
expect(reaped.sort()).toEqual(['hn_never', 'hn_spawned']);
|
|
601
|
+
expect(requireNode(store, 'hn_spawned').status).toBe('error');
|
|
602
|
+
expect(requireNode(store, 'hn_never').error).toMatch(/stuck starting/);
|
|
603
|
+
expect(requireNode(store, 'hn_fresh').status).toBe('spawning');
|
|
604
|
+
expect(orch.stopped).toEqual([spawn.dockerId]);
|
|
605
|
+
expect(orch.removed).toEqual([spawn.dockerId]);
|
|
606
|
+
expect(orch.reclaimedWorkdirs).toEqual(['hn_never']);
|
|
607
|
+
expect(changes.map((c) => c.id).sort()).toEqual(['hn_never', 'hn_spawned']);
|
|
608
|
+
});
|
|
609
|
+
});
|