@ccmsg/cli 0.9.1 → 0.11.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/package.json +1 -1
- package/src/auth/admin.ts +32 -6
- package/src/cli.ts +377 -57
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +398 -70
- package/src/daemon/supervise.ts +27 -3
- package/src/instance/ccmsg-config.d.ts +112 -0
- package/src/instance/config.ts +513 -131
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +71 -26
- package/src/instance/paths.ts +37 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +47 -62
- package/src/mesh/wire.ts +1 -29
- package/src/mesh/probe.ts +0 -105
package/src/instance/config.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
statSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
3
10
|
import { type DumpPreset, type Endpoint, TranscriptItemSelector } from "@ccmsg/protocol";
|
|
4
11
|
import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
|
|
12
|
+
import { ID } from "./identity.ts";
|
|
5
13
|
import { parseCidr } from "./client.ts";
|
|
6
14
|
|
|
7
15
|
/** Where the instance accepts WebSocket connections, and from whom.
|
|
@@ -110,11 +118,24 @@ export interface InstanceConfig {
|
|
|
110
118
|
* home says nothing about the program it belongs to, and an instance that
|
|
111
119
|
* guessed would walk the wrong tree for the whole of its first session. */
|
|
112
120
|
readonly harness: Harness;
|
|
113
|
-
/** Every mesh
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
|
|
121
|
+
/** Every instance of the mesh, this one among them (§7.1).
|
|
122
|
+
*
|
|
123
|
+
* Data, and the same data on every host: a settings function is handed it
|
|
124
|
+
* and may read it — an instance that wants to know who else there is has it
|
|
125
|
+
* here — but a returned list that differs from the file's is refused. Which
|
|
126
|
+
* entry is this instance is the row carrying its own id, which is what
|
|
127
|
+
* settles `endpoint` below. */
|
|
128
|
+
readonly endpoints: readonly EndpointRow[];
|
|
129
|
+
/** Where peers and people reach this instance: its own row of the mesh
|
|
130
|
+
* (§7.1).
|
|
131
|
+
*
|
|
132
|
+
* Not something a settings file states — the row is, and two places to write
|
|
133
|
+
* one address is one place for it to be wrong. An instance behind a reverse
|
|
134
|
+
* proxy is dialled at the proxy's name and listens on loopback, and the two
|
|
135
|
+
* cannot be derived from each other, so what a peer dials is written down
|
|
136
|
+
* beside who it belongs to. Absent on an instance the mesh does not name,
|
|
137
|
+
* which is one that serves the unix socket alone. */
|
|
138
|
+
readonly endpoint?: Endpoint;
|
|
118
139
|
/** Absent when this instance serves the unix socket only. */
|
|
119
140
|
readonly entry?: EntryConfig;
|
|
120
141
|
readonly upstream: UpstreamConfig;
|
|
@@ -147,176 +168,543 @@ export class ConfigError extends Error {
|
|
|
147
168
|
}
|
|
148
169
|
}
|
|
149
170
|
|
|
150
|
-
/** An instance with no config file: the unix socket, no
|
|
171
|
+
/** An instance with no config file: the unix socket, no mesh, no upstreams.
|
|
151
172
|
*
|
|
152
173
|
* Absent is not broken. A config that is not there states nothing wrong, while
|
|
153
174
|
* one that is there and unreadable states something wrong — only the second is
|
|
154
175
|
* the fail-fast case. */
|
|
155
176
|
export const DEFAULT_CONFIG: InstanceConfig = {
|
|
156
177
|
harness: DEFAULT_HARNESS,
|
|
157
|
-
|
|
178
|
+
endpoints: [],
|
|
158
179
|
upstream: {},
|
|
159
180
|
direct_delivery: true,
|
|
160
181
|
fork_origin: false,
|
|
161
182
|
dump: { presets: [] },
|
|
162
183
|
};
|
|
163
184
|
|
|
164
|
-
/**
|
|
185
|
+
/** The file every instance's settings start from, and the directory holding
|
|
186
|
+
* one file per instance. Both are read from the config home a person edits
|
|
187
|
+
* (§8.2). The names are held here alone, so what the files are called is one
|
|
188
|
+
* edit rather than a search. */
|
|
189
|
+
export const CONFIG_FILE = "config_v2.ts";
|
|
190
|
+
export const INSTANCES_DIR = "instances";
|
|
191
|
+
|
|
192
|
+
/** The declarations a config file writes against, as they are called where
|
|
193
|
+
* they are copied to, and as this build keeps them. */
|
|
194
|
+
export const TYPES_FILE = "ccmsg-config_v2.d.ts";
|
|
195
|
+
const TYPES_SOURCE = "ccmsg-config.d.ts";
|
|
196
|
+
|
|
197
|
+
/** What the settings used to be written in. Named so a config home that still
|
|
198
|
+
* holds one is told where its settings have moved to, rather than starting
|
|
199
|
+
* with every setting it carried silently absent. */
|
|
200
|
+
const JSON_FILE = "config.json";
|
|
201
|
+
|
|
202
|
+
/** The fields a config function may hand back. Checked rather than ignored,
|
|
203
|
+
* because a misspelled field is a setting that was written and does not take:
|
|
204
|
+
* the types say so while the file is being edited, and this says so when it is
|
|
205
|
+
* read. */
|
|
206
|
+
const FIELDS = ["harness", "entry", "upstream", "direct_delivery", "fork_origin", "dump"] as const;
|
|
207
|
+
|
|
208
|
+
/** What only one instance's own file may state: which config home it answers
|
|
209
|
+
* for, and the address it is reached at. Neither is a thing the shared file
|
|
210
|
+
* could say once for everybody. */
|
|
211
|
+
const INSTANCE_FIELDS = ["dir", "name"] as const;
|
|
212
|
+
|
|
213
|
+
/** The mesh, as data: who is in it and where each one is reached.
|
|
165
214
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
* is
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
215
|
+
* A list rather than something derived, because it is the one thing an
|
|
216
|
+
* instance cannot work out for itself — which address of the several a host
|
|
217
|
+
* has is the one its peers dial, and which of the entries is this instance.
|
|
218
|
+
* Both are answered by the row carrying its own id, which is what settles
|
|
219
|
+
* `self` (§7.1) without asking the network anything.
|
|
220
|
+
*
|
|
221
|
+
* Every instance of the mesh is in it, this host's and the others', so one
|
|
222
|
+
* file can be copied to every host unchanged (§8.2). */
|
|
223
|
+
export const ENDPOINTS_FILE = "endpoints.json";
|
|
172
224
|
|
|
173
|
-
/**
|
|
225
|
+
/** Which of them this host starts. An id here and not in the endpoints is a
|
|
226
|
+
* mistake; an id in the endpoints and not here is another host's instance,
|
|
227
|
+
* which this one dials and does not start. */
|
|
228
|
+
export const SUPERVISOR_FILE = "supervisor.json";
|
|
229
|
+
|
|
230
|
+
/** Where the settings that were read and checked are kept, and the one file
|
|
231
|
+
* the supervisor and every instance actually read.
|
|
174
232
|
*
|
|
175
|
-
*
|
|
176
|
-
* is
|
|
177
|
-
*
|
|
178
|
-
|
|
233
|
+
* Apart from the files a person edits because the two answer different
|
|
234
|
+
* questions: what is being written, and what is running. A config that does
|
|
235
|
+
* not check out never reaches here, which is what lets a broken edit be
|
|
236
|
+
* reported without taking the host down (§8.3). */
|
|
237
|
+
export const STATE_CONFIG_DIR = "config";
|
|
238
|
+
export const SATISFIED_FILE = "satisfied.json";
|
|
239
|
+
export const REJECTED_DIR = "config.rejected";
|
|
240
|
+
|
|
241
|
+
/** One entry of the mesh. */
|
|
242
|
+
export interface EndpointRow {
|
|
243
|
+
readonly id: string;
|
|
244
|
+
readonly endpoint: Endpoint;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** What one instance is, once its file has been read. */
|
|
248
|
+
export interface InstanceSetting {
|
|
249
|
+
readonly id: string;
|
|
250
|
+
readonly name: string;
|
|
179
251
|
readonly dir: string;
|
|
180
|
-
readonly
|
|
252
|
+
readonly config: InstanceConfig;
|
|
181
253
|
}
|
|
182
254
|
|
|
183
|
-
/**
|
|
184
|
-
* homes run one.
|
|
255
|
+
/** Everything that was read, checked, and is therefore what runs.
|
|
185
256
|
*
|
|
186
|
-
* One
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
export interface
|
|
191
|
-
readonly
|
|
192
|
-
readonly instances: readonly
|
|
257
|
+
* One value rather than a directory to walk: the supervisor and the instances
|
|
258
|
+
* read this and nothing else, so what they run with is what was checked, and
|
|
259
|
+
* no TypeScript is evaluated a second time where a different answer could come
|
|
260
|
+
* back. */
|
|
261
|
+
export interface Satisfied {
|
|
262
|
+
readonly endpoints: readonly EndpointRow[];
|
|
263
|
+
readonly supervisor: { readonly instances: readonly string[] };
|
|
264
|
+
readonly instances: readonly InstanceSetting[];
|
|
193
265
|
}
|
|
194
266
|
|
|
195
|
-
|
|
267
|
+
/** A label a person may give an instance. Narrow because it is typed at a
|
|
268
|
+
* command and printed in a listing; nothing is found by it, since files are
|
|
269
|
+
* named by id. */
|
|
270
|
+
export const CONFIG_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
196
271
|
|
|
197
|
-
/**
|
|
198
|
-
*
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
272
|
+
/** Something wrong with one file, said where it is: which file, and what about
|
|
273
|
+
* it. Collected rather than thrown one at a time, so an operator who broke two
|
|
274
|
+
* things is told about both. */
|
|
275
|
+
export interface ConfigProblem {
|
|
276
|
+
readonly file: string;
|
|
277
|
+
readonly msg: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The files this reads, at the paths they are read and copied by. */
|
|
281
|
+
export function configFiles(configDir: string, instances: readonly string[]): string[] {
|
|
282
|
+
return [
|
|
283
|
+
join(configDir, CONFIG_FILE),
|
|
284
|
+
join(configDir, ENDPOINTS_FILE),
|
|
285
|
+
join(configDir, SUPERVISOR_FILE),
|
|
286
|
+
...instances.map((id) => join(configDir, INSTANCES_DIR, instanceFileName(id))),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function instanceFileName(id: string): string {
|
|
291
|
+
return `instance-${id}.ts`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Read everything a person edits, call what has to be called, and check the
|
|
295
|
+
* whole of it (DV-Q8, §8.3).
|
|
296
|
+
*
|
|
297
|
+
* One pass rather than a check per file, because what makes a config right is
|
|
298
|
+
* mostly between files: an id the supervisor starts has to be an entry of the
|
|
299
|
+
* mesh and have settings of its own, two instances must not hold one address
|
|
300
|
+
* or one config home, and the data is the mesh — a settings function that
|
|
301
|
+
* returned a different one has stated something it does not get to state. Each
|
|
302
|
+
* file is checked as far as it can be on its own so that a person is told
|
|
303
|
+
* where the mistake is, and nothing is applied until all of it holds.
|
|
304
|
+
*
|
|
305
|
+
* The settings functions are called here and never again: what they returned
|
|
306
|
+
* is what runs. They are expected to have no side effects, since this runs
|
|
307
|
+
* them to answer questions — `config show`, `config diff --satisfied` — as well as
|
|
308
|
+
* to apply them. */
|
|
309
|
+
export async function evaluate(
|
|
310
|
+
configDir: string,
|
|
311
|
+
): Promise<{ satisfied?: Satisfied; problems: readonly ConfigProblem[] }> {
|
|
312
|
+
const problems: ConfigProblem[] = [];
|
|
313
|
+
const at = (file: string, msg: string): undefined => {
|
|
314
|
+
problems.push({ file, msg });
|
|
315
|
+
return undefined;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const endpointsFile = join(configDir, ENDPOINTS_FILE);
|
|
319
|
+
const supervisorFile = join(configDir, SUPERVISOR_FILE);
|
|
320
|
+
const configFile = join(configDir, CONFIG_FILE);
|
|
321
|
+
|
|
322
|
+
const endpoints = readEndpoints(endpointsFile, at);
|
|
323
|
+
const supervised = readSupervisor(supervisorFile, at);
|
|
324
|
+
|
|
325
|
+
// An empty config home is not a broken one: nothing is being run, which is
|
|
326
|
+
// what a host that has had no `daemon add` looks like.
|
|
327
|
+
if (endpoints === undefined || supervised === undefined) {
|
|
328
|
+
if (problems.length > 0) return { problems };
|
|
329
|
+
return {
|
|
330
|
+
satisfied: { endpoints: [], supervisor: { instances: [] }, instances: [] },
|
|
331
|
+
problems,
|
|
332
|
+
};
|
|
205
333
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
334
|
+
|
|
335
|
+
const defaults = await defaultsOf(configFile, endpoints, at);
|
|
336
|
+
const instances: InstanceSetting[] = [];
|
|
337
|
+
for (const id of supervised) {
|
|
338
|
+
if (!endpoints.some((row) => row.id === id)) {
|
|
339
|
+
at(supervisorFile, `${id} is not an entry of ${ENDPOINTS_FILE}`);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
const file = join(configDir, INSTANCES_DIR, instanceFileName(id));
|
|
343
|
+
if (!existsSync(file)) {
|
|
344
|
+
at(supervisorFile, `${id} has no settings of its own at ${file}`);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (defaults === undefined) continue;
|
|
348
|
+
const read = await instanceOf(file, id, defaults, endpoints, at);
|
|
349
|
+
if (read !== undefined) instances.push(read);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// What no single file can be wrong about on its own.
|
|
353
|
+
const dirs = new Map<string, string>();
|
|
354
|
+
const ports = new Map<number, string>();
|
|
355
|
+
for (const one of instances) {
|
|
356
|
+
const file = join(configDir, INSTANCES_DIR, instanceFileName(one.id));
|
|
357
|
+
const home = dirs.get(one.dir);
|
|
358
|
+
if (home !== undefined) at(file, `dir ${one.dir} is already what ${home} answers for`);
|
|
359
|
+
else dirs.set(one.dir, one.name);
|
|
360
|
+
const port = one.config.entry?.port;
|
|
361
|
+
if (port === undefined || port === 0) continue;
|
|
362
|
+
const held = ports.get(port);
|
|
363
|
+
if (held !== undefined) at(file, `port ${String(port)} is already ${held}'s`);
|
|
364
|
+
else ports.set(port, one.name);
|
|
211
365
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
366
|
+
|
|
367
|
+
if (problems.length > 0) return { problems };
|
|
368
|
+
return {
|
|
369
|
+
satisfied: { endpoints, supervisor: { instances: supervised }, instances },
|
|
370
|
+
problems,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** The mesh as the data states it. */
|
|
375
|
+
function readEndpoints(
|
|
376
|
+
file: string,
|
|
377
|
+
at: (file: string, msg: string) => undefined,
|
|
378
|
+
): readonly EndpointRow[] | undefined {
|
|
379
|
+
const parsed = readJson(file, at);
|
|
380
|
+
if (parsed === undefined) return undefined;
|
|
381
|
+
if (!Array.isArray(parsed)) return at(file, "must be an array of {id, endpoint}");
|
|
382
|
+
const rows: EndpointRow[] = [];
|
|
383
|
+
for (const [index, raw] of parsed.entries()) {
|
|
384
|
+
const where = `[${String(index)}]`;
|
|
385
|
+
if (typeof raw !== "object" || raw === null) {
|
|
386
|
+
at(file, `${where} must be an object with id and endpoint`);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const fields = raw as Record<string, unknown>;
|
|
390
|
+
const id = fields["id"];
|
|
391
|
+
const endpoint = fields["endpoint"];
|
|
392
|
+
if (typeof id !== "string" || !ID.test(id)) {
|
|
393
|
+
at(file, `${where}.id must be an instance id`);
|
|
394
|
+
continue;
|
|
216
395
|
}
|
|
396
|
+
if (typeof endpoint !== "string" || !ENDPOINT.test(endpoint)) {
|
|
397
|
+
at(file, `${where}.endpoint must be an http:// or https:// base URL ending in /`);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (rows.some((row) => row.id === id)) at(file, `${where}.id repeats ${id}`);
|
|
401
|
+
else if (rows.some((row) => row.endpoint === endpoint)) {
|
|
402
|
+
// Two entries at one address would each be this instance to whoever
|
|
403
|
+
// dialled it, and neither could be told from the other (§7.1).
|
|
404
|
+
at(file, `${where}.endpoint repeats ${endpoint}`);
|
|
405
|
+
} else rows.push({ id, endpoint: endpoint as Endpoint });
|
|
217
406
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
407
|
+
return rows;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function readSupervisor(
|
|
411
|
+
file: string,
|
|
412
|
+
at: (file: string, msg: string) => undefined,
|
|
413
|
+
): readonly string[] | undefined {
|
|
414
|
+
const parsed = readJson(file, at);
|
|
415
|
+
if (parsed === undefined) return undefined;
|
|
416
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
417
|
+
return at(file, "must be an object with instances");
|
|
221
418
|
}
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
419
|
+
const listed = (parsed as Record<string, unknown>)["instances"] ?? [];
|
|
420
|
+
if (!Array.isArray(listed) || listed.some((id) => typeof id !== "string" || !ID.test(id))) {
|
|
421
|
+
return at(file, "instances must be an array of instance ids");
|
|
422
|
+
}
|
|
423
|
+
const ids = listed as string[];
|
|
424
|
+
const twice = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
425
|
+
if (twice.length > 0) return at(file, `instances repeats ${[...new Set(twice)].join(", ")}`);
|
|
426
|
+
return ids;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** What every instance starts from. */
|
|
430
|
+
async function defaultsOf(
|
|
431
|
+
file: string,
|
|
432
|
+
endpoints: readonly EndpointRow[],
|
|
433
|
+
at: (file: string, msg: string) => undefined,
|
|
434
|
+
): Promise<InstanceConfig | undefined> {
|
|
435
|
+
if (!existsSync(file)) {
|
|
436
|
+
const legacy = join(dirname(file), JSON_FILE);
|
|
437
|
+
if (existsSync(legacy)) {
|
|
438
|
+
return at(
|
|
439
|
+
legacy,
|
|
440
|
+
`settings are TypeScript now: write ${file}, ${join(dirname(file), ENDPOINTS_FILE)} and ${join(dirname(file), SUPERVISOR_FILE)}`,
|
|
441
|
+
);
|
|
228
442
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
443
|
+
return { ...DEFAULT_CONFIG, endpoints };
|
|
444
|
+
}
|
|
445
|
+
const returned = await called(file, {
|
|
446
|
+
builtin: frozen({ ...DEFAULT_CONFIG, endpoints }),
|
|
447
|
+
config: copied({ ...DEFAULT_CONFIG, endpoints }),
|
|
448
|
+
});
|
|
449
|
+
if (returned.problem !== undefined) return at(file, returned.problem);
|
|
450
|
+
return settingsOf(file, returned.value, endpoints, false, at)?.config;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** One instance's own file, read over what the shared one returned. */
|
|
454
|
+
async function instanceOf(
|
|
455
|
+
file: string,
|
|
456
|
+
id: string,
|
|
457
|
+
defaults: InstanceConfig,
|
|
458
|
+
endpoints: readonly EndpointRow[],
|
|
459
|
+
at: (file: string, msg: string) => undefined,
|
|
460
|
+
): Promise<InstanceSetting | undefined> {
|
|
461
|
+
const returned = await called(file, {
|
|
462
|
+
builtin: frozen({ ...DEFAULT_CONFIG, endpoints }),
|
|
463
|
+
default: frozen(defaults),
|
|
464
|
+
config: { ...copied(defaults), dir: "", name: id },
|
|
232
465
|
});
|
|
466
|
+
if (returned.problem !== undefined) return at(file, returned.problem);
|
|
467
|
+
const read = settingsOf(file, returned.value, endpoints, true, at);
|
|
468
|
+
if (read === undefined) return undefined;
|
|
469
|
+
// Where this instance is reached: its own row of the mesh. An instance the
|
|
470
|
+
// data does not name could not be dialled by anybody and could not settle
|
|
471
|
+
// what a handshake calls it (§7.1), so it is a config error rather than an
|
|
472
|
+
// instance with no address.
|
|
473
|
+
const mine = endpoints.find((row) => row.id === id);
|
|
474
|
+
if (mine === undefined) {
|
|
475
|
+
return at(file, `${id} is not an entry of ${ENDPOINTS_FILE}, so it has no endpoint`);
|
|
476
|
+
}
|
|
233
477
|
return {
|
|
234
|
-
|
|
235
|
-
|
|
478
|
+
id,
|
|
479
|
+
name: read.name === "" ? id : read.name,
|
|
480
|
+
dir: read.dir,
|
|
481
|
+
config: { ...read.config, endpoint: mine.endpoint },
|
|
236
482
|
};
|
|
237
483
|
}
|
|
238
484
|
|
|
239
|
-
/**
|
|
240
|
-
export function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
485
|
+
/** The settings that were applied, as the state directory holds them. */
|
|
486
|
+
export function applied(stateRoot: string): Satisfied | undefined {
|
|
487
|
+
let text: string;
|
|
488
|
+
try {
|
|
489
|
+
text = readFileSync(join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE), "utf8");
|
|
490
|
+
} catch {
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
return JSON.parse(text) as Satisfied;
|
|
495
|
+
} catch {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
244
498
|
}
|
|
245
499
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
500
|
+
/** Write down what checked out: the value the supervisor and the instances
|
|
501
|
+
* read, and a copy of each file it was read from.
|
|
248
502
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
|
|
503
|
+
* The copies are what `config diff` compares against and what `config revert`
|
|
504
|
+
* puts back, so they are taken at the same relative paths. Only the files
|
|
505
|
+
* named above are copied: what a settings file imports is its own business and
|
|
506
|
+
* is not backed up here, and a config home that loses one of those still
|
|
507
|
+
* starts, because what starts an instance is the value and not the file. */
|
|
508
|
+
export function apply(configDir: string, stateRoot: string, satisfied: Satisfied): void {
|
|
509
|
+
const into = join(stateRoot, STATE_CONFIG_DIR);
|
|
510
|
+
mkdirSync(join(into, INSTANCES_DIR), { recursive: true });
|
|
511
|
+
for (const file of configFiles(configDir, satisfied.supervisor.instances)) {
|
|
512
|
+
if (!existsSync(file)) continue;
|
|
513
|
+
copyFileSync(file, join(into, relative(configDir, file)));
|
|
514
|
+
}
|
|
515
|
+
writeFileSync(join(into, SATISFIED_FILE), `${JSON.stringify(satisfied, null, 2)}\n`);
|
|
516
|
+
}
|
|
252
517
|
|
|
253
|
-
/**
|
|
254
|
-
* the only ones where "combine" could mean more than one thing.
|
|
518
|
+
/** Read, check, and apply, which is the one thing startup and reload both do.
|
|
255
519
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* is
|
|
261
|
-
export
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
};
|
|
520
|
+
* A config that does not check out leaves the applied one standing and is
|
|
521
|
+
* reported: an instance already serving a session is not something a typo in a
|
|
522
|
+
* file should take away, and an operator finds out from the log and from
|
|
523
|
+
* `daemon status` rather than from everything being gone. The first run is the
|
|
524
|
+
* exception — there is nothing to fall back to, so there is nothing to run. */
|
|
525
|
+
export async function settle(
|
|
526
|
+
configDir: string,
|
|
527
|
+
stateRoot: string,
|
|
528
|
+
): Promise<{ satisfied: Satisfied; problems: readonly ConfigProblem[]; applied: boolean }> {
|
|
529
|
+
const read = await evaluate(configDir);
|
|
530
|
+
if (read.satisfied !== undefined) {
|
|
531
|
+
apply(configDir, stateRoot, read.satisfied);
|
|
532
|
+
return { satisfied: read.satisfied, problems: [], applied: true };
|
|
533
|
+
}
|
|
534
|
+
const standing = applied(stateRoot);
|
|
535
|
+
if (standing === undefined) {
|
|
536
|
+
throw new ConfigError(
|
|
537
|
+
read.problems[0]?.file ?? join(configDir, CONFIG_FILE),
|
|
538
|
+
read.problems.map((one) => `${one.file}: ${one.msg}`).join("; "),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
return { satisfied: standing, problems: read.problems, applied: false };
|
|
542
|
+
}
|
|
280
543
|
|
|
281
|
-
|
|
282
|
-
|
|
544
|
+
/** What one config home's instance runs with, out of what is applied. */
|
|
545
|
+
export function configOf(satisfied: Satisfied, dir: string): InstanceSetting | undefined {
|
|
546
|
+
return satisfied.instances.find((one) => one.dir === dir);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function readJson(file: string, at: (file: string, msg: string) => undefined): unknown {
|
|
550
|
+
let text: string;
|
|
551
|
+
try {
|
|
552
|
+
text = readFileSync(file, "utf8");
|
|
553
|
+
} catch {
|
|
554
|
+
return undefined;
|
|
555
|
+
}
|
|
556
|
+
try {
|
|
557
|
+
return JSON.parse(text);
|
|
558
|
+
} catch (cause) {
|
|
559
|
+
return at(file, `not valid JSON (${String(cause)})`);
|
|
560
|
+
}
|
|
283
561
|
}
|
|
284
562
|
|
|
285
|
-
|
|
286
|
-
|
|
563
|
+
/** Put the declarations a config file writes against beside the files that
|
|
564
|
+
* write against them.
|
|
565
|
+
*
|
|
566
|
+
* Copied into the config home rather than reached where this build keeps them:
|
|
567
|
+
* a relative `import type` resolves with no tsconfig and no node_modules
|
|
568
|
+
* anywhere near it, and it goes on resolving when this checkout moves. */
|
|
569
|
+
export function writeConfigTypes(configDir: string): string {
|
|
570
|
+
const at = join(configDir, TYPES_FILE);
|
|
571
|
+
mkdirSync(configDir, { recursive: true });
|
|
572
|
+
copyFileSync(new URL(`./${TYPES_SOURCE}`, import.meta.url).pathname, at);
|
|
573
|
+
return at;
|
|
287
574
|
}
|
|
288
575
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
576
|
+
/** Import one config file and call what it exports.
|
|
577
|
+
*
|
|
578
|
+
* The modified time rides on the specifier because an import is cached by it:
|
|
579
|
+
* a file read again in the same process after being edited — a supervisor
|
|
580
|
+
* asked to add an instance, a test writing two configs — would otherwise be
|
|
581
|
+
* the first read over again. */
|
|
582
|
+
async function called(
|
|
583
|
+
file: string,
|
|
584
|
+
ctx: Record<string, unknown>,
|
|
585
|
+
): Promise<{ value?: unknown; problem?: string }> {
|
|
586
|
+
let module: { default?: unknown };
|
|
587
|
+
try {
|
|
588
|
+
module = (await import(`${file}?mtime=${String(statSync(file).mtimeMs)}`)) as {
|
|
589
|
+
default?: unknown;
|
|
590
|
+
};
|
|
591
|
+
} catch (cause) {
|
|
592
|
+
return { problem: `cannot be loaded (${String(cause)})` };
|
|
593
|
+
}
|
|
594
|
+
const define = module.default;
|
|
595
|
+
if (typeof define !== "function") {
|
|
596
|
+
return { problem: "must default export a function taking { config } and returning it" };
|
|
597
|
+
}
|
|
598
|
+
try {
|
|
599
|
+
// Awaited whatever it answers with: what a settings file has to do to
|
|
600
|
+
// answer — read a secret, ask something — is its own business.
|
|
601
|
+
return { value: await (define as (given: unknown) => unknown)(ctx) };
|
|
602
|
+
} catch (cause) {
|
|
603
|
+
return { problem: `threw while being read (${String(cause)})` };
|
|
302
604
|
}
|
|
303
|
-
return out;
|
|
304
605
|
}
|
|
305
606
|
|
|
306
|
-
/** What one config
|
|
307
|
-
*
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
607
|
+
/** What one config function handed back, checked at the shape an instance uses
|
|
608
|
+
* it. */
|
|
609
|
+
function settingsOf(
|
|
610
|
+
file: string,
|
|
611
|
+
returned: unknown,
|
|
612
|
+
endpoints: readonly EndpointRow[],
|
|
613
|
+
wantsDir: boolean,
|
|
614
|
+
at: (file: string, msg: string) => undefined,
|
|
615
|
+
): { dir: string; name: string; config: InstanceConfig } | undefined {
|
|
616
|
+
if (typeof returned !== "object" || returned === null || Array.isArray(returned)) {
|
|
617
|
+
return at(file, "must return the config it was handed");
|
|
618
|
+
}
|
|
619
|
+
const fields = returned as Record<string, unknown>;
|
|
620
|
+
for (const name of Object.keys(fields)) {
|
|
621
|
+
if ((INSTANCE_FIELDS as readonly string[]).includes(name)) {
|
|
622
|
+
if (wantsDir) continue;
|
|
623
|
+
return at(file, `${name} belongs to an ${INSTANCES_DIR}/ file, which this is not`);
|
|
624
|
+
}
|
|
625
|
+
if (name === "endpoints") continue;
|
|
626
|
+
if (!(FIELDS as readonly string[]).includes(name)) {
|
|
627
|
+
return at(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// The mesh is data: a function is handed it so it can read it, and a
|
|
631
|
+
// function that handed back a different one has stated something that is
|
|
632
|
+
// not its to state — which would be a host running a mesh nobody wrote down.
|
|
633
|
+
if (!sameMesh(fields["endpoints"], endpoints)) {
|
|
634
|
+
return at(
|
|
635
|
+
file,
|
|
636
|
+
`endpoints are ${ENDPOINTS_FILE}'s to state, and this returned a different list`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const dir = fields["dir"];
|
|
640
|
+
if (wantsDir && (typeof dir !== "string" || !isAbsolute(dir))) {
|
|
641
|
+
return at(file, "dir must be the absolute config home this instance answers for");
|
|
642
|
+
}
|
|
643
|
+
const name = fields["name"];
|
|
644
|
+
if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
|
|
645
|
+
return at(file, "name must be a label in lower case, digits, dots, dashes");
|
|
646
|
+
}
|
|
647
|
+
let config: InstanceConfig;
|
|
648
|
+
try {
|
|
649
|
+
config = parseConfig(file, fields);
|
|
650
|
+
} catch (cause) {
|
|
651
|
+
return at(
|
|
652
|
+
file,
|
|
653
|
+
cause instanceof ConfigError ? cause.message.slice(file.length + 2) : String(cause),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
dir: wantsDir ? (dir as string) : "",
|
|
658
|
+
name: typeof name === "string" ? name : "",
|
|
659
|
+
config: { ...config, endpoints },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Whether what came back is the mesh that went in, row by row.
|
|
664
|
+
*
|
|
665
|
+
* Field by field rather than by serialising the two: what is being asked is
|
|
666
|
+
* whether a settings function changed anything, and two lists that differ in
|
|
667
|
+
* the order of their keys are the same mesh. */
|
|
668
|
+
function sameMesh(returned: unknown, rows: readonly EndpointRow[]): boolean {
|
|
669
|
+
if (!Array.isArray(returned)) return rows.length === 0;
|
|
670
|
+
if (returned.length !== rows.length) return false;
|
|
671
|
+
return rows.every((row, at) => {
|
|
672
|
+
const one = returned[at] as { id?: unknown; endpoint?: unknown } | undefined;
|
|
673
|
+
return one?.id === row.id && one.endpoint === row.endpoint;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** A copy nothing can write to, for the values a config function builds on
|
|
678
|
+
* rather than edits: what `builtin` and `default` are is settled before the
|
|
679
|
+
* file runs, so a file that tried to edit one is told so where it did it. */
|
|
680
|
+
function frozen(value: InstanceConfig): Record<string, unknown> {
|
|
681
|
+
return deepFreeze(copied(value));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function deepFreeze<T>(value: T): T {
|
|
685
|
+
if (typeof value !== "object" || value === null) return value;
|
|
686
|
+
for (const held of Object.values(value)) deepFreeze(held);
|
|
687
|
+
return Object.freeze(value);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The mutable copy a config function edits and returns.
|
|
691
|
+
*
|
|
692
|
+
* The mesh is in it, because a settings function may want to read who else
|
|
693
|
+
* there is; handing back a different one is what is refused. */
|
|
694
|
+
function copied(value: InstanceConfig): Record<string, unknown> {
|
|
695
|
+
return structuredClone(value) as unknown as Record<string, unknown>;
|
|
313
696
|
}
|
|
314
697
|
|
|
315
698
|
/** One instance's settings, read at the shape the instance uses them. */
|
|
316
699
|
export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
|
|
317
700
|
return {
|
|
318
701
|
harness: harnessOf(file, fields["harness"]),
|
|
319
|
-
|
|
702
|
+
// Put back by the caller from the data, which is where the mesh is
|
|
703
|
+
// stated; what a settings function returned has already been held to it.
|
|
704
|
+
endpoints: [],
|
|
705
|
+
...(fields["endpoint"] === undefined
|
|
706
|
+
? {}
|
|
707
|
+
: { endpoint: endpointOf(file, "endpoint", fields["endpoint"]) }),
|
|
320
708
|
...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
|
|
321
709
|
upstream: upstreamOf(file, fields["upstream"]),
|
|
322
710
|
direct_delivery: flagOf(
|
|
@@ -469,12 +857,6 @@ function terminalGatewayOf(file: string, raw: string): string {
|
|
|
469
857
|
return raw;
|
|
470
858
|
}
|
|
471
859
|
|
|
472
|
-
function peersOf(file: string, raw: unknown): readonly Endpoint[] {
|
|
473
|
-
if (raw === undefined) return [];
|
|
474
|
-
if (!Array.isArray(raw)) throw new ConfigError(file, "peers must be an array of endpoint URLs");
|
|
475
|
-
return raw.map((peer, index) => endpointOf(file, `peers[${index}]`, peer));
|
|
476
|
-
}
|
|
477
|
-
|
|
478
860
|
function entryOf(file: string, raw: unknown): EntryConfig {
|
|
479
861
|
const fields = objectOf(file, "entry", raw);
|
|
480
862
|
const port = fields["port"];
|