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