@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,250 +1,288 @@
|
|
|
1
|
-
import debug from 'debug';
|
|
2
|
-
|
|
3
|
-
import { decodeCadreInvitation } from '@serfab/cadre-core';
|
|
4
|
-
|
|
5
|
-
import type { NodeStateListener } from '../orchestrator/types.js';
|
|
6
|
-
import type { HostedNodeStore } from './hosted-node-store.js';
|
|
7
|
-
import { errorMessage,
|
|
8
|
-
import type { HostedNode, HostedNodeChange, HostedNodeStatus } from './types.js';
|
|
9
|
-
|
|
10
|
-
const log = debug('cadre:host:hosted-node-watcher');
|
|
11
|
-
|
|
12
|
-
/** How often an unclaimed node's `/status` is read for the claim, and a joining node's for its redemption. */
|
|
13
|
-
export const HOSTED_NODE_CLAIM_POLL_MS = 2_000;
|
|
14
|
-
|
|
15
|
-
/** How often a joined node's `/status` is read for its connection count. */
|
|
16
|
-
export const HOSTED_NODE_CONNECTED_POLL_MS = 15_000;
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
private
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
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
|
-
private
|
|
130
|
-
if (
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
|
|
3
|
+
import { decodeCadreInvitation } from '@serfab/cadre-core';
|
|
4
|
+
|
|
5
|
+
import type { NodeStateListener } from '../orchestrator/types.js';
|
|
6
|
+
import type { HostedNodeStore } from './hosted-node-store.js';
|
|
7
|
+
import { errorMessage, readWatchedNodeStatus, reportsClaim, reportsRemoval, type NodeStatus, type RemovedNodeStatus } from './node-status.js';
|
|
8
|
+
import type { HostedNode, HostedNodeChange, HostedNodeStatus } from './types.js';
|
|
9
|
+
|
|
10
|
+
const log = debug('cadre:host:hosted-node-watcher');
|
|
11
|
+
|
|
12
|
+
/** How often an unclaimed node's `/status` is read for the claim, and a joining node's for its redemption. */
|
|
13
|
+
export const HOSTED_NODE_CLAIM_POLL_MS = 2_000;
|
|
14
|
+
|
|
15
|
+
/** How often a joined node's `/status` is read for its connection count. */
|
|
16
|
+
export const HOSTED_NODE_CONNECTED_POLL_MS = 15_000;
|
|
17
|
+
|
|
18
|
+
/** The `error` a node an owner removed from its cadre is recorded with. */
|
|
19
|
+
const REMOVED_BY_OWNER_ERROR = 'Removed from its cadre by an owner';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The orchestrator surface the watcher needs: the exit/start signal, when the orchestrator
|
|
23
|
+
* has one, and the stop of a node whose invitation failed or that an owner removed.
|
|
24
|
+
*/
|
|
25
|
+
export interface WatchedOrchestrator {
|
|
26
|
+
onStateChange?(listener: NodeStateListener): () => void;
|
|
27
|
+
stopContainer(dockerId: string): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Statuses the watcher reads every tick: a node still on its way into its cadre. */
|
|
31
|
+
const PENDING_STATUSES: ReadonlySet<HostedNodeStatus> = new Set<HostedNodeStatus>(['unclaimed', 'joining']);
|
|
32
|
+
|
|
33
|
+
export interface HostedNodeWatcherOptions {
|
|
34
|
+
store: HostedNodeStore;
|
|
35
|
+
orchestrator: WatchedOrchestrator;
|
|
36
|
+
now: () => Date;
|
|
37
|
+
/** Where a claim or a liveness change is reported (`HostedNodeService.emit`). */
|
|
38
|
+
emit: (change: HostedNodeChange) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Follows each hosted node's `/status`: an `unclaimed` record becomes `joined` when
|
|
43
|
+
* the node reports a finished claim; a `joining` record becomes `joined` when a member
|
|
44
|
+
* accepted its invitation, or `error` when the redemption failed; a `joined`
|
|
45
|
+
* record's `connected` follows the node's control-connection count; and any of them
|
|
46
|
+
* becomes `error` when the node reports that an owner removed it from its cadre.
|
|
47
|
+
* Best-effort: a poll that fails is retried on the next tick, and nothing here throws
|
|
48
|
+
* out of the timer.
|
|
49
|
+
*
|
|
50
|
+
* One timer at the claim cadence drives all three: every tick polls every unclaimed and
|
|
51
|
+
* joining node, and each joined node every {@link HOSTED_NODE_CONNECTED_POLL_MS}, or at once when it
|
|
52
|
+
* was poked — the orchestrator's state change on a respawn pokes the node, and the
|
|
53
|
+
* poke stands until a poll succeeds, so a respawned child is noticed on its first
|
|
54
|
+
* answer rather than at the next 15 s mark. Polls are serialized on one promise
|
|
55
|
+
* tail so a timer pass and a poked pass cannot both write one record.
|
|
56
|
+
*
|
|
57
|
+
* NOTE: the 2 s timer ticks while nothing is unclaimed (one cached store read, no
|
|
58
|
+
* I/O per tick); if it ever shows up in a profile, stop it when no record is
|
|
59
|
+
* unclaimed and restart it from `poke`.
|
|
60
|
+
*/
|
|
61
|
+
export class HostedNodeWatcher {
|
|
62
|
+
private readonly store: HostedNodeStore;
|
|
63
|
+
private readonly orchestrator: WatchedOrchestrator;
|
|
64
|
+
private readonly now: () => Date;
|
|
65
|
+
private readonly emit: (change: HostedNodeChange) => void;
|
|
66
|
+
|
|
67
|
+
private tail: Promise<void> = Promise.resolve();
|
|
68
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
69
|
+
private unsubscribe?: () => void;
|
|
70
|
+
/** When each joined node's `/status` last answered (ms since epoch). */
|
|
71
|
+
private readonly lastPolledAt = new Map<string, number>();
|
|
72
|
+
/** Joined nodes to poll on the next pass regardless of cadence. */
|
|
73
|
+
private readonly due = new Set<string>();
|
|
74
|
+
|
|
75
|
+
constructor(opts: HostedNodeWatcherOptions) {
|
|
76
|
+
this.store = opts.store;
|
|
77
|
+
this.orchestrator = opts.orchestrator;
|
|
78
|
+
this.now = opts.now;
|
|
79
|
+
this.emit = opts.emit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Start the timer and the state-change subscription, and poll once. Idempotent. */
|
|
83
|
+
start(): void {
|
|
84
|
+
if (this.timer) return;
|
|
85
|
+
this.unsubscribe = this.orchestrator.onStateChange?.((info) => { this.poke(info.id); });
|
|
86
|
+
this.timer = setInterval(() => { this.poke(); }, HOSTED_NODE_CLAIM_POLL_MS);
|
|
87
|
+
this.timer.unref();
|
|
88
|
+
this.poke();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
stop(): void {
|
|
92
|
+
if (this.timer) {
|
|
93
|
+
clearInterval(this.timer);
|
|
94
|
+
this.timer = undefined;
|
|
95
|
+
}
|
|
96
|
+
this.unsubscribe?.();
|
|
97
|
+
this.unsubscribe = undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Ask for a pass now; `id` names a joined node to poll ahead of its cadence. Fire-and-forget. */
|
|
101
|
+
poke(id?: string): void {
|
|
102
|
+
if (id) this.due.add(id);
|
|
103
|
+
void this.poll().catch((err) => { log('status poll failed: %s', errorMessage(err)); });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One pass over every unclaimed, joining and due joined record, serialized against the others. */
|
|
107
|
+
poll(): Promise<void> {
|
|
108
|
+
const next = this.tail.then(() => this.pollOnce(), () => this.pollOnce());
|
|
109
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
110
|
+
return next;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async pollOnce(): Promise<void> {
|
|
114
|
+
let records: HostedNode[];
|
|
115
|
+
try {
|
|
116
|
+
records = this.store.list().filter((n) => PENDING_STATUSES.has(n.status) || n.status === 'joined');
|
|
117
|
+
} catch (err) {
|
|
118
|
+
// A malformed hosted-nodes.json throws on every load; log rather than let it escape the timer.
|
|
119
|
+
log('poll could not list hosted nodes: %s', errorMessage(err));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
this.forgetRemoved(new Set(records.map((r) => r.id)));
|
|
123
|
+
for (const record of records) {
|
|
124
|
+
if (this.shouldPoll(record)) await this.pollOne(record);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Drop the cadence and poke bookkeeping of nodes no longer on record, so neither map grows with removals. */
|
|
129
|
+
private forgetRemoved(live: Set<string>): void {
|
|
130
|
+
for (const id of this.lastPolledAt.keys()) if (!live.has(id)) this.lastPolledAt.delete(id);
|
|
131
|
+
for (const id of this.due) if (!live.has(id)) this.due.delete(id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private shouldPoll(record: HostedNode): boolean {
|
|
135
|
+
if (PENDING_STATUSES.has(record.status) || this.due.has(record.id)) return true;
|
|
136
|
+
const last = this.lastPolledAt.get(record.id);
|
|
137
|
+
return last === undefined || this.now().getTime() - last >= HOSTED_NODE_CONNECTED_POLL_MS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async pollOne(record: HostedNode): Promise<void> {
|
|
141
|
+
if (!record.statusEndpoint) return;
|
|
142
|
+
let status: NodeStatus | RemovedNodeStatus;
|
|
143
|
+
try {
|
|
144
|
+
status = await readWatchedNodeStatus(record.statusEndpoint);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
log('hosted node %s did not answer /status (retried next tick): %s', record.id, errorMessage(err));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.lastPolledAt.set(record.id, this.now().getTime());
|
|
150
|
+
this.due.delete(record.id);
|
|
151
|
+
|
|
152
|
+
// Re-read: `record` predates the round-trip and `store.put` replaces the whole
|
|
153
|
+
// row, so writing that copy back would resurrect a node removed meanwhile, or
|
|
154
|
+
// undo a respawn's new handles. The store is synchronous, so this read-decide-
|
|
155
|
+
// write is atomic against the event loop as long as no `await` sneaks in.
|
|
156
|
+
const current = this.store.get(record.id);
|
|
157
|
+
if (!current || current.status !== record.status) return;
|
|
158
|
+
if (reportsRemoval(status)) {
|
|
159
|
+
await this.applyRemoved(current, status);
|
|
160
|
+
} else if (current.status === 'unclaimed') {
|
|
161
|
+
this.applyUnclaimed(current, status);
|
|
162
|
+
} else if (current.status === 'joining') {
|
|
163
|
+
await this.applyJoining(current, status);
|
|
164
|
+
} else {
|
|
165
|
+
this.applyJoined(current, status);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** An unclaimed node that reports a finished claim is `joined`, with the party and owner the claim named. */
|
|
170
|
+
private applyUnclaimed(current: HostedNode, status: NodeStatus): void {
|
|
171
|
+
if (!reportsClaim(status)) return;
|
|
172
|
+
this.store.put({
|
|
173
|
+
...current,
|
|
174
|
+
status: 'joined',
|
|
175
|
+
partyId: status.node.partyId,
|
|
176
|
+
ownerKey: status.node.claimedBy,
|
|
177
|
+
peerId: current.peerId ?? status.peerId,
|
|
178
|
+
connected: status.node.connectionPaths.total > 0,
|
|
179
|
+
updatedAt: this.now().toISOString(),
|
|
180
|
+
});
|
|
181
|
+
log('hosted node %s was claimed by owner %s into cadre %s', current.id, status.node.claimedBy.slice(0, 8), status.node.partyId);
|
|
182
|
+
this.emit({ kind: 'claimed', id: current.id });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A joining node whose redemption settled: `joined` with the member that admitted it and
|
|
187
|
+
* the invitation's issuer as its owner, or `error` with the reason and whether Retry may
|
|
188
|
+
* help. A failed node's child is stopped after the write, as a give-up's is, and kept
|
|
189
|
+
* with its workdir for Retry; the write comes first so the exit the stop raises finds
|
|
190
|
+
* a record the supervisor leaves alone. Still `pending`: nothing.
|
|
191
|
+
*/
|
|
192
|
+
private async applyJoining(current: HostedNode, status: NodeStatus): Promise<void> {
|
|
193
|
+
const invitation = status.node.invitation;
|
|
194
|
+
if (invitation?.state === 'accepted') {
|
|
195
|
+
const ownerKey = issuerOf(current);
|
|
196
|
+
this.store.put({
|
|
197
|
+
...current,
|
|
198
|
+
status: 'joined',
|
|
199
|
+
...(ownerKey ? { ownerKey } : {}),
|
|
200
|
+
...(invitation.memberPeerId ? { memberPeerId: invitation.memberPeerId } : {}),
|
|
201
|
+
peerId: current.peerId ?? status.peerId,
|
|
202
|
+
connected: status.node.connectionPaths.total > 0,
|
|
203
|
+
updatedAt: this.now().toISOString(),
|
|
204
|
+
});
|
|
205
|
+
log('hosted node %s joined cadre %s at member %s', current.id, current.partyId, invitation.memberPeerId ?? '(unnamed)');
|
|
206
|
+
this.emit({ kind: 'joined', id: current.id });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (invitation?.state !== 'failed') return;
|
|
210
|
+
const error = invitation.error ?? 'the invitation could not be redeemed';
|
|
211
|
+
this.store.put({
|
|
212
|
+
...current,
|
|
213
|
+
status: 'error',
|
|
214
|
+
error,
|
|
215
|
+
retryable: invitation.retryable === true,
|
|
216
|
+
peerId: current.peerId ?? status.peerId,
|
|
217
|
+
updatedAt: this.now().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
log('hosted node %s could not join cadre %s (%s): %s', current.id, current.partyId, invitation.retryable ? 'retryable' : 'final', error);
|
|
220
|
+
this.emit({ kind: 'changed', id: current.id });
|
|
221
|
+
await this.stopChild(current, 'its invitation failed');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* A node that reports an owner removed it from its cadre: `error`, not retryable, its child
|
|
226
|
+
* stopped after the write as a failed invitation's is, and its workdir kept for Remove (or a
|
|
227
|
+
* claim node's Reset). The child had already stopped its node, and stays up only to report
|
|
228
|
+
* the removal, so stopping it loses nothing. The party and owner come from the report, for an
|
|
229
|
+
* `unclaimed` record whose claim this watcher never saw: its own party is the placeholder.
|
|
230
|
+
*/
|
|
231
|
+
private async applyRemoved(current: HostedNode, status: RemovedNodeStatus): Promise<void> {
|
|
232
|
+
const partyId = status.node.partyId || current.partyId;
|
|
233
|
+
const ownerKey = current.ownerKey ?? status.node.claimedBy;
|
|
234
|
+
this.store.put({
|
|
235
|
+
...current,
|
|
236
|
+
status: 'error',
|
|
237
|
+
partyId,
|
|
238
|
+
...(ownerKey ? { ownerKey } : {}),
|
|
239
|
+
error: REMOVED_BY_OWNER_ERROR,
|
|
240
|
+
retryable: false,
|
|
241
|
+
removedByOwner: true,
|
|
242
|
+
updatedAt: this.now().toISOString(),
|
|
243
|
+
});
|
|
244
|
+
log('hosted node %s was removed from cadre %s by an owner', current.id, partyId);
|
|
245
|
+
this.emit({ kind: 'changed', id: current.id });
|
|
246
|
+
await this.stopChild(current, 'an owner removed it');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Stop a node's child after its record went `error`; a failed stop is logged, the record stands. */
|
|
250
|
+
private async stopChild(current: HostedNode, why: string): Promise<void> {
|
|
251
|
+
if (!current.dockerId) return;
|
|
252
|
+
try {
|
|
253
|
+
await this.orchestrator.stopContainer(current.dockerId);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
log('failed to stop hosted node %s after %s: %s', current.id, why, errorMessage(err));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* A joined node's `connected` follows its control-connection count; published only on a
|
|
261
|
+
* change. Its `/status` keeps reporting the invitation for as long as the child that
|
|
262
|
+
* redeemed it runs, and whatever it says there is ignored: the node is a member, and the
|
|
263
|
+
* record says so.
|
|
264
|
+
*/
|
|
265
|
+
private applyJoined(current: HostedNode, status: NodeStatus): void {
|
|
266
|
+
const connected = status.node.connectionPaths.total > 0;
|
|
267
|
+
if (current.connected === connected) return;
|
|
268
|
+
this.store.put({ ...current, connected, updatedAt: this.now().toISOString() });
|
|
269
|
+
log('hosted node %s is now %s', current.id, connected ? 'connected' : 'disconnected');
|
|
270
|
+
this.emit({ kind: 'changed', id: current.id });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The owner a joined invitation node is shown under: the issuer of its invitation. The
|
|
276
|
+
* record's bundle decoded at `join`, so this fails only on a hand-edited
|
|
277
|
+
* `hosted-nodes.json` or on a record written before invitations carried their
|
|
278
|
+
* `sereus-invite:1.` prefix; the node is a member either way, so the record says so without one.
|
|
279
|
+
*/
|
|
280
|
+
function issuerOf(node: HostedNode): string | undefined {
|
|
281
|
+
if (node.join.kind !== 'invitation') return undefined;
|
|
282
|
+
try {
|
|
283
|
+
return decodeCadreInvitation(node.join.encoded).invite.issuerKey;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
log('hosted node %s carries an invitation that no longer decodes: %s', node.id, errorMessage(err));
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading a hosted child's `/status` — the one window the host has onto a node it
|
|
3
|
-
* runs: its peer id and addresses before the claim,
|
|
4
|
-
* and the owner key after it
|
|
5
|
-
* status watcher.
|
|
3
|
+
* runs: its peer id and addresses before the claim, the claim itself, the party
|
|
4
|
+
* and the owner key after it, and its removal by an owner. Shared by
|
|
5
|
+
* `HostedNodeService.claimDetails` and the status watcher.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
// The child's `/status` is typed against the CLI's own answer, so a field the CLI
|
|
@@ -24,12 +24,36 @@ export function statusUrlFor(healthEndpoint: string): string {
|
|
|
24
24
|
/** A `/status` answer from a node that has its peer identity. */
|
|
25
25
|
export type NodeStatus = HealthStatus & { peerId: string };
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A `/status` answer from a child an owner removed from its cadre. The child stopped its node
|
|
29
|
+
* and stays up only to report this, so it has no peer identity.
|
|
30
|
+
*/
|
|
31
|
+
export type RemovedNodeStatus = HealthStatus & { node: { removed: NonNullable<HealthStatus['node']['removed']> } };
|
|
32
|
+
|
|
27
33
|
/**
|
|
28
34
|
* The child's `/status`, fresh every call. Throws `node_unavailable` while the child
|
|
29
35
|
* does not answer, answers with an error, or has no peer identity yet — every one of
|
|
30
36
|
* which a caller retries.
|
|
31
37
|
*/
|
|
32
38
|
export async function readNodeStatus(statusEndpoint: string): Promise<NodeStatus> {
|
|
39
|
+
return requirePeerIdentity(await fetchNodeStatus(statusEndpoint));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* {@link readNodeStatus} for the watcher, which also has to hear a removal: a child reporting
|
|
44
|
+
* one is answered as it is rather than refused for having no peer identity.
|
|
45
|
+
*/
|
|
46
|
+
export async function readWatchedNodeStatus(statusEndpoint: string): Promise<NodeStatus | RemovedNodeStatus> {
|
|
47
|
+
const status = await fetchNodeStatus(statusEndpoint);
|
|
48
|
+
return reportsRemoval(status) ? status : requirePeerIdentity(status);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether the child reports that an owner removed it from its cadre. */
|
|
52
|
+
export function reportsRemoval(status: HealthStatus): status is RemovedNodeStatus {
|
|
53
|
+
return Boolean(status.node.removed);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fetchNodeStatus(statusEndpoint: string): Promise<HealthStatus> {
|
|
33
57
|
let res: Response;
|
|
34
58
|
try {
|
|
35
59
|
res = await fetch(statusEndpoint);
|
|
@@ -39,12 +63,14 @@ export async function readNodeStatus(statusEndpoint: string): Promise<NodeStatus
|
|
|
39
63
|
if (!res.ok) {
|
|
40
64
|
throw new HostedNodeError('node_unavailable', `Hosted node /status returned ${res.status}`);
|
|
41
65
|
}
|
|
42
|
-
let status: HealthStatus;
|
|
43
66
|
try {
|
|
44
|
-
|
|
67
|
+
return (await res.json()) as HealthStatus;
|
|
45
68
|
} catch (err) {
|
|
46
69
|
throw new HostedNodeError('node_unavailable', `Hosted node /status is not JSON: ${errorMessage(err)}`);
|
|
47
70
|
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requirePeerIdentity(status: HealthStatus): NodeStatus {
|
|
48
74
|
if (!status.peerId || !status.multiaddrs?.length) {
|
|
49
75
|
throw new HostedNodeError('node_unavailable', 'Hosted node has no peer identity yet');
|
|
50
76
|
}
|