@serfab/cadre-host 0.13.0 → 1.1.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.
Files changed (62) hide show
  1. package/README.md +6 -4
  2. package/dist/bin/host.js +9 -1
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/__tests__/fake-orchestrator.js +1 -1
  5. package/dist/donation/__tests__/fake-orchestrator.js.map +1 -1
  6. package/dist/donation/donation-service.d.ts +7 -2
  7. package/dist/donation/donation-service.js +16 -2
  8. package/dist/donation/donation-service.js.map +1 -1
  9. package/dist/donation/donation-supervisor.d.ts +12 -2
  10. package/dist/donation/donation-supervisor.js +25 -2
  11. package/dist/donation/donation-supervisor.js.map +1 -1
  12. package/dist/donation/types.d.ts +2 -1
  13. package/dist/donation/types.js.map +1 -1
  14. package/dist/installer/index.d.ts +9 -1
  15. package/dist/installer/index.js +5 -1
  16. package/dist/installer/index.js.map +1 -1
  17. package/dist/orchestrator/host-process-orchestrator.d.ts +71 -11
  18. package/dist/orchestrator/host-process-orchestrator.js +173 -80
  19. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  20. package/dist/orchestrator/port-allocator.d.ts +44 -2
  21. package/dist/orchestrator/port-allocator.js +63 -5
  22. package/dist/orchestrator/port-allocator.js.map +1 -1
  23. package/dist/orchestrator/port-probe.d.ts +15 -0
  24. package/dist/orchestrator/port-probe.js +22 -0
  25. package/dist/orchestrator/port-probe.js.map +1 -0
  26. package/dist/orchestrator/types.d.ts +13 -1
  27. package/dist/orchestrator/types.js.map +1 -1
  28. package/dist/server/routes/bootstrap-node-validation.d.ts +30 -11
  29. package/dist/server/routes/bootstrap-node-validation.js +32 -13
  30. package/dist/server/routes/bootstrap-node-validation.js.map +1 -1
  31. package/dist/server/server.d.ts +4 -1
  32. package/dist/server/server.js +36 -2
  33. package/dist/server/server.js.map +1 -1
  34. package/dist/ui/assets/{index-C-iks41M.js → index-CEoNRwM_.js} +3 -3
  35. package/dist/ui/assets/{index-C-iks41M.js.map → index-CEoNRwM_.js.map} +1 -1
  36. package/dist/ui/index.html +1 -1
  37. package/package.json +4 -4
  38. package/src/__tests__/orchestrator-env-scrub.test.ts +22 -0
  39. package/src/__tests__/orchestrator-node-identity.test.ts +4 -4
  40. package/src/__tests__/orchestrator-owner.test.ts +45 -2
  41. package/src/__tests__/orchestrator-ports.test.ts +172 -0
  42. package/src/__tests__/orchestrator.test.ts +190 -79
  43. package/src/bin/host.ts +9 -1
  44. package/src/donation/__tests__/donation-service.test.ts +1155 -1068
  45. package/src/donation/__tests__/donation-supervisor.test.ts +548 -510
  46. package/src/donation/__tests__/fake-orchestrator.ts +1 -1
  47. package/src/donation/donation-service.ts +22 -3
  48. package/src/donation/donation-supervisor.ts +25 -2
  49. package/src/donation/types.ts +2 -1
  50. package/src/installer/__tests__/installer.smoke.test.ts +263 -218
  51. package/src/installer/index.ts +327 -314
  52. package/src/orchestrator/host-process-orchestrator.ts +1250 -1143
  53. package/src/orchestrator/port-allocator.ts +148 -91
  54. package/src/orchestrator/port-probe.ts +31 -0
  55. package/src/orchestrator/types.ts +13 -1
  56. package/src/server/__tests__/bootstrap-node-validation.test.ts +12 -8
  57. package/src/server/__tests__/grants-route.test.ts +72 -4
  58. package/src/server/__tests__/nodes-route.test.ts +2 -2
  59. package/src/server/__tests__/publishers.test.ts +1 -1
  60. package/src/server/__tests__/status-route.test.ts +1 -1
  61. package/src/server/routes/bootstrap-node-validation.ts +32 -13
  62. package/src/server/server.ts +157 -122
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>cadre-host</title>
8
- <script type="module" crossorigin src="./assets/index-C-iks41M.js"></script>
8
+ <script type="module" crossorigin src="./assets/index-CEoNRwM_.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="./assets/index-BVoqLjyX.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/cadre-host",
3
- "version": "0.13.0",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted cadre node manager for basement-PC deployments",
6
6
  "main": "dist/index.js",
