@serfab/cadre-host 0.10.0 → 0.12.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 (158) hide show
  1. package/README.md +406 -296
  2. package/dist/bin/host.d.ts +11 -7
  3. package/dist/bin/host.js +40 -12
  4. package/dist/bin/host.js.map +1 -1
  5. package/dist/installer/config.d.ts +4 -4
  6. package/dist/installer/config.js +5 -39
  7. package/dist/installer/config.js.map +1 -1
  8. package/dist/installer/identity.d.ts +8 -1
  9. package/dist/installer/identity.js +8 -1
  10. package/dist/installer/identity.js.map +1 -1
  11. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  12. package/dist/orchestrator/host-process-orchestrator.js +9 -4
  13. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  14. package/dist/server/settings-store.d.ts +1 -1
  15. package/dist/server/settings-store.js +5 -1
  16. package/dist/server/settings-store.js.map +1 -1
  17. package/dist/server/static.js +38 -38
  18. package/dist/ui/assets/index-C-iks41M.js.map +1 -1
  19. package/dist/ui/index.html +12 -12
  20. package/package.json +4 -4
  21. package/service/cadre-host.service.tmpl +16 -16
  22. package/service/com.serfab.cadre-host.plist.tmpl +35 -35
  23. package/service/install-service.ps1 +98 -98
  24. package/service/uninstall-service.ps1 +19 -19
  25. package/src/__tests__/build-targets.test.ts +26 -26
  26. package/src/__tests__/cli-invite.smoke.test.ts +192 -192
  27. package/src/__tests__/cli-nat.smoke.test.ts +212 -212
  28. package/src/__tests__/cli.smoke.test.ts +51 -46
  29. package/src/__tests__/global-setup.ts +56 -51
  30. package/src/__tests__/host-owner.smoke.test.ts +123 -123
  31. package/src/__tests__/orchestrator-env-scrub.test.ts +140 -140
  32. package/src/__tests__/orchestrator-node-identity.test.ts +5 -5
  33. package/src/__tests__/orchestrator-owner.test.ts +198 -198
  34. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -153
  35. package/src/__tests__/orchestrator-push.test.ts +145 -145
  36. package/src/__tests__/orchestrator.test.ts +951 -951
  37. package/src/auth/__tests__/duration.test.ts +28 -28
  38. package/src/auth/__tests__/self-label.test.ts +85 -85
  39. package/src/auth/__tests__/trust-circle-store.test.ts +115 -115
  40. package/src/auth/__tests__/trust-circle.test.ts +445 -445
  41. package/src/auth/duration.ts +33 -33
  42. package/src/auth/index.ts +21 -21
  43. package/src/auth/self-label.ts +74 -74
  44. package/src/auth/trust-circle-store.ts +155 -155
  45. package/src/auth/types.ts +99 -99
  46. package/src/bin/__tests__/cli-reference.test.ts +167 -0
  47. package/src/bin/host.ts +1326 -1299
  48. package/src/donation/__tests__/donation-service.test.ts +1068 -1068
  49. package/src/donation/__tests__/donation-supervisor.test.ts +510 -510
  50. package/src/donation/__tests__/fake-orchestrator.test.ts +168 -168
  51. package/src/donation/__tests__/fake-orchestrator.ts +225 -225
  52. package/src/donation/__tests__/grant-service.test.ts +213 -213
  53. package/src/donation/__tests__/grant-store.test.ts +113 -113
  54. package/src/donation/donation-service.ts +962 -962
  55. package/src/donation/donation-store.ts +171 -171
  56. package/src/donation/donation-supervisor.ts +388 -388
  57. package/src/donation/grant-service.ts +200 -200
  58. package/src/donation/grant-store.ts +152 -152
  59. package/src/donation/index.ts +64 -64
  60. package/src/donation/types.ts +243 -243
  61. package/src/installer/__tests__/config.test.ts +134 -112
  62. package/src/installer/__tests__/identity.test.ts +34 -34
  63. package/src/installer/__tests__/paths.test.ts +47 -47
  64. package/src/installer/__tests__/service-host.test.ts +62 -62
  65. package/src/installer/__tests__/wizard.test.ts +64 -64
  66. package/src/installer/config.ts +5 -43
  67. package/src/installer/identity.ts +56 -49
  68. package/src/installer/paths.ts +109 -109
  69. package/src/installer/platform.ts +27 -27
  70. package/src/installer/service-host/index.ts +21 -21
  71. package/src/installer/service-host/template.ts +21 -21
  72. package/src/installer/wizard.ts +103 -103
  73. package/src/nat/__tests__/address-resolver.test.ts +82 -82
  74. package/src/nat/__tests__/ddns-duckdns.test.ts +83 -83
  75. package/src/nat/__tests__/ddns-updater.test.ts +253 -253
  76. package/src/nat/__tests__/external-ip.test.ts +124 -124
  77. package/src/nat/__tests__/nat-service.test.ts +18 -0
  78. package/src/nat/__tests__/nat-store.test.ts +93 -93
  79. package/src/nat/__tests__/port-mapper.test.ts +191 -191
  80. package/src/nat/__tests__/reachability.test.ts +24 -24
  81. package/src/nat/__tests__/secrets.test.ts +178 -178
  82. package/src/nat/address-resolver.ts +51 -51
  83. package/src/nat/ddns/duckdns.ts +76 -76
  84. package/src/nat/ddns/index.ts +55 -55
  85. package/src/nat/ddns/updater.ts +243 -243
  86. package/src/nat/external-ip.ts +154 -154
  87. package/src/nat/index.ts +71 -71
  88. package/src/nat/nat-service.ts +501 -501
  89. package/src/nat/nat-store.ts +154 -154
  90. package/src/nat/port-mapper.ts +329 -329
  91. package/src/nat/reachability.ts +34 -34
  92. package/src/nat/secrets/file-store.ts +130 -130
  93. package/src/nat/secrets/index.ts +112 -112
  94. package/src/nat/secrets/keytar-store.ts +67 -67
  95. package/src/nat/types.ts +159 -159
  96. package/src/orchestrator/__tests__/node-identity.test.ts +62 -62
  97. package/src/orchestrator/host-process-orchestrator.ts +9 -4
  98. package/src/orchestrator/index.ts +10 -10
  99. package/src/orchestrator/log-rotator.ts +53 -53
  100. package/src/orchestrator/node-identity.ts +40 -40
  101. package/src/orchestrator/pid-liveness.ts +24 -24
  102. package/src/orchestrator/port-allocator.ts +91 -91
  103. package/src/orchestrator/state-store.ts +90 -90
  104. package/src/orchestrator/types.ts +141 -141
  105. package/src/owner/__tests__/owner-node-client.test.ts +194 -194
  106. package/src/owner/index.ts +12 -12
  107. package/src/owner/owner-node-client.ts +259 -259
  108. package/src/push/__tests__/push.test.ts +90 -90
  109. package/src/push/index.ts +146 -146
  110. package/src/server/__tests__/bus.test.ts +38 -38
  111. package/src/server/__tests__/error-handler.test.ts +118 -118
  112. package/src/server/__tests__/grants-admin-route.test.ts +73 -73
  113. package/src/server/__tests__/grants-route.test.ts +263 -263
  114. package/src/server/__tests__/nodes-route.test.ts +259 -259
  115. package/src/server/__tests__/origin-guard.test.ts +72 -72
  116. package/src/server/__tests__/publishers.test.ts +216 -216
  117. package/src/server/__tests__/server.smoke.test.ts +253 -253
  118. package/src/server/__tests__/settings-route.test.ts +195 -194
  119. package/src/server/__tests__/sse-route.test.ts +119 -119
  120. package/src/server/__tests__/static.test.ts +145 -145
  121. package/src/server/__tests__/status-route.test.ts +134 -134
  122. package/src/server/__tests__/strands-route.test.ts +233 -233
  123. package/src/server/error-handler.ts +178 -178
  124. package/src/server/events/bus.ts +42 -42
  125. package/src/server/events/sse-route.ts +81 -81
  126. package/src/server/events/types.ts +23 -23
  127. package/src/server/index.ts +267 -267
  128. package/src/server/origin-guard.ts +84 -84
  129. package/src/server/routes/grants-admin.ts +41 -41
  130. package/src/server/routes/grants.ts +210 -210
  131. package/src/server/routes/nat.ts +63 -63
  132. package/src/server/routes/nodes.ts +170 -170
  133. package/src/server/routes/settings.ts +123 -123
  134. package/src/server/routes/status.ts +118 -118
  135. package/src/server/routes/strands.ts +54 -54
  136. package/src/server/routes/trust-circle.ts +49 -49
  137. package/src/server/routes/update.ts +43 -43
  138. package/src/server/server.ts +122 -122
  139. package/src/server/settings-store.ts +5 -1
  140. package/src/server/static.ts +207 -207
  141. package/src/strands/__tests__/strand-service.test.ts +165 -165
  142. package/src/strands/index.ts +14 -14
  143. package/src/strands/strand-service.ts +135 -135
  144. package/src/strands/types.ts +102 -102
  145. package/src/update/__tests__/apply.test.ts +158 -158
  146. package/src/update/__tests__/manifest.test.ts +128 -128
  147. package/src/update/__tests__/release-key.test.ts +42 -42
  148. package/src/update/__tests__/sign.test.ts +78 -78
  149. package/src/update/__tests__/store.test.ts +81 -81
  150. package/src/update/__tests__/update-service.integration.test.ts +256 -256
  151. package/src/update/__tests__/version.test.ts +31 -31
  152. package/src/update/apply.ts +190 -190
  153. package/src/update/index.ts +341 -341
  154. package/src/update/release-key.ts +71 -71
  155. package/src/update/sign.ts +74 -74
  156. package/src/update/store.ts +101 -101
  157. package/src/update/types.ts +109 -109
  158. package/src/update/version.ts +80 -80
