@ccmsg/cli 0.9.0 → 0.10.1
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 +298 -81
- package/src/daemon/control.ts +61 -9
- package/src/daemon/registry.ts +370 -65
- package/src/daemon/supervise.ts +14 -2
- package/src/instance/ccmsg-config.d.ts +111 -0
- package/src/instance/config.ts +444 -126
- package/src/instance/identity.ts +11 -2
- package/src/instance/instance.ts +9 -2
- package/src/instance/paths.ts +19 -6
- package/src/mesh/mesh.ts +30 -2
- package/src/upstream/events.ts +108 -10
- package/src/upstream/gateway.ts +7 -2
- package/src/upstream/requests.ts +102 -8
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 { isAbsolute, join } 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,30 @@ 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 endpoint, this instance's own among them (§7.1).
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
121
|
+
/** Every mesh endpoint, this instance's own among them (§7.1).
|
|
122
|
+
*
|
|
123
|
+
* Derived rather than written: the instances on this host are the ones whose
|
|
124
|
+
* files say which port they listen on, and the rest are the endpoints
|
|
125
|
+
* `peers.json` names. A person who had to write the local half as well would
|
|
126
|
+
* be writing down a second time what `daemon add` already settled, and could
|
|
127
|
+
* get it wrong — which is a mesh an instance is silently not in. Which entry
|
|
128
|
+
* of the list is this instance is settled at startup by the probe (§7.1). */
|
|
117
129
|
readonly peers: readonly Endpoint[];
|
|
130
|
+
/** Where peers and people reach this instance, when that is not the address
|
|
131
|
+
* it binds.
|
|
132
|
+
*
|
|
133
|
+
* An instance behind a reverse proxy is dialled at the proxy's name and
|
|
134
|
+
* listens on loopback, and the two cannot be derived from each other. It is
|
|
135
|
+
* what the mesh puts in its list for this instance — so it is what the probe
|
|
136
|
+
* settles `self` to, what a handshake carries as `iss` and `aud`, and what
|
|
137
|
+
* a person is handed to open a page at (§7.1). Absent leaves the address
|
|
138
|
+
* this instance binds, which is what a host with no proxy in front of it
|
|
139
|
+
* has.
|
|
140
|
+
*
|
|
141
|
+
* Stated per instance, in the file that already states which port: what a
|
|
142
|
+
* proxy is set up to forward where is one fact, and writing it twice is a
|
|
143
|
+
* second place for it to be wrong. */
|
|
144
|
+
readonly endpoint?: Endpoint;
|
|
118
145
|
/** Absent when this instance serves the unix socket only. */
|
|
119
146
|
readonly entry?: EntryConfig;
|
|
120
147
|
readonly upstream: UpstreamConfig;
|
|
@@ -161,47 +188,302 @@ export const DEFAULT_CONFIG: InstanceConfig = {
|
|
|
161
188
|
dump: { presets: [] },
|
|
162
189
|
};
|
|
163
190
|
|
|
164
|
-
/**
|
|
191
|
+
/** The file every instance's settings start from, and the directory holding
|
|
192
|
+
* one file per instance. Both are read from the config home a person edits
|
|
193
|
+
* (§8.2). The names are held here alone, so what the files are called is one
|
|
194
|
+
* edit rather than a search. */
|
|
195
|
+
export const CONFIG_FILE = "config_v2.ts";
|
|
196
|
+
export const INSTANCES_DIR = "instances";
|
|
197
|
+
|
|
198
|
+
/** The declarations a config file writes against, as they are called where
|
|
199
|
+
* they are copied to, and as this build keeps them. */
|
|
200
|
+
export const TYPES_FILE = "ccmsg-config_v2.d.ts";
|
|
201
|
+
const TYPES_SOURCE = "ccmsg-config.d.ts";
|
|
202
|
+
|
|
203
|
+
/** What the settings used to be written in. Named so a config home that still
|
|
204
|
+
* holds one is told where its settings have moved to, rather than starting
|
|
205
|
+
* with every setting it carried silently absent. */
|
|
206
|
+
const JSON_FILE = "config.json";
|
|
207
|
+
|
|
208
|
+
/** The fields a config function may hand back. Checked rather than ignored,
|
|
209
|
+
* because a misspelled field is a setting that was written and does not take:
|
|
210
|
+
* the types say so while the file is being edited, and this says so when it is
|
|
211
|
+
* read. */
|
|
212
|
+
const FIELDS = ["harness", "entry", "upstream", "direct_delivery", "fork_origin", "dump"] as const;
|
|
213
|
+
|
|
214
|
+
/** What only one instance's own file may state: which config home it answers
|
|
215
|
+
* for, and the address it is reached at. Neither is a thing the shared file
|
|
216
|
+
* could say once for everybody. */
|
|
217
|
+
const INSTANCE_FIELDS = ["dir", "name", "endpoint"] as const;
|
|
218
|
+
|
|
219
|
+
/** Which clusters this host knows of, and where each one's own file is.
|
|
220
|
+
*
|
|
221
|
+
* Data rather than a function, and a list rather than a directory listing: what
|
|
222
|
+
* is a cluster and what is an instance is stated, so a file nobody listed is
|
|
223
|
+
* not read and a file somebody listed and then deleted is an error rather than
|
|
224
|
+
* a cluster that quietly shrank. `ccmsg mesh` and `daemon add` write these,
|
|
225
|
+
* which is why they are the shape a program reads whole.
|
|
165
226
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
export
|
|
170
|
-
|
|
227
|
+
* A cluster's file is this host's account of that cluster. A cluster spans
|
|
228
|
+
* hosts and no copy of it is the canonical one: each host writes down the peers
|
|
229
|
+
* it dials and the instances it runs. */
|
|
230
|
+
export const CLUSTERS_FILE = "clusters.json";
|
|
231
|
+
export const CLUSTERS_DIR = "clusters";
|
|
232
|
+
|
|
233
|
+
export function clusterFileName(id: string): string {
|
|
234
|
+
return `cluster-${id}.json`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function instanceFileName(id: string): string {
|
|
238
|
+
return `instance-${id}.ts`;
|
|
171
239
|
}
|
|
172
240
|
|
|
173
|
-
/**
|
|
241
|
+
/** What one file under `instances/` says: which config home it is for, and
|
|
242
|
+
* what that instance runs with.
|
|
174
243
|
*
|
|
175
|
-
*
|
|
176
|
-
* is
|
|
177
|
-
*
|
|
178
|
-
|
|
244
|
+
* The id is what the file is called and what everything the instance issued is
|
|
245
|
+
* keyed by; the name is a label a person picks and may change, and defaults to
|
|
246
|
+
* the id. Keeping them apart is what lets a rename be a rename — the file, the
|
|
247
|
+
* state directory and every record already written stay where they are. */
|
|
248
|
+
export interface InstanceSetting {
|
|
249
|
+
readonly id: string;
|
|
250
|
+
readonly name: string;
|
|
179
251
|
readonly dir: string;
|
|
180
|
-
readonly
|
|
252
|
+
readonly config: InstanceConfig;
|
|
253
|
+
/** The clusters this instance belongs to, in the order the host lists them.
|
|
254
|
+
* More than one is allowed: an instance is a config home, and which
|
|
255
|
+
* management units it is part of is a separate question (A2). */
|
|
256
|
+
readonly clusters: readonly ClusterInfo[];
|
|
181
257
|
}
|
|
182
258
|
|
|
183
|
-
/**
|
|
184
|
-
* homes run one.
|
|
259
|
+
/** One cluster, as this host writes it down.
|
|
185
260
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
readonly
|
|
261
|
+
* A cluster is the unit a person manages: some instances, one mesh, one scope
|
|
262
|
+
* for the authentication records that are replicated across it. Which
|
|
263
|
+
* instances are in it is stated here rather than discovered, so an instance
|
|
264
|
+
* file that nobody listed runs nothing and an id listed with no file is an
|
|
265
|
+
* error. */
|
|
266
|
+
export interface ClusterSetting {
|
|
267
|
+
readonly id: string;
|
|
268
|
+
readonly name: string;
|
|
269
|
+
/** The mesh endpoints of this cluster that this host does not serve itself.
|
|
270
|
+
* The ones it does serve are the instances listed below, at the address each
|
|
271
|
+
* of their files gives them. */
|
|
272
|
+
readonly peers: readonly Endpoint[];
|
|
273
|
+
readonly instances: readonly string[];
|
|
193
274
|
}
|
|
194
275
|
|
|
195
|
-
|
|
276
|
+
/** What an instance is told about one cluster it belongs to: which cluster,
|
|
277
|
+
* and every mesh endpoint of it — this instance's own among them, because that
|
|
278
|
+
* is what the startup probe settles which entry it is against (§7.1). */
|
|
279
|
+
export interface ClusterInfo {
|
|
280
|
+
readonly id: string;
|
|
281
|
+
readonly name: string;
|
|
282
|
+
readonly peers: readonly Endpoint[];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** A label a person may give an instance or a cluster.
|
|
286
|
+
*
|
|
287
|
+
* Narrow because it is typed at a command and printed in a listing, not
|
|
288
|
+
* because anything is found by it: files are named by id, so a name may change
|
|
289
|
+
* without moving anything. */
|
|
290
|
+
export const CONFIG_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
291
|
+
|
|
292
|
+
/** Everything the config home says, read once (DV-Q8).
|
|
293
|
+
*
|
|
294
|
+
* There is no watch and no reload: the files are small, an instance is cheap
|
|
295
|
+
* to restart because almost nothing it holds is persistent (§3.6), and
|
|
296
|
+
* restarting is therefore the whole of "apply a config change" (§8.2).
|
|
297
|
+
*
|
|
298
|
+
* The functions are handed frozen copies of what they build on and a mutable
|
|
299
|
+
* copy of their own starting point, so what an instance runs with is what its
|
|
300
|
+
* file returned: there is no merge rule to know, because the file does the
|
|
301
|
+
* combining itself and can see exactly what it is combining with. */
|
|
302
|
+
export async function loadAll(configDir: string): Promise<{
|
|
303
|
+
readonly defaults: InstanceConfig;
|
|
304
|
+
readonly clusters: readonly ClusterSetting[];
|
|
305
|
+
readonly instances: readonly InstanceSetting[];
|
|
306
|
+
}> {
|
|
307
|
+
const file = join(configDir, CONFIG_FILE);
|
|
308
|
+
if (!existsSync(file)) {
|
|
309
|
+
const legacy = join(configDir, JSON_FILE);
|
|
310
|
+
if (existsSync(legacy)) {
|
|
311
|
+
throw new ConfigError(
|
|
312
|
+
legacy,
|
|
313
|
+
`settings are TypeScript now: write ${file}, ${join(configDir, CLUSTERS_FILE)} and ${join(configDir, INSTANCES_DIR, instanceFileName("<id>"))}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return { defaults: DEFAULT_CONFIG, clusters: [], instances: [] };
|
|
317
|
+
}
|
|
318
|
+
const returned = await called(file, {
|
|
319
|
+
builtin: frozen(DEFAULT_CONFIG),
|
|
320
|
+
config: copied(DEFAULT_CONFIG),
|
|
321
|
+
});
|
|
322
|
+
const defaults = settingsOf(file, returned, false).config;
|
|
323
|
+
const clusters = loadClusters(configDir);
|
|
324
|
+
|
|
325
|
+
// Every instance any cluster lists, read once however many clusters list it:
|
|
326
|
+
// an instance is one config home and one process, and belonging to two
|
|
327
|
+
// clusters is not being two of anything.
|
|
328
|
+
const own = new Map<string, { name: string; dir: string; config: InstanceConfig }>();
|
|
329
|
+
const homes = new Map<string, string>();
|
|
330
|
+
for (const cluster of clusters) {
|
|
331
|
+
for (const id of cluster.instances) {
|
|
332
|
+
if (own.has(id)) continue;
|
|
333
|
+
const at = join(configDir, INSTANCES_DIR, instanceFileName(id));
|
|
334
|
+
if (!existsSync(at)) {
|
|
335
|
+
throw new ConfigError(
|
|
336
|
+
join(configDir, CLUSTERS_DIR, clusterFileName(cluster.id)),
|
|
337
|
+
`names instance ${id}, whose file ${at} is not there`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const answer = await called(at, {
|
|
341
|
+
builtin: frozen(DEFAULT_CONFIG),
|
|
342
|
+
default: frozen(defaults),
|
|
343
|
+
config: { ...copied(defaults), dir: "", name: id },
|
|
344
|
+
});
|
|
345
|
+
const settings = settingsOf(at, answer, true);
|
|
346
|
+
// Two instances answering for one config home would take each other's
|
|
347
|
+
// lock and state (A2), so which of the two files is wrong is asked here
|
|
348
|
+
// rather than discovered as a start that never settles.
|
|
349
|
+
const already = homes.get(settings.dir);
|
|
350
|
+
if (already !== undefined) {
|
|
351
|
+
throw new ConfigError(at, `dir ${settings.dir} is already what ${already} answers for`);
|
|
352
|
+
}
|
|
353
|
+
homes.set(settings.dir, instanceFileName(id));
|
|
354
|
+
own.set(id, {
|
|
355
|
+
name: settings.name === "" ? id : settings.name,
|
|
356
|
+
dir: settings.dir,
|
|
357
|
+
config: settings.config,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// What each cluster's mesh is: its own remote peers, and the instances of
|
|
363
|
+
// this host that are in it, each at the address its file gives it (§7.1).
|
|
364
|
+
const meshes = new Map<string, ClusterInfo>();
|
|
365
|
+
for (const cluster of clusters) {
|
|
366
|
+
const mesh: Endpoint[] = [];
|
|
367
|
+
for (const id of cluster.instances) {
|
|
368
|
+
const reached = endpointOfInstance(own.get(id)?.config);
|
|
369
|
+
if (reached !== undefined && !mesh.includes(reached)) mesh.push(reached);
|
|
370
|
+
}
|
|
371
|
+
for (const peer of cluster.peers) if (!mesh.includes(peer)) mesh.push(peer);
|
|
372
|
+
meshes.set(cluster.id, { id: cluster.id, name: cluster.name, peers: mesh });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const instances = [...own].map(([id, held]) => {
|
|
376
|
+
const mine = clusters
|
|
377
|
+
.filter((cluster) => cluster.instances.includes(id))
|
|
378
|
+
.flatMap((cluster) => {
|
|
379
|
+
const info = meshes.get(cluster.id);
|
|
380
|
+
return info === undefined ? [] : [info];
|
|
381
|
+
});
|
|
382
|
+
// The mesh this instance dials is every cluster it is in. Holding the
|
|
383
|
+
// clusters apart as well is what the isolation between them will be built
|
|
384
|
+
// on; what it does today is say which are which.
|
|
385
|
+
const peers: Endpoint[] = [];
|
|
386
|
+
for (const cluster of mine) {
|
|
387
|
+
for (const peer of cluster.peers) if (!peers.includes(peer)) peers.push(peer);
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
id,
|
|
391
|
+
name: held.name,
|
|
392
|
+
dir: held.dir,
|
|
393
|
+
config: { ...held.config, peers },
|
|
394
|
+
clusters: mine,
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
return { defaults, clusters, instances };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Where a peer reaches one instance: what its file says it is reached at, and
|
|
401
|
+
* failing that the address it binds. Neither is an instance that serves the
|
|
402
|
+
* unix socket alone, which is in nobody's mesh. */
|
|
403
|
+
function endpointOfInstance(config: InstanceConfig | undefined): Endpoint | undefined {
|
|
404
|
+
if (config === undefined) return undefined;
|
|
405
|
+
if (config.endpoint !== undefined) return config.endpoint;
|
|
406
|
+
return config.entry === undefined ? undefined : localEndpoint(config.entry);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The clusters this host knows of, in the order it lists them. */
|
|
410
|
+
export function loadClusters(configDir: string): readonly ClusterSetting[] {
|
|
411
|
+
const file = join(configDir, CLUSTERS_FILE);
|
|
412
|
+
const top = readJson(file);
|
|
413
|
+
if (top === undefined) return [];
|
|
414
|
+
const listed = (top as { clusters?: unknown })["clusters"];
|
|
415
|
+
if (!Array.isArray(listed) || listed.some((id) => !ID.test(String(id)))) {
|
|
416
|
+
throw new ConfigError(file, "clusters must be an array of cluster ids");
|
|
417
|
+
}
|
|
418
|
+
const ids = listed as string[];
|
|
419
|
+
const repeated = ids.filter((id, index) => ids.indexOf(id) !== index);
|
|
420
|
+
if (repeated.length > 0) {
|
|
421
|
+
throw new ConfigError(file, `repeats ${[...new Set(repeated)].join(", ")}`);
|
|
422
|
+
}
|
|
423
|
+
return ids.map((id) => loadCluster(configDir, id));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** One cluster's own file. Listed and missing is an error: a cluster whose
|
|
427
|
+
* instances could not be read is a mesh silently short of them. */
|
|
428
|
+
export function loadCluster(configDir: string, id: string): ClusterSetting {
|
|
429
|
+
const file = join(configDir, CLUSTERS_DIR, clusterFileName(id));
|
|
430
|
+
const fields = readJson(file);
|
|
431
|
+
if (fields === undefined) {
|
|
432
|
+
throw new ConfigError(
|
|
433
|
+
join(configDir, CLUSTERS_FILE),
|
|
434
|
+
`names cluster ${id}, whose file ${file} is not there`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const name = fields["name"];
|
|
438
|
+
if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
|
|
439
|
+
throw new ConfigError(file, "name must be a label in lower case, digits, dots, dashes");
|
|
440
|
+
}
|
|
441
|
+
const peers = fields["peers"];
|
|
442
|
+
if (peers !== undefined && !Array.isArray(peers)) {
|
|
443
|
+
throw new ConfigError(file, "peers must be an array of endpoint URLs");
|
|
444
|
+
}
|
|
445
|
+
const read = ((peers ?? []) as unknown[]).map((peer, index) =>
|
|
446
|
+
endpointOf(file, `peers[${String(index)}]`, peer),
|
|
447
|
+
);
|
|
448
|
+
const twice = read.filter((peer, index) => read.indexOf(peer) !== index);
|
|
449
|
+
if (twice.length > 0)
|
|
450
|
+
throw new ConfigError(file, `peers repeats ${[...new Set(twice)].join(", ")}`);
|
|
451
|
+
const instances = fields["instances"];
|
|
452
|
+
if (
|
|
453
|
+
instances !== undefined &&
|
|
454
|
+
(!Array.isArray(instances) || instances.some((one) => !ID.test(String(one))))
|
|
455
|
+
) {
|
|
456
|
+
throw new ConfigError(file, "instances must be an array of instance ids");
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
id,
|
|
460
|
+
name: typeof name === "string" ? name : id,
|
|
461
|
+
peers: read,
|
|
462
|
+
instances: (instances ?? []) as string[],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Write one cluster's file back, at the shape a person reads it in. */
|
|
467
|
+
export function saveCluster(configDir: string, cluster: ClusterSetting): void {
|
|
468
|
+
mkdirSync(join(configDir, CLUSTERS_DIR), { recursive: true });
|
|
469
|
+
writeFileSync(
|
|
470
|
+
join(configDir, CLUSTERS_DIR, clusterFileName(cluster.id)),
|
|
471
|
+
`${JSON.stringify({ name: cluster.name, peers: cluster.peers, instances: cluster.instances }, null, 2)}\n`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
196
474
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
199
|
-
|
|
475
|
+
/** Write down which clusters there are. */
|
|
476
|
+
export function saveClusters(configDir: string, ids: readonly string[]): void {
|
|
477
|
+
mkdirSync(configDir, { recursive: true });
|
|
478
|
+
writeFileSync(join(configDir, CLUSTERS_FILE), `${JSON.stringify({ clusters: ids }, null, 2)}\n`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function readJson(file: string): Record<string, unknown> | undefined {
|
|
200
482
|
let text: string;
|
|
201
483
|
try {
|
|
202
484
|
text = readFileSync(file, "utf8");
|
|
203
485
|
} catch {
|
|
204
|
-
return
|
|
486
|
+
return undefined;
|
|
205
487
|
}
|
|
206
488
|
let parsed: unknown;
|
|
207
489
|
try {
|
|
@@ -209,114 +491,156 @@ export function loadShared(file: string): SharedConfig {
|
|
|
209
491
|
} catch (cause) {
|
|
210
492
|
throw new ConfigError(file, `not valid JSON (${String(cause)})`);
|
|
211
493
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
const { dir, ...settings } = fields;
|
|
226
|
-
if (typeof dir !== "string" || !isAbsolute(dir)) {
|
|
227
|
-
throw new ConfigError(file, `instances[${index}].dir must be an absolute config home`);
|
|
228
|
-
}
|
|
229
|
-
if (seen.has(dir)) throw new ConfigError(file, `instances[${index}].dir repeats ${dir}`);
|
|
230
|
-
seen.add(dir);
|
|
231
|
-
return { dir, settings };
|
|
232
|
-
});
|
|
233
|
-
return {
|
|
234
|
-
defaults: top["defaults"] === undefined ? {} : objectOf(file, "defaults", top["defaults"]),
|
|
235
|
-
instances,
|
|
236
|
-
};
|
|
494
|
+
return objectOf(file, "the top level", parsed);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The address another instance on this host is dialled at, when its file
|
|
498
|
+
* states no public one.
|
|
499
|
+
*
|
|
500
|
+
* A bind of every address is not an address, so a host that listens on all of
|
|
501
|
+
* them is reached at the loopback one — the peer doing the dialling is on this
|
|
502
|
+
* machine, and that is the address it has. */
|
|
503
|
+
function localEndpoint(entry: EntryConfig): Endpoint {
|
|
504
|
+
const host = entry.host === "0.0.0.0" || entry.host === "::" ? "127.0.0.1" : entry.host;
|
|
505
|
+
const at = host.includes(":") ? `[${host}]` : host;
|
|
506
|
+
return `http://${at}:${String(entry.port)}/` as Endpoint;
|
|
237
507
|
}
|
|
238
508
|
|
|
239
|
-
/**
|
|
240
|
-
export function
|
|
241
|
-
|
|
242
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
243
|
-
writeFileSync(file, `${JSON.stringify({ defaults: shared.defaults, instances }, null, 2)}\n`);
|
|
509
|
+
/** The instances this host runs, in the order its clusters list them. */
|
|
510
|
+
export async function loadInstances(configDir: string): Promise<readonly InstanceSetting[]> {
|
|
511
|
+
return (await loadAll(configDir)).instances;
|
|
244
512
|
}
|
|
245
513
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
514
|
+
/** What one config home's instance runs with. A config home no cluster lists
|
|
515
|
+
* still resolves — `daemon run` on an unregistered directory is what
|
|
516
|
+
* `config.ts` returns, plus the built-ins. */
|
|
517
|
+
export async function loadConfig(configDir: string, dir: string): Promise<InstanceConfig> {
|
|
518
|
+
const all = await loadAll(configDir);
|
|
519
|
+
return all.instances.find((one) => one.dir === dir)?.config ?? all.defaults;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Put the declarations a config file writes against beside the files that
|
|
523
|
+
* write against them.
|
|
248
524
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
|
|
525
|
+
* Copied into the config home rather than reached where this build keeps them:
|
|
526
|
+
* a relative `import type` resolves with no tsconfig and no node_modules
|
|
527
|
+
* anywhere near it, and it goes on resolving when this checkout moves. */
|
|
528
|
+
export function writeConfigTypes(configDir: string): string {
|
|
529
|
+
const at = join(configDir, TYPES_FILE);
|
|
530
|
+
mkdirSync(configDir, { recursive: true });
|
|
531
|
+
copyFileSync(new URL(`./${TYPES_SOURCE}`, import.meta.url).pathname, at);
|
|
532
|
+
return at;
|
|
533
|
+
}
|
|
252
534
|
|
|
253
|
-
/**
|
|
254
|
-
* the only ones where "combine" could mean more than one thing.
|
|
535
|
+
/** Import one config file and call what it exports.
|
|
255
536
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
"
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
537
|
+
* The modified time rides on the specifier because an import is cached by it:
|
|
538
|
+
* a file read again in the same process after being edited — a supervisor
|
|
539
|
+
* asked to add an instance, a test writing two configs — would otherwise be
|
|
540
|
+
* the first read over again. */
|
|
541
|
+
async function called(file: string, ctx: Record<string, unknown>): Promise<unknown> {
|
|
542
|
+
let module: { default?: unknown };
|
|
543
|
+
try {
|
|
544
|
+
module = (await import(`${file}?mtime=${String(statSync(file).mtimeMs)}`)) as {
|
|
545
|
+
default?: unknown;
|
|
546
|
+
};
|
|
547
|
+
} catch (cause) {
|
|
548
|
+
throw new ConfigError(file, `cannot be loaded (${String(cause)})`);
|
|
549
|
+
}
|
|
550
|
+
const define = module.default;
|
|
551
|
+
if (typeof define !== "function") {
|
|
552
|
+
throw new ConfigError(
|
|
553
|
+
file,
|
|
554
|
+
"must default export a function taking { config } and returning it",
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
return await (define as (given: unknown) => unknown)(ctx);
|
|
559
|
+
} catch (cause) {
|
|
560
|
+
if (cause instanceof ConfigError) throw cause;
|
|
561
|
+
throw new ConfigError(file, `threw while being read (${String(cause)})`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
280
564
|
|
|
281
|
-
function
|
|
282
|
-
|
|
565
|
+
/** What one config function handed back, checked at the shape an instance uses
|
|
566
|
+
* it. */
|
|
567
|
+
function settingsOf(
|
|
568
|
+
file: string,
|
|
569
|
+
returned: unknown,
|
|
570
|
+
wantsDir: boolean,
|
|
571
|
+
): { dir: string; name: string; config: InstanceConfig } {
|
|
572
|
+
const fields = objectOf(file, "what the config function returned", returned);
|
|
573
|
+
for (const name of Object.keys(fields)) {
|
|
574
|
+
if ((INSTANCE_FIELDS as readonly string[]).includes(name)) {
|
|
575
|
+
if (wantsDir) continue;
|
|
576
|
+
throw new ConfigError(
|
|
577
|
+
file,
|
|
578
|
+
`${name} belongs to an ${INSTANCES_DIR}/ file, which this is not`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (name === "peers") {
|
|
582
|
+
// Said as its own refusal rather than as an unknown field, because a
|
|
583
|
+
// person writing one is not misspelling anything: they are stating a
|
|
584
|
+
// mesh, and the answer is where a mesh is stated now.
|
|
585
|
+
throw new ConfigError(
|
|
586
|
+
file,
|
|
587
|
+
`peers are not written here: a mesh belongs to a cluster, so the instances of one are the ids its ${CLUSTERS_DIR}/ file lists and the rest are that file's peers (ccmsg mesh add)`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
if (!(FIELDS as readonly string[]).includes(name)) {
|
|
591
|
+
throw new ConfigError(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const dir = fields["dir"];
|
|
595
|
+
if (wantsDir && (typeof dir !== "string" || !isAbsolute(dir))) {
|
|
596
|
+
throw new ConfigError(file, "dir must be the absolute config home this instance answers for");
|
|
597
|
+
}
|
|
598
|
+
const name = fields["name"];
|
|
599
|
+
if (name !== undefined && (typeof name !== "string" || !CONFIG_NAME.test(name))) {
|
|
600
|
+
throw new ConfigError(file, "name must be a label in lower case, digits, dots, dashes");
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
dir: wantsDir ? (dir as string) : "",
|
|
604
|
+
name: typeof name === "string" ? name : "",
|
|
605
|
+
config: parseConfig(file, fields),
|
|
606
|
+
};
|
|
283
607
|
}
|
|
284
608
|
|
|
285
|
-
|
|
286
|
-
|
|
609
|
+
/** A copy nothing can write to, for the values a config function builds on
|
|
610
|
+
* rather than edits: what `builtin` and `default` are is settled before the
|
|
611
|
+
* file runs, so a file that tried to edit one is told so where it did it. */
|
|
612
|
+
function frozen(value: InstanceConfig): Record<string, unknown> {
|
|
613
|
+
return deepFreeze(copied(value));
|
|
287
614
|
}
|
|
288
615
|
|
|
289
|
-
function
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
): Record<string, unknown> {
|
|
294
|
-
const out: Record<string, unknown> = { ...base };
|
|
295
|
-
for (const [name, value] of Object.entries(over)) {
|
|
296
|
-
const path = at === "" ? name : `${at}.${name}`;
|
|
297
|
-
const under = out[name];
|
|
298
|
-
out[name] =
|
|
299
|
-
ruleFor(path) === "merge" && plainObject(under) && plainObject(value)
|
|
300
|
-
? merged(under, value, path)
|
|
301
|
-
: value;
|
|
302
|
-
}
|
|
303
|
-
return out;
|
|
616
|
+
function deepFreeze<T>(value: T): T {
|
|
617
|
+
if (typeof value !== "object" || value === null) return value;
|
|
618
|
+
for (const held of Object.values(value)) deepFreeze(held);
|
|
619
|
+
return Object.freeze(value);
|
|
304
620
|
}
|
|
305
621
|
|
|
306
|
-
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
622
|
+
/** The mutable copy a config function edits and returns.
|
|
623
|
+
*
|
|
624
|
+
* Without `peers`, which is the one field of a config that no file writes: it
|
|
625
|
+
* is derived from the instances of this host and `peers.json`, so handing it
|
|
626
|
+
* over would be offering a value that is ignored — and a file that returned it
|
|
627
|
+
* unchanged would be returning a field this refuses. */
|
|
628
|
+
function copied(value: InstanceConfig): Record<string, unknown> {
|
|
629
|
+
const { peers: _derived, ...written } = structuredClone(value);
|
|
630
|
+
return written as unknown as Record<string, unknown>;
|
|
313
631
|
}
|
|
314
632
|
|
|
315
633
|
/** One instance's settings, read at the shape the instance uses them. */
|
|
316
634
|
export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
|
|
317
635
|
return {
|
|
318
636
|
harness: harnessOf(file, fields["harness"]),
|
|
319
|
-
|
|
637
|
+
// Filled in by whoever read the config home, which is the only place the
|
|
638
|
+
// mesh is known: one file states one instance, and a mesh is every one of
|
|
639
|
+
// them plus what `peers.json` names.
|
|
640
|
+
peers: [],
|
|
641
|
+
...(fields["endpoint"] === undefined
|
|
642
|
+
? {}
|
|
643
|
+
: { endpoint: endpointOf(file, "endpoint", fields["endpoint"]) }),
|
|
320
644
|
...(fields["entry"] === undefined ? {} : { entry: entryOf(file, fields["entry"]) }),
|
|
321
645
|
upstream: upstreamOf(file, fields["upstream"]),
|
|
322
646
|
direct_delivery: flagOf(
|
|
@@ -469,12 +793,6 @@ function terminalGatewayOf(file: string, raw: string): string {
|
|
|
469
793
|
return raw;
|
|
470
794
|
}
|
|
471
795
|
|
|
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
796
|
function entryOf(file: string, raw: unknown): EntryConfig {
|
|
479
797
|
const fields = objectOf(file, "entry", raw);
|
|
480
798
|
const port = fields["port"];
|