@@ -65,9 +65,9 @@
65
65
  "@libp2p/interface": "^3.1.0",
66
66
  "@libp2p/peer-id": "^6.0.4",
67
67
  "@multiformats/multiaddr": "^12.5.1",
68
- "@serfab/cadre-cli": "^0.13.0",
69
- "@serfab/cadre-core": "^0.13.0",
70
- "@serfab/cadre-provider": "^0.13.0",
68
+ "@serfab/cadre-cli": "^1.1.0",
69
+ "@serfab/cadre-core": "^1.1.0",
70
+ "@serfab/cadre-provider": "^1.1.0",
71
71
  "commander": "^14.0.2",
72
72
  "debug": "^4.4.3",
73
73
  "fastify": "^5.7.1",
@@ -12,6 +12,10 @@
12
12
  *
13
13
  * A fake CLI records the vars under test from its environment to a file so
14
14
  * the scrub is observable without a real cadre node.
15
+ *
16
+ * The same record carries `CADRE_LISTEN_ADDRS`, one of the vars the orchestrator
17
+ * deliberately SETS: a child must be handed a WebSocket listener beside its TCP one,
18
+ * on the ports its handle holds, or a phone cannot reach it.
15
19
  */
16
20
 
17
21
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
@@ -35,6 +39,7 @@ if (tokenPath) {
35
39
  CADRE_PARTY_ID: process.env.CADRE_PARTY_ID ?? null,
36
40
  CADRE_STORAGE_PATH: process.env.CADRE_STORAGE_PATH ?? null,
37
41
  CADRE_ADMIN_PORT: process.env.CADRE_ADMIN_PORT ?? null,
42
+ CADRE_LISTEN_ADDRS: process.env.CADRE_LISTEN_ADDRS ?? null,
38
43
  DEBUG: process.env.DEBUG ?? null,
39
44
  };
40
45
  try { fs.writeFileSync(path.join(dir, 'env-seen.json'), JSON.stringify(record), 'utf8'); } catch (e) { console.error(e); }
@@ -137,4 +142,21 @@ describe('HostProcessOrchestrator child env scrub', () => {
137
142
  expect(seen.DEBUG).toBe('some:value');
138
143
  }
139
144
  });
145
+
146
+ it('hands each child a TCP and a WebSocket listen address on its own ports', async () => {
147
+ const rootDir = join(tmpRoot, 'b');
148
+ const orch = makeOrchestrator(rootDir);
149
+ await orch.init();
150
+
151
+ await orch.createContainer({
152
+ containerId: 'child-1',
153
+ partyId: 'party-1',
154
+ bootstrapNodes: [],
155
+ profile: 'storage',
156
+ });
157
+
158
+ const seen = JSON.parse(await waitForFile(join(rootDir, 'child-1', 'env-seen.json')));
159
+ const ports = orch.getNode('child-1')!.ports;
160
+ expect(seen.CADRE_LISTEN_ADDRS).toBe(`/ip4/0.0.0.0/tcp/${ports.p2p},/ip4/0.0.0.0/tcp/${ports.ws}/ws`);
161
+ });
140
162
  });