@@ -1,388 +1,388 @@
1
- import debug from 'debug';
2
-
3
- import type { Orchestrator } from '@serfab/cadre-provider';
4
-
5
- import type { ManagedNodeInfo, NodeStateListener } from '../orchestrator/types.js';
6
- import type { DonationService } from './donation-service.js';
7
- import type { DonationStore } from './donation-store.js';
8
- import type { Donation, DonationStatus } from './types.js';
9
-
10
- const log = debug('cadre:host:donation-supervisor');
11
-
12
- /**
13
- * Unit of the doubling respawn backoff: the wait after `n` consecutive failed
14
- * attempts is `base * 2^n`, capped at {@link DONATION_RESPAWN_BACKOFF_MAX_MS}.
15
- * So the first retry waits 10s (one attempt is already recorded by then), the
16
- * second 20s, and so on. A node with no recorded attempt is respawned
17
- * immediately.
18
- */
19
- export const DONATION_RESPAWN_BACKOFF_BASE_MS = 5_000;
20
-
21
- /**
22
- * Ceiling on the doubling respawn backoff. 5 minutes.
23
- *
24
- * NOTE: not reachable at the current {@link DONATION_RESPAWN_MAX_ATTEMPTS} of 5
25
- * — the longest wait actually served is the one before the 5th attempt,
26
- * `5s * 2^4` = 80s, and the 5th failure gives up. The cap only starts clamping
27
- * once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
28
- * the attempt cap without revisiting this one changes nothing.
29
- */
30
- export const DONATION_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
31
-
32
- /**
33
- * Consecutive respawn attempts after which the host stops trying and marks the
34
- * donation `error` — a node that will not stay up is a host-side fault the
35
- * borrower cannot see, so surface it instead of spinning forever.
36
- */
37
- export const DONATION_RESPAWN_MAX_ATTEMPTS = 5;
38
-
39
- /**
40
- * How long a respawned node must stay up before its attempt budget is refilled.
41
- * The last respawn produced something that survived, so the next crash starts
42
- * from a fresh budget rather than inheriting an old crash loop's count.
43
- */
44
- export const DONATION_RESPAWN_HEALTHY_MS = 10 * 60_000;
45
-
46
- /** How often the supervisor sweep runs while cadre-host is up. 1 minute. */
47
- export const DONATION_RESPAWN_SWEEP_MS = 60_000;
48
-
49
- /**
50
- * Statuses the supervisor considers. `provisioning` is a provision still in
51
- * flight (its own code path owns the child); `error` and `terminated` are
52
- * terminal — a loan the host gave up on or the borrower ended, neither of which
53
- * may come back on its own.
54
- */
55
- const SUPERVISED_STATUSES: ReadonlySet<DonationStatus> = new Set<DonationStatus>([
56
- 'awaiting_seed',
57
- 'seeded',
58
- ]);
59
-
60
- /**
61
- * The orchestrator surface the supervisor needs: the shared `Orchestrator`
62
- * (for `isRunning` / `stopContainer`) plus cadre-host's own exit-event
63
- * subscription, which is not part of that cross-package interface.
64
- * `HostProcessOrchestrator` satisfies this.
65
- */
66
- export interface SupervisedOrchestrator extends Orchestrator {
67
- onStateChange(listener: NodeStateListener): () => void;
68
- }
69
-
70
- /** Constructor options. */
71
- export interface DonationSupervisorOptions {
72
- /** Lifecycle service — the supervisor drives its `respawn`. */
73
- service: DonationService;
74
- /** Persistent donation store — the candidate list and the give-up write. */
75
- store: DonationStore;
76
- /** Orchestrator that owns the donated child processes. */
77
- orchestrator: SupervisedOrchestrator;
78
- /** Clock override for tests. */
79
- now?: () => Date;
80
- }
81
-
82
- /**
83
- * DonationSupervisor — owns the invariant *a non-terminal donation is expected
84
- * to be running*.
85
- *
86
- * Nothing else re-spawns a donated node: `bin/host.ts` re-spawns only the host's
87
- * own owner node, the `/api/nodes/:id/{start,restart}` routes refuse non-owner
88
- * ids, and the stale-`awaiting_seed` reap only terminates. So without this class
89
- * a crashed, OOM-killed, or reboot-killed donated node stays dead with its
90
- * record still reading `seeded`, silently costing the borrower a node and their
91
- * grant a quota slot.
92
- *
93
- * One code path ({@link reconcile}) behind three triggers:
94
- *
95
- * - **host startup** — one pass after `orchestrator.init()`, which catches every
96
- * node that died while the host was down;
97
- * - **exit signal** — the orchestrator's `onStateChange`, so a crash is noticed
98
- * in milliseconds rather than at the next sweep;
99
- * - **periodic sweep** — the backstop for deaths no exit event covers (host was
100
- * down, listener missed, orchestrator re-attached to an already-dead pid).
101
- *
102
- * Passes are serialized: `DonationService.respawn` is not itself serialized, and
103
- * two overlapping respawns of one id both spawn a child while the second drops
104
- * the first's orchestrator handle (see its docstring). Since two of the three
105
- * triggers can fire at once, the serialization lives here.
106
- */
107
- export class DonationSupervisor {
108
- private readonly service: DonationService;
109
- private readonly store: DonationStore;
110
- private readonly orchestrator: SupervisedOrchestrator;
111
- private readonly now: () => Date;
112
-
113
- /** Serialization tail — see the class docstring. */
114
- private tail: Promise<void> = Promise.resolve();
115
- private timer?: ReturnType<typeof setInterval>;
116
- private unsubscribe?: () => void;
117
- /** At most one exit-triggered pass queued at a time (crash storms coalesce). */
118
- private exitPassQueued = false;
119
- /** Set by `stop()` so an in-flight pass abandons its remaining records. */
120
- private disposed = false;
121
-
122
- constructor(opts: DonationSupervisorOptions) {
123
- this.service = opts.service;
124
- this.store = opts.store;
125
- this.orchestrator = opts.orchestrator;
126
- this.now = opts.now ?? (() => new Date());
127
- log('DonationSupervisor initialized');
128
- }
129
-
130
- /**
131
- * One reconcile pass over the donation store. Serialized against every other
132
- * pass, so a caller may get a queued pass rather than an immediate one.
133
- * Returns the donation ids it respawned in *its own* pass.
134
- */
135
- reconcile(): Promise<string[]> {
136
- const run = (): Promise<string[]> => this.reconcileOnce();
137
- const next = this.tail.then(run, run);
138
- // The stored tail swallows outcomes — a failed pass must not reject the
139
- // next caller's wait, only sequence after it.
140
- this.tail = next.then(() => undefined, () => undefined);
141
- return next;
142
- }
143
-
144
- /**
145
- * Subscribe to child exits, start the periodic sweep, and run the startup
146
- * pass. Idempotent — a second call while started is a no-op.
147
- */
148
- start(): void {
149
- if (this.timer) return;
150
- this.disposed = false;
151
- // Subscribe before the first pass so an exit during it is not missed.
152
- this.unsubscribe = this.orchestrator.onStateChange((info) => { this.onNodeState(info); });
153
- this.timer = setInterval(() => { this.sweep('timer'); }, DONATION_RESPAWN_SWEEP_MS);
154
- this.timer.unref();
155
- this.sweep('startup');
156
- }
157
-
158
- /**
159
- * Stop the timer and unsubscribe. A pass already in flight abandons its
160
- * remaining records rather than being awaited — every record it has not
161
- * reached is picked up by the next host start's startup pass.
162
- */
163
- stop(): void {
164
- this.disposed = true;
165
- if (this.timer) {
166
- clearInterval(this.timer);
167
- this.timer = undefined;
168
- }
169
- this.unsubscribe?.();
170
- this.unsubscribe = undefined;
171
- log('DonationSupervisor stopped');
172
- }
173
-
174
- /** Fire-and-forget pass for the timer / exit / startup triggers. */
175
- private sweep(trigger: string): void {
176
- void this.reconcile()
177
- .then((ids) => {
178
- if (ids.length) log('%s sweep respawned %d donation(s): %o', trigger, ids.length, ids);
179
- })
180
- .catch((err) => {
181
- console.error(`donation respawn sweep failed: ${errorMessage(err)}`);
182
- });
183
- }
184
-
185
- /**
186
- * Exit-event trigger. Deliberately cheap: it does not map the handle back to a
187
- * donation (that needs a store read per event), it just asks for a pass. Owner
188
- * node exits are not ours — `bin/host.ts` owns re-spawning that one.
189
- */
190
- private onNodeState(info: ManagedNodeInfo): void {
191
- if (info.owner || info.status !== 'stopped') return;
192
- if (this.exitPassQueued) return;
193
- this.exitPassQueued = true;
194
- void this.reconcile()
195
- .catch((err) => { console.error(`donation respawn sweep failed: ${errorMessage(err)}`); })
196
- .finally(() => { this.exitPassQueued = false; });
197
- }
198
-
199
- /** The unserialized body of one pass. */
200
- private async reconcileOnce(): Promise<string[]> {
201
- const respawned: string[] = [];
202
- let candidates: Donation[];
203
- try {
204
- candidates = this.store.list().filter((d) => SUPERVISED_STATUSES.has(d.status));
205
- } catch (err) {
206
- // A malformed donations.json throws on every load; log rather than let an
207
- // unhandled rejection escape the timer.
208
- log('reconcile could not list donations: %s', errorMessage(err));
209
- return respawned;
210
- }
211
-
212
- for (const donation of candidates) {
213
- if (this.disposed) break;
214
- try {
215
- const id = await this.reconcileOne(donation);
216
- if (id) respawned.push(id);
217
- } catch (err) {
218
- // One bad record must never end the sweep — the others are still down.
219
- log('reconcile of donation %s failed: %s', donation.id, errorMessage(err));
220
- }
221
- }
222
- return respawned;
223
- }
224
-
225
- /** Reconcile one record. Returns its id when this pass respawned it. */
226
- private async reconcileOne(donation: Donation): Promise<string | undefined> {
227
- // No handle means nothing was ever spawned — `provision` owns that record
228
- // until it writes one, and replaying it here would race that provision.
229
- if (!donation.dockerId) return undefined;
230
-
231
- if (await this.orchestrator.isRunning(donation.dockerId)) {
232
- this.refillBudgetIfHealthy(donation);
233
- return undefined;
234
- }
235
- if (!this.backoffElapsed(donation)) {
236
- log('donation %s is down but still inside its respawn backoff', donation.id);
237
- return undefined;
238
- }
239
- return this.attemptRespawn(donation);
240
- }
241
-
242
- /**
243
- * Whether enough time has passed since the last attempt. A record with no
244
- * attempt history (or an unparsable timestamp — never wedge a node on bad
245
- * data) is eligible immediately.
246
- */
247
- private backoffElapsed(donation: Donation): boolean {
248
- const respawn = donation.respawn;
249
- if (!respawn) return true;
250
- const last = Date.parse(respawn.lastAttemptAt);
251
- if (Number.isNaN(last)) return true;
252
- const delay = Math.min(
253
- DONATION_RESPAWN_BACKOFF_BASE_MS * 2 ** respawn.attempts,
254
- DONATION_RESPAWN_BACKOFF_MAX_MS,
255
- );
256
- return this.now().getTime() - last >= delay;
257
- }
258
-
259
- /**
260
- * Clear the attempt count once a respawned node has been up long enough to
261
- * count as healthy, so the next crash gets a full budget instead of inheriting
262
- * the previous crash loop's.
263
- *
264
- * `updatedAt` is deliberately left alone: it is the age the stale-`awaiting_seed`
265
- * reap measures, and a liveness observation is not borrower activity.
266
- */
267
- private refillBudgetIfHealthy(donation: Donation): void {
268
- if (!this.budgetRefillDue(donation)) return;
269
- // Re-read before writing: `donation` comes from the snapshot this pass
270
- // started from, which can be several awaits old, and `store.put` replaces
271
- // the whole row — writing the stale copy back would undo a concurrent seed
272
- // or terminate that landed mid-pass.
273
- const current = this.store.get(donation.id);
274
- if (!current?.respawn || !this.budgetRefillDue(current)) return;
275
- try {
276
- this.store.put({ ...current, respawn: { ...current.respawn, attempts: 0 } });
277
- log('donation %s is healthy again — respawn budget refilled', donation.id);
278
- } catch (err) {
279
- // Costs one attempt off the next crash's budget, nothing more.
280
- log('failed to refill respawn budget for %s: %s', donation.id, errorMessage(err));
281
- }
282
- }
283
-
284
- /**
285
- * Whether a record carries a crash-loop count old enough to clear. An
286
- * unparsable timestamp is treated as not-due — unlike the backoff check,
287
- * being wrong here would erase real attempt history rather than free a node.
288
- */
289
- private budgetRefillDue(donation: Donation): boolean {
290
- const respawn = donation.respawn;
291
- if (!respawn || respawn.attempts === 0) return false;
292
- const last = Date.parse(respawn.lastAttemptAt);
293
- if (Number.isNaN(last)) return false;
294
- return this.now().getTime() - last > DONATION_RESPAWN_HEALTHY_MS;
295
- }
296
-
297
- /** One respawn attempt, with the give-up check on failure. */
298
- private async attemptRespawn(donation: Donation): Promise<string | undefined> {
299
- try {
300
- const result = await this.service.respawn(donation.id);
301
- switch (result.outcome) {
302
- case 'not_respawnable':
303
- // A record written before the spawn inputs were persisted. Not
304
- // respawnable and never will be; skip it every pass (cheap — no
305
- // orchestrator call is reached) rather than fail the sweep.
306
- log('donation %s is down but not respawnable (no persisted spawn inputs)', donation.id);
307
- return undefined;
308
- case 'abandoned':
309
- // The loan ended while the spawn was in flight and the ending won.
310
- // Nothing threw, so no attempt is persisted and the give-up path is
311
- // never entered — correct: this is not a failed attempt.
312
- log(
313
- 'respawn of donation %s was abandoned (record went %s mid-spawn)',
314
- donation.id,
315
- result.status ?? 'missing',
316
- );
317
- return undefined;
318
- case 'respawned':
319
- log('respawned donation %s → %s', donation.id, result.donation.dockerId);
320
- return donation.id;
321
- }
322
- } catch (err) {
323
- const message = errorMessage(err);
324
- // `respawn` persists the incremented counter; re-read it rather than
325
- // recomputing, so a give-up decision is made on what is actually on disk.
326
- const attempts = this.store.get(donation.id)?.respawn?.attempts ?? 0;
327
- log('respawn attempt %d for donation %s failed: %s', attempts, donation.id, message);
328
- if (attempts >= DONATION_RESPAWN_MAX_ATTEMPTS) {
329
- await this.giveUp(donation.id, attempts, message);
330
- }
331
- return undefined;
332
- }
333
- }
334
-
335
- /**
336
- * Stop trying: mark the donation `error` and stop whatever child the record
337
- * still names. `error` is outside the store's live statuses, so the grant's
338
- * quota frees and the borrower can provision a fresh node.
339
- *
340
- * The record is written BEFORE the stop for the same reason `terminate` does
341
- * it in that order — the stop fires `onStateChange`, and a pass triggered by
342
- * it must not see "node gone, record still `seeded`" and start respawning
343
- * again.
344
- *
345
- * The child is stopped but NOT removed: `removeContainer` deletes the workdir,
346
- * and the workdir holds the identity key the borrower's cadre approved. A
347
- * later `terminate()` still works on an `error` record and reclaims it.
348
- *
349
- * NOTE: an `error`-after-give-up record therefore keeps its workdir forever
350
- * unless someone calls `terminate`. If stale `error` workdirs ever pile up on
351
- * real hosts, extend the reap sweep in `donation-service.ts` to cover them.
352
- */
353
- private async giveUp(id: string, attempts: number, lastError: string): Promise<void> {
354
- const donation = this.store.get(id);
355
- if (!donation) return;
356
- // The record may have gone terminal while the failing attempt was in flight
357
- // — a `terminate` mid-pass is exactly why `respawn` threw. Overwriting that
358
- // with `error` would rewrite the borrower's own ending as a host fault, and
359
- // the stop below would fire against a child `terminate` already reclaimed.
360
- if (!SUPERVISED_STATUSES.has(donation.status)) {
361
- log('donation %s went %s before give-up — leaving it alone', id, donation.status);
362
- return;
363
- }
364
- this.store.put({
365
- ...donation,
366
- status: 'error',
367
- error: `respawn gave up after ${attempts} attempts: ${lastError}`,
368
- updatedAt: this.now().toISOString(),
369
- });
370
- log('gave up respawning donation %s after %d attempts: %s', id, attempts, lastError);
371
- if (donation.dockerId) {
372
- try {
373
- await this.orchestrator.stopContainer(donation.dockerId);
374
- } catch (err) {
375
- // The record names a live handle in the ordinary case — a failed respawn
376
- // restores the handle it dropped, and a respawn that spawned but failed
377
- // to record writes the new one onto the record. Still guarded: a handle
378
- // can be lost to a host restart (state.json gone, or the child already
379
- // reaped), and the child is dead either way, which is why we are here.
380
- log('failed to stop container %s while giving up: %s', donation.dockerId, errorMessage(err));
381
- }
382
- }
383
- }
384
- }
385
-
386
- function errorMessage(err: unknown): string {
387
- return err instanceof Error ? err.message : String(err);
388
- }
1
+ import debug from 'debug';
2
+
3
+ import type { Orchestrator } from '@serfab/cadre-provider';
4
+
5
+ import type { ManagedNodeInfo, NodeStateListener } from '../orchestrator/types.js';
6
+ import type { DonationService } from './donation-service.js';
7
+ import type { DonationStore } from './donation-store.js';
8
+ import type { Donation, DonationStatus } from './types.js';
9
+
10
+ const log = debug('cadre:host:donation-supervisor');
11
+
12
+ /**
13
+ * Unit of the doubling respawn backoff: the wait after `n` consecutive failed
14
+ * attempts is `base * 2^n`, capped at {@link DONATION_RESPAWN_BACKOFF_MAX_MS}.
15
+ * So the first retry waits 10s (one attempt is already recorded by then), the
16
+ * second 20s, and so on. A node with no recorded attempt is respawned
17
+ * immediately.
18
+ */
19
+ export const DONATION_RESPAWN_BACKOFF_BASE_MS = 5_000;
20
+
21
+ /**
22
+ * Ceiling on the doubling respawn backoff. 5 minutes.
23
+ *
24
+ * NOTE: not reachable at the current {@link DONATION_RESPAWN_MAX_ATTEMPTS} of 5
25
+ * — the longest wait actually served is the one before the 5th attempt,
26
+ * `5s * 2^4` = 80s, and the 5th failure gives up. The cap only starts clamping
27
+ * once a record can reach 6 recorded attempts (`5s * 2^6` = 320s), so raising
28
+ * the attempt cap without revisiting this one changes nothing.
29
+ */
30
+ export const DONATION_RESPAWN_BACKOFF_MAX_MS = 5 * 60_000;
31
+
32
+ /**
33
+ * Consecutive respawn attempts after which the host stops trying and marks the
34
+ * donation `error` — a node that will not stay up is a host-side fault the
35
+ * borrower cannot see, so surface it instead of spinning forever.
36
+ */
37
+ export const DONATION_RESPAWN_MAX_ATTEMPTS = 5;
38
+
39
+ /**
40
+ * How long a respawned node must stay up before its attempt budget is refilled.
41
+ * The last respawn produced something that survived, so the next crash starts
42
+ * from a fresh budget rather than inheriting an old crash loop's count.
43
+ */
44
+ export const DONATION_RESPAWN_HEALTHY_MS = 10 * 60_000;
45
+
46
+ /** How often the supervisor sweep runs while cadre-host is up. 1 minute. */
47
+ export const DONATION_RESPAWN_SWEEP_MS = 60_000;
48
+
49
+ /**
50
+ * Statuses the supervisor considers. `provisioning` is a provision still in
51
+ * flight (its own code path owns the child); `error` and `terminated` are
52
+ * terminal — a loan the host gave up on or the borrower ended, neither of which
53
+ * may come back on its own.
54
+ */
55
+ const SUPERVISED_STATUSES: ReadonlySet<DonationStatus> = new Set<DonationStatus>([
56
+ 'awaiting_seed',
57
+ 'seeded',
58
+ ]);
59
+
60
+ /**
61
+ * The orchestrator surface the supervisor needs: the shared `Orchestrator`
62
+ * (for `isRunning` / `stopContainer`) plus cadre-host's own exit-event
63
+ * subscription, which is not part of that cross-package interface.
64
+ * `HostProcessOrchestrator` satisfies this.
65
+ */
66
+ export interface SupervisedOrchestrator extends Orchestrator {
67
+ onStateChange(listener: NodeStateListener): () => void;
68
+ }
69
+
70
+ /** Constructor options. */
71
+ export interface DonationSupervisorOptions {
72
+ /** Lifecycle service — the supervisor drives its `respawn`. */
73
+ service: DonationService;
74
+ /** Persistent donation store — the candidate list and the give-up write. */
75
+ store: DonationStore;
76
+ /** Orchestrator that owns the donated child processes. */
77
+ orchestrator: SupervisedOrchestrator;
78
+ /** Clock override for tests. */
79
+ now?: () => Date;
80
+ }
81
+
82
+ /**
83
+ * DonationSupervisor — owns the invariant *a non-terminal donation is expected
84
+ * to be running*.
85
+ *
86
+ * Nothing else re-spawns a donated node: `bin/host.ts` re-spawns only the host's
87
+ * own owner node, the `/api/nodes/:id/{start,restart}` routes refuse non-owner
88
+ * ids, and the stale-`awaiting_seed` reap only terminates. So without this class
89
+ * a crashed, OOM-killed, or reboot-killed donated node stays dead with its
90
+ * record still reading `seeded`, silently costing the borrower a node and their
91
+ * grant a quota slot.
92
+ *
93
+ * One code path ({@link reconcile}) behind three triggers:
94
+ *
95
+ * - **host startup** — one pass after `orchestrator.init()`, which catches every
96
+ * node that died while the host was down;
97
+ * - **exit signal** — the orchestrator's `onStateChange`, so a crash is noticed
98
+ * in milliseconds rather than at the next sweep;
99
+ * - **periodic sweep** — the backstop for deaths no exit event covers (host was
100
+ * down, listener missed, orchestrator re-attached to an already-dead pid).
101
+ *
102
+ * Passes are serialized: `DonationService.respawn` is not itself serialized, and
103
+ * two overlapping respawns of one id both spawn a child while the second drops
104
+ * the first's orchestrator handle (see its docstring). Since two of the three
105
+ * triggers can fire at once, the serialization lives here.
106
+ */
107
+ export class DonationSupervisor {
108
+ private readonly service: DonationService;
109
+ private readonly store: DonationStore;
110
+ private readonly orchestrator: SupervisedOrchestrator;
111
+ private readonly now: () => Date;
112
+
113
+ /** Serialization tail — see the class docstring. */
114
+ private tail: Promise<void> = Promise.resolve();
115
+ private timer?: ReturnType<typeof setInterval>;
116
+ private unsubscribe?: () => void;
117
+ /** At most one exit-triggered pass queued at a time (crash storms coalesce). */
118
+ private exitPassQueued = false;
119
+ /** Set by `stop()` so an in-flight pass abandons its remaining records. */
120
+ private disposed = false;
121
+
122
+ constructor(opts: DonationSupervisorOptions) {
123
+ this.service = opts.service;
124
+ this.store = opts.store;
125
+ this.orchestrator = opts.orchestrator;
126
+ this.now = opts.now ?? (() => new Date());
127
+ log('DonationSupervisor initialized');
128
+ }
129
+
130
+ /**
131
+ * One reconcile pass over the donation store. Serialized against every other
132
+ * pass, so a caller may get a queued pass rather than an immediate one.
133
+ * Returns the donation ids it respawned in *its own* pass.
134
+ */
135
+ reconcile(): Promise<string[]> {
136
+ const run = (): Promise<string[]> => this.reconcileOnce();
137
+ const next = this.tail.then(run, run);
138
+ // The stored tail swallows outcomes — a failed pass must not reject the
139
+ // next caller's wait, only sequence after it.
140
+ this.tail = next.then(() => undefined, () => undefined);
141
+ return next;
142
+ }
143
+
144
+ /**
145
+ * Subscribe to child exits, start the periodic sweep, and run the startup
146
+ * pass. Idempotent — a second call while started is a no-op.
147
+ */
148
+ start(): void {
149
+ if (this.timer) return;
150
+ this.disposed = false;
151
+ // Subscribe before the first pass so an exit during it is not missed.
152
+ this.unsubscribe = this.orchestrator.onStateChange((info) => { this.onNodeState(info); });
153
+ this.timer = setInterval(() => { this.sweep('timer'); }, DONATION_RESPAWN_SWEEP_MS);
154
+ this.timer.unref();
155
+ this.sweep('startup');
156
+ }
157
+
158
+ /**
159
+ * Stop the timer and unsubscribe. A pass already in flight abandons its
160
+ * remaining records rather than being awaited — every record it has not
161
+ * reached is picked up by the next host start's startup pass.
162
+ */
163
+ stop(): void {
164
+ this.disposed = true;
165
+ if (this.timer) {
166
+ clearInterval(this.timer);
167
+ this.timer = undefined;
168
+ }
169
+ this.unsubscribe?.();
170
+ this.unsubscribe = undefined;
171
+ log('DonationSupervisor stopped');
172
+ }
173
+
174
+ /** Fire-and-forget pass for the timer / exit / startup triggers. */
175
+ private sweep(trigger: string): void {
176
+ void this.reconcile()
177
+ .then((ids) => {
178
+ if (ids.length) log('%s sweep respawned %d donation(s): %o', trigger, ids.length, ids);
179
+ })
180
+ .catch((err) => {
181
+ console.error(`donation respawn sweep failed: ${errorMessage(err)}`);
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Exit-event trigger. Deliberately cheap: it does not map the handle back to a
187
+ * donation (that needs a store read per event), it just asks for a pass. Owner
188
+ * node exits are not ours — `bin/host.ts` owns re-spawning that one.
189
+ */
190
+ private onNodeState(info: ManagedNodeInfo): void {
191
+ if (info.owner || info.status !== 'stopped') return;
192
+ if (this.exitPassQueued) return;
193
+ this.exitPassQueued = true;
194
+ void this.reconcile()
195
+ .catch((err) => { console.error(`donation respawn sweep failed: ${errorMessage(err)}`); })
196
+ .finally(() => { this.exitPassQueued = false; });
197
+ }
198
+
199
+ /** The unserialized body of one pass. */
200
+ private async reconcileOnce(): Promise<string[]> {
201
+ const respawned: string[] = [];
202
+ let candidates: Donation[];
203
+ try {
204
+ candidates = this.store.list().filter((d) => SUPERVISED_STATUSES.has(d.status));
205
+ } catch (err) {
206
+ // A malformed donations.json throws on every load; log rather than let an
207
+ // unhandled rejection escape the timer.
208
+ log('reconcile could not list donations: %s', errorMessage(err));
209
+ return respawned;
210
+ }
211
+
212
+ for (const donation of candidates) {
213
+ if (this.disposed) break;
214
+ try {
215
+ const id = await this.reconcileOne(donation);
216
+ if (id) respawned.push(id);
217
+ } catch (err) {
218
+ // One bad record must never end the sweep — the others are still down.
219
+ log('reconcile of donation %s failed: %s', donation.id, errorMessage(err));
220
+ }
221
+ }
222
+ return respawned;
223
+ }
224
+
225
+ /** Reconcile one record. Returns its id when this pass respawned it. */
226
+ private async reconcileOne(donation: Donation): Promise<string | undefined> {
227
+ // No handle means nothing was ever spawned — `provision` owns that record
228
+ // until it writes one, and replaying it here would race that provision.
229
+ if (!donation.dockerId) return undefined;
230
+
231
+ if (await this.orchestrator.isRunning(donation.dockerId)) {
232
+ this.refillBudgetIfHealthy(donation);
233
+ return undefined;
234
+ }
235
+ if (!this.backoffElapsed(donation)) {
236
+ log('donation %s is down but still inside its respawn backoff', donation.id);
237
+ return undefined;
238
+ }
239
+ return this.attemptRespawn(donation);
240
+ }
241
+
242
+ /**
243
+ * Whether enough time has passed since the last attempt. A record with no
244
+ * attempt history (or an unparsable timestamp — never wedge a node on bad
245
+ * data) is eligible immediately.
246
+ */
247
+ private backoffElapsed(donation: Donation): boolean {
248
+ const respawn = donation.respawn;
249
+ if (!respawn) return true;
250
+ const last = Date.parse(respawn.lastAttemptAt);
251
+ if (Number.isNaN(last)) return true;
252
+ const delay = Math.min(
253
+ DONATION_RESPAWN_BACKOFF_BASE_MS * 2 ** respawn.attempts,
254
+ DONATION_RESPAWN_BACKOFF_MAX_MS,
255
+ );
256
+ return this.now().getTime() - last >= delay;
257
+ }
258
+
259
+ /**
260
+ * Clear the attempt count once a respawned node has been up long enough to
261
+ * count as healthy, so the next crash gets a full budget instead of inheriting
262
+ * the previous crash loop's.
263
+ *
264
+ * `updatedAt` is deliberately left alone: it is the age the stale-`awaiting_seed`
265
+ * reap measures, and a liveness observation is not borrower activity.
266
+ */
267
+ private refillBudgetIfHealthy(donation: Donation): void {
268
+ if (!this.budgetRefillDue(donation)) return;
269
+ // Re-read before writing: `donation` comes from the snapshot this pass
270
+ // started from, which can be several awaits old, and `store.put` replaces
271
+ // the whole row — writing the stale copy back would undo a concurrent seed
272
+ // or terminate that landed mid-pass.
273
+ const current = this.store.get(donation.id);
274
+ if (!current?.respawn || !this.budgetRefillDue(current)) return;
275
+ try {
276
+ this.store.put({ ...current, respawn: { ...current.respawn, attempts: 0 } });
277
+ log('donation %s is healthy again — respawn budget refilled', donation.id);
278
+ } catch (err) {
279
+ // Costs one attempt off the next crash's budget, nothing more.
280
+ log('failed to refill respawn budget for %s: %s', donation.id, errorMessage(err));
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Whether a record carries a crash-loop count old enough to clear. An
286
+ * unparsable timestamp is treated as not-due — unlike the backoff check,
287
+ * being wrong here would erase real attempt history rather than free a node.
288
+ */
289
+ private budgetRefillDue(donation: Donation): boolean {
290
+ const respawn = donation.respawn;
291
+ if (!respawn || respawn.attempts === 0) return false;
292
+ const last = Date.parse(respawn.lastAttemptAt);
293
+ if (Number.isNaN(last)) return false;
294
+ return this.now().getTime() - last > DONATION_RESPAWN_HEALTHY_MS;
295
+ }
296
+
297
+ /** One respawn attempt, with the give-up check on failure. */
298
+ private async attemptRespawn(donation: Donation): Promise<string | undefined> {
299
+ try {
300
+ const result = await this.service.respawn(donation.id);
301
+ switch (result.outcome) {
302
+ case 'not_respawnable':
303
+ // A record written before the spawn inputs were persisted. Not
304
+ // respawnable and never will be; skip it every pass (cheap — no
305
+ // orchestrator call is reached) rather than fail the sweep.
306
+ log('donation %s is down but not respawnable (no persisted spawn inputs)', donation.id);
307
+ return undefined;
308
+ case 'abandoned':
309
+ // The loan ended while the spawn was in flight and the ending won.
310
+ // Nothing threw, so no attempt is persisted and the give-up path is
311
+ // never entered — correct: this is not a failed attempt.
312
+ log(
313
+ 'respawn of donation %s was abandoned (record went %s mid-spawn)',
314
+ donation.id,
315
+ result.status ?? 'missing',
316
+ );
317
+ return undefined;
318
+ case 'respawned':
319
+ log('respawned donation %s → %s', donation.id, result.donation.dockerId);
320
+ return donation.id;
321
+ }
322
+ } catch (err) {
323
+ const message = errorMessage(err);
324
+ // `respawn` persists the incremented counter; re-read it rather than
325
+ // recomputing, so a give-up decision is made on what is actually on disk.
326
+ const attempts = this.store.get(donation.id)?.respawn?.attempts ?? 0;
327
+ log('respawn attempt %d for donation %s failed: %s', attempts, donation.id, message);
328
+ if (attempts >= DONATION_RESPAWN_MAX_ATTEMPTS) {
329
+ await this.giveUp(donation.id, attempts, message);
330
+ }
331
+ return undefined;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Stop trying: mark the donation `error` and stop whatever child the record
337
+ * still names. `error` is outside the store's live statuses, so the grant's
338
+ * quota frees and the borrower can provision a fresh node.
339
+ *
340
+ * The record is written BEFORE the stop for the same reason `terminate` does
341
+ * it in that order — the stop fires `onStateChange`, and a pass triggered by
342
+ * it must not see "node gone, record still `seeded`" and start respawning
343
+ * again.
344
+ *
345
+ * The child is stopped but NOT removed: `removeContainer` deletes the workdir,
346
+ * and the workdir holds the identity key the borrower's cadre approved. A
347
+ * later `terminate()` still works on an `error` record and reclaims it.
348
+ *
349
+ * NOTE: an `error`-after-give-up record therefore keeps its workdir forever
350
+ * unless someone calls `terminate`. If stale `error` workdirs ever pile up on
351
+ * real hosts, extend the reap sweep in `donation-service.ts` to cover them.
352
+ */
353
+ private async giveUp(id: string, attempts: number, lastError: string): Promise<void> {
354
+ const donation = this.store.get(id);
355
+ if (!donation) return;
356
+ // The record may have gone terminal while the failing attempt was in flight
357
+ // — a `terminate` mid-pass is exactly why `respawn` threw. Overwriting that
358
+ // with `error` would rewrite the borrower's own ending as a host fault, and
359
+ // the stop below would fire against a child `terminate` already reclaimed.
360
+ if (!SUPERVISED_STATUSES.has(donation.status)) {
361
+ log('donation %s went %s before give-up — leaving it alone', id, donation.status);
362
+ return;
363
+ }
364
+ this.store.put({
365
+ ...donation,
366
+ status: 'error',
367
+ error: `respawn gave up after ${attempts} attempts: ${lastError}`,
368
+ updatedAt: this.now().toISOString(),
369
+ });
370
+ log('gave up respawning donation %s after %d attempts: %s', id, attempts, lastError);
371
+ if (donation.dockerId) {
372
+ try {
373
+ await this.orchestrator.stopContainer(donation.dockerId);
374
+ } catch (err) {
375
+ // The record names a live handle in the ordinary case — a failed respawn
376
+ // restores the handle it dropped, and a respawn that spawned but failed
377
+ // to record writes the new one onto the record. Still guarded: a handle
378
+ // can be lost to a host restart (state.json gone, or the child already
379
+ // reaped), and the child is dead either way, which is why we are here.
380
+ log('failed to stop container %s while giving up: %s', donation.dockerId, errorMessage(err));
381
+ }
382
+ }
383
+ }
384
+ }
385
+
386
+ function errorMessage(err: unknown): string {
387
+ return err instanceof Error ? err.message : String(err);
388
+ }