@@ -162,12 +162,12 @@ describe('HostProcessOrchestrator node identity', () => {
162
162
  // The failure the identity step can actually produce is a damaged key file,
163
163
  // and `DonationService.provision` turns every such throw into an `error`
164
164
  // record and lets the grantee retry. If the spawn path reserved its ports
165
- // before the step that throws, each retry would burn four more ports out of a
165
+ // before the step that throws, each retry would burn five more ports out of a
166
166
  // bounded range until provisioning stopped working altogether.
167
167
  it('reserves no ports when the identity step fails', async () => {
168
168
  const rootDir = join(tmpRoot, 'd');
169
- // Exactly one node's worth of ports (health, metrics, p2p, admin).
170
- const orch = makeOrchestrator(rootDir, { start: 18100, end: 18103 });
169
+ // Exactly one node's worth of ports (health, metrics, p2p, admin, ws).
170
+ const orch = makeOrchestrator(rootDir, { start: 18100, end: 18104 });
171
171
  await orch.init();
172
172
 
173
173
  mkdirSync(join(rootDir, 'donated-bad'), { recursive: true });
@@ -181,7 +181,7 @@ describe('HostProcessOrchestrator node identity', () => {
181
181
  };
182
182
  await expect(orch.createContainer({ ...request, containerId: 'donated-bad' })).rejects.toThrow();
183
183
 
184
- // The whole range is still free, so a healthy container still gets its four.
184
+ // The whole range is still free, so a healthy container still gets its five.
185
185
  const ok = await orch.createContainer({ ...request, containerId: 'donated-good' });
186
186
  expect(ok.p2pPort).toBeGreaterThanOrEqual(18100);
187
187
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
14
 
@@ -165,7 +165,9 @@ describe('HostProcessOrchestrator.ensureOwnerNode', () => {
165
165
  const a = makeOrchestrator(rootDir);
166
166
  await a.init();
167
167
  const node = await a.ensureOwnerNode(CFG);
168
- await waitFor(() => a.isRunning(node.dockerId));
168
+ // Not `a.isRunning`: that answers from the ChildProcess at once, but `b`
169
+ // below can only recognise the node once it has written its startup token.
170
+ await waitFor(() => existsSync(join(rootDir, OWNER_CONTAINER_ID, '.startup-token')));
169
171
  const before = a.getOwnerAdminEndpoint();
170
172
 
171
173
  const b = makeOrchestrator(rootDir);
@@ -180,6 +182,29 @@ describe('HostProcessOrchestrator.ensureOwnerNode', () => {
180
182
  expect(isPidAlive(pid)).toBe(true);
181
183
  });
182
184
 
185
+ // The owner's own guard arm: its admin channel binds on loopback, so the probe
186
+ // must check 127.0.0.1 too — the fake owner binds nothing else.
187
+ it('refuses to re-spawn over a re-attached owner whose admin port is still bound', async () => {
188
+ const rootDir = join(tmpRoot, 'g');
189
+ const a = makeOrchestrator(rootDir);
190
+ await a.init();
191
+ const node = await a.ensureOwnerNode(CFG);
192
+ const tokenPath = join(rootDir, OWNER_CONTAINER_ID, '.startup-token');
193
+ await waitFor(async () => (await a.getLogs(node.dockerId)).includes('admin listening'));
194
+
195
+ // Unverified as far as `b` can tell: live pid, token not (yet) matching.
196
+ writeFileSync(tokenPath, 'not-yet', 'utf8');
197
+ const b = makeOrchestrator(rootDir);
198
+ await b.init();
199
+ expect(await b.isRunning(node.dockerId)).toBe(false);
200
+
201
+ await expect(b.ensureOwnerNode(CFG)).rejects.toThrow(
202
+ new RegExp(`owner may still be running: port ${node.ports.admin} on 127\\.0\\.0\\.1 is unavailable`),
203
+ );
204
+ expect(b.listNodes().map((n) => n.dockerId)).toEqual([node.dockerId]);
205
+ expect(isPidAlive(decodeDockerId(node.dockerId).pid)).toBe(true);
206
+ });
207
+
183
208
  it('restartOwnerNode preserves the workdir and re-spawns', async () => {
184
209
  const orch = makeOrchestrator(join(tmpRoot, 'e'));
185
210
  await orch.init();
@@ -195,4 +220,22 @@ describe('HostProcessOrchestrator.ensureOwnerNode', () => {
195
220
  expect(orch.listNodes()).toHaveLength(1);
196
221
  await waitFor(() => !isPidAlive(firstPid));
197
222
  });
223
+
224
+ // Like a lent node, the owner node comes back on its previous ports even when lower
225
+ // ones are free. Only its TCP port differs: the configured libp2pPort wins over the
226
+ // previous handle's, because the NAT mapping points at the configured one.
227
+ it('re-spawns on its previous ports, taking p2p from the current config', async () => {
228
+ const orch = makeOrchestrator(join(tmpRoot, 'f'));
229
+ await orch.init();
230
+ const lower = await orch.createContainer({ containerId: 'c0', partyId: 'party-c0', bootstrapNodes: [], profile: 'transaction' });
231
+ const first = await orch.ensureOwnerNode(CFG);
232
+ await waitFor(() => orch.isRunning(first.dockerId));
233
+
234
+ await orch.removeContainer(lower.dockerId);
235
+ await orch.stopOwnerNode();
236
+ const respawned = await orch.ensureOwnerNode({ ...CFG, libp2pPort: 4556 });
237
+
238
+ expect(respawned.dockerId).not.toBe(first.dockerId);
239
+ expect(respawned.ports).toEqual({ ...first.ports, p2p: 4556 });
240
+ });
198
241
  });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * The pure port-set logic behind `HostProcessOrchestrator`: the allocator, the
3
+ * helpers that allocate, hold, release and reuse one node's set of ports, and the
4
+ * listen addresses a child is started with. No child processes — the behaviour these
5
+ * feed is exercised against a stub child in `orchestrator.test.ts`.
6
+ */
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import { childListenAddrs } from '../orchestrator/host-process-orchestrator.js';
11
+ import {
12
+ allocateNodePorts,
13
+ PortAllocator,
14
+ releaseNodePorts,
15
+ reserveNodePorts,
16
+ reusedNodePorts,
17
+ } from '../orchestrator/port-allocator.js';
18
+ import type { NodePorts } from '../orchestrator/types.js';
19
+
20
+ /**
21
+ * A port set as `state.json` hands it back from a build that predates the `ws` key:
22
+ * the type says every key is present, the data does not.
23
+ */
24
+ function portsWithoutWs(ports: Omit<NodePorts, 'ws'>): NodePorts {
25
+ return ports as NodePorts;
26
+ }
27
+
28
+ describe('PortAllocator', () => {
29
+ it('allocates sequentially, releases, and reuses', () => {
30
+ const a = new PortAllocator(100, 102);
31
+ expect(a.allocate()).toBe(100);
32
+ expect(a.allocate()).toBe(101);
33
+ expect(a.allocate()).toBe(102);
34
+ expect(() => a.allocate()).toThrow(/No available ports/);
35
+ a.release(101);
36
+ expect(a.allocate()).toBe(101);
37
+ });
38
+
39
+ it('markUsed reserves ports without allocating', () => {
40
+ const a = new PortAllocator(100, 102);
41
+ a.markUsed(101);
42
+ expect(a.allocate()).toBe(100);
43
+ expect(a.allocate()).toBe(102);
44
+ expect(() => a.allocate()).toThrow();
45
+ });
46
+
47
+ // Without the integer check, `undefined` and NaN pass both range comparisons (each
48
+ // is false) and land in the used-set, as would an in-range fraction.
49
+ it('markUsed ignores a non-integer', () => {
50
+ const a = new PortAllocator(100, 101);
51
+ for (const junk of [undefined as unknown as number, Number.NaN, 100.5]) {
52
+ a.markUsed(junk);
53
+ expect(a.has(junk)).toBe(false);
54
+ }
55
+ });
56
+ });
57
+
58
+ describe('allocateNodePorts', () => {
59
+ it('allocates the five ports in a fixed order, the newest key last', () => {
60
+ const a = new PortAllocator(10000, 10010);
61
+ expect(allocateNodePorts(a)).toEqual({ health: 10000, metrics: 10001, p2p: 10002, admin: 10003, ws: 10004 });
62
+ });
63
+
64
+ // A partial set left reserved would leak from a bounded range on every failed
65
+ // provision — the whole reason this helper exists rather than five allocates.
66
+ it('is all-or-nothing: an exhausted range releases everything it took', () => {
67
+ const a = new PortAllocator(13000, 13002);
68
+ expect(() => allocateNodePorts(a)).toThrow(/No available ports/);
69
+ expect(a.has(13000)).toBe(false);
70
+ expect(a.has(13001)).toBe(false);
71
+ expect(a.has(13002)).toBe(false);
72
+ });
73
+
74
+ // Four ports used to be one node's worth. A range sized for that now fails on the
75
+ // fifth, and must neither hand back a set missing `ws` nor keep the four it took.
76
+ it('fails cleanly on a range with room for four ports but not five', () => {
77
+ const a = new PortAllocator(13000, 13003);
78
+ expect(() => allocateNodePorts(a)).toThrow(/No available ports/);
79
+ for (let port = 13000; port <= 13003; port++) expect(a.has(port)).toBe(false);
80
+ });
81
+
82
+ // The owner node's p2p port is pinned by the NAT mapping, not allocated.
83
+ it('honours an override and reserves it', () => {
84
+ const a = new PortAllocator(10000, 10010);
85
+ expect(allocateNodePorts(a, { p2p: 10005 })).toEqual({
86
+ health: 10000,
87
+ metrics: 10001,
88
+ p2p: 10005,
89
+ admin: 10002,
90
+ ws: 10003,
91
+ });
92
+ // Reserved, so no later allocation can hand it out again.
93
+ expect(a.has(10005)).toBe(true);
94
+ });
95
+
96
+ // An out-of-range override is the production case for the owner node: its
97
+ // libp2p port is chosen by NAT config, not by the allocator, so reserving it
98
+ // is a documented no-op rather than an error.
99
+ it('accepts an override outside the managed range without reserving it', () => {
100
+ const a = new PortAllocator(10000, 10010);
101
+ expect(allocateNodePorts(a, { p2p: 40000 })).toMatchObject({ p2p: 40000, health: 10000 });
102
+ expect(a.has(40000)).toBe(false);
103
+ });
104
+
105
+ // A re-spawn passes its previous set as overrides; every one must come back exactly.
106
+ it('returns a full set of overrides exactly, allocating nothing', () => {
107
+ const a = new PortAllocator(10000, 10010);
108
+ const previous = { health: 10006, metrics: 10007, p2p: 10008, admin: 10009, ws: 10010 };
109
+ expect(allocateNodePorts(a, previous)).toEqual(previous);
110
+ expect(a.allocate()).toBe(10000);
111
+ });
112
+ });
113
+
114
+ describe('reserveNodePorts / releaseNodePorts', () => {
115
+ it('hold and then release every key of the set', () => {
116
+ const a = new PortAllocator(10000, 10010);
117
+ const ports = { health: 10000, metrics: 10001, p2p: 10002, admin: 10003, ws: 10004 };
118
+ reserveNodePorts(a, ports);
119
+ for (const port of Object.values(ports)) expect(a.has(port)).toBe(true);
120
+ releaseNodePorts(a, ports);
121
+ for (const port of Object.values(ports)) expect(a.has(port)).toBe(false);
122
+ });
123
+
124
+ // Rehydrating, or dropping, a `state.json` handle written before `ws` existed.
125
+ it('skip a key the set lacks', () => {
126
+ const a = new PortAllocator(10000, 10010);
127
+ const ports = portsWithoutWs({ health: 10000, metrics: 10001, p2p: 10002, admin: 10003 });
128
+ reserveNodePorts(a, ports);
129
+ expect(a.has(undefined as unknown as number)).toBe(false);
130
+ expect(a.allocate()).toBe(10004);
131
+ releaseNodePorts(a, ports);
132
+ expect(a.has(10000)).toBe(false);
133
+ expect(a.has(10004)).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe('reusedNodePorts', () => {
138
+ const previous = { health: 10005, metrics: 10006, p2p: 10007, admin: 10008, ws: 10009 };
139
+
140
+ it("returns the dropped handle's whole port set", () => {
141
+ expect(reusedNodePorts([{ ports: previous }])).toEqual(previous);
142
+ });
143
+
144
+ it('returns nothing when nothing was dropped, so every port is allocated fresh', () => {
145
+ expect(reusedNodePorts([])).toEqual({});
146
+ });
147
+
148
+ // A handle persisted before `ws` existed: its other ports come back, `ws` is new.
149
+ it('omits a key the dropped handle lacks', () => {
150
+ const legacy = portsWithoutWs({ health: 10005, metrics: 10006, p2p: 10007, admin: 10008 });
151
+ const reused = reusedNodePorts([{ ports: legacy }]);
152
+ expect(reused).toEqual({ health: 10005, metrics: 10006, p2p: 10007, admin: 10008 });
153
+ expect('ws' in reused).toBe(false);
154
+
155
+ const a = new PortAllocator(10000, 10010);
156
+ expect(allocateNodePorts(a, reused)).toEqual({ health: 10005, metrics: 10006, p2p: 10007, admin: 10008, ws: 10000 });
157
+ });
158
+
159
+ it('takes the last handle when a state holding duplicates dropped several', () => {
160
+ const older = { health: 10000, metrics: 10001, p2p: 10002, admin: 10003, ws: 10004 };
161
+ expect(reusedNodePorts([{ ports: older }, { ports: previous }])).toEqual(previous);
162
+ });
163
+ });
164
+
165
+ describe('childListenAddrs', () => {
166
+ it('names a TCP listener on the p2p port and a WebSocket listener on the ws port, on every interface', () => {
167
+ expect(childListenAddrs({ p2p: 10002, ws: 10004 })).toEqual([
168
+ '/ip4/0.0.0.0/tcp/10002',
169
+ '/ip4/0.0.0.0/tcp/10004/ws',
170
+ ]);
171
+ });
172
+ });