@ccmsg/cli 0.10.1 → 0.11.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.
@@ -6,7 +6,7 @@ import {
6
6
  statSync,
7
7
  writeFileSync,
8
8
  } from "node:fs";
9
- import { isAbsolute, join } from "node:path";
9
+ import { dirname, isAbsolute, join, relative } from "node:path";
10
10
  import { type DumpPreset, type Endpoint, TranscriptItemSelector } from "@ccmsg/protocol";
11
11
  import { DEFAULT_HARNESS, type Harness, HARNESSES, isHarness } from "../harness/index.ts";
12
12
  import { ID } from "./identity.ts";
@@ -118,29 +118,23 @@ export interface InstanceConfig {
118
118
  * home says nothing about the program it belongs to, and an instance that
119
119
  * guessed would walk the wrong tree for the whole of its first session. */
120
120
  readonly harness: Harness;
121
- /** Every mesh endpoint, this instance's own among them (§7.1).
121
+ /** Every instance of the mesh, this one among them (§7.1).
122
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). */
129
- readonly peers: readonly Endpoint[];
130
- /** Where peers and people reach this instance, when that is not the address
131
- * it binds.
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).
132
131
  *
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. */
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. */
144
138
  readonly endpoint?: Endpoint;
145
139
  /** Absent when this instance serves the unix socket only. */
146
140
  readonly entry?: EntryConfig;
@@ -174,14 +168,14 @@ export class ConfigError extends Error {
174
168
  }
175
169
  }
176
170
 
177
- /** An instance with no config file: the unix socket, no peers, no upstreams.
171
+ /** An instance with no config file: the unix socket, no mesh, no upstreams.
178
172
  *
179
173
  * Absent is not broken. A config that is not there states nothing wrong, while
180
174
  * one that is there and unreadable states something wrong — only the second is
181
175
  * the fail-fast case. */
182
176
  export const DEFAULT_CONFIG: InstanceConfig = {
183
177
  harness: DEFAULT_HARNESS,
184
- peers: [],
178
+ endpoints: [],
185
179
  upstream: {},
186
180
  direct_delivery: true,
187
181
  fork_origin: false,
@@ -214,309 +208,356 @@ const FIELDS = ["harness", "entry", "upstream", "direct_delivery", "fork_origin"
214
208
  /** What only one instance's own file may state: which config home it answers
215
209
  * for, and the address it is reached at. Neither is a thing the shared file
216
210
  * could say once for everybody. */
217
- const INSTANCE_FIELDS = ["dir", "name", "endpoint"] as const;
211
+ const INSTANCE_FIELDS = ["dir", "name"] as const;
218
212
 
219
- /** Which clusters this host knows of, and where each one's own file is.
213
+ /** The mesh, as data: who is in it and where each one is reached.
220
214
  *
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.
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.
226
220
  *
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";
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";
232
224
 
233
- export function clusterFileName(id: string): string {
234
- return `cluster-${id}.json`;
235
- }
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";
236
229
 
237
- export function instanceFileName(id: string): string {
238
- return `instance-${id}.ts`;
230
+ /** Where the settings that were read and checked are kept, and the one file
231
+ * the supervisor and every instance actually read.
232
+ *
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;
239
245
  }
240
246
 
241
- /** What one file under `instances/` says: which config home it is for, and
242
- * what that instance runs with.
243
- *
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. */
247
+ /** What one instance is, once its file has been read. */
248
248
  export interface InstanceSetting {
249
249
  readonly id: string;
250
250
  readonly name: string;
251
251
  readonly dir: string;
252
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[];
257
253
  }
258
254
 
259
- /** One cluster, as this host writes it down.
255
+ /** Everything that was read, checked, and is therefore what runs.
260
256
  *
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[];
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[];
274
265
  }
275
266
 
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[];
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._-]*$/;
271
+
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;
283
278
  }
284
279
 
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._-]*$/;
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
+ }
291
289
 
292
- /** Everything the config home says, read once (DV-Q8).
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).
293
296
  *
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
+ * 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.
297
304
  *
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
- }
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
+ };
360
333
  }
361
334
 
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);
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;
370
341
  }
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);
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;
388
346
  }
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 };
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);
365
+ }
366
+
367
+ if (problems.length > 0) return { problems };
368
+ return {
369
+ satisfied: { endpoints, supervisor: { instances: supervised }, instances },
370
+ problems,
371
+ };
398
372
  }
399
373
 
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);
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;
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 });
406
+ }
407
+ return rows;
407
408
  }
408
409
 
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");
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");
417
418
  }
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(", ")}`);
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
422
  }
423
- return ids.map((id) => loadCluster(configDir, id));
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;
424
427
  }
425
428
 
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
- );
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
+ );
442
+ }
443
+ return { ...DEFAULT_CONFIG, endpoints };
436
444
  }
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");
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 },
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`);
457
476
  }
458
477
  return {
459
478
  id,
460
- name: typeof name === "string" ? name : id,
461
- peers: read,
462
- instances: (instances ?? []) as string[],
479
+ name: read.name === "" ? id : read.name,
480
+ dir: read.dir,
481
+ config: { ...read.config, endpoint: mine.endpoint },
463
482
  };
464
483
  }
465
484
 
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
- }
474
-
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 {
485
+ /** The settings that were applied, as the state directory holds them. */
486
+ export function applied(stateRoot: string): Satisfied | undefined {
482
487
  let text: string;
483
488
  try {
484
- text = readFileSync(file, "utf8");
489
+ text = readFileSync(join(stateRoot, STATE_CONFIG_DIR, SATISFIED_FILE), "utf8");
485
490
  } catch {
486
491
  return undefined;
487
492
  }
488
- let parsed: unknown;
489
493
  try {
490
- parsed = JSON.parse(text);
491
- } catch (cause) {
492
- throw new ConfigError(file, `not valid JSON (${String(cause)})`);
494
+ return JSON.parse(text) as Satisfied;
495
+ } catch {
496
+ return undefined;
493
497
  }
494
- return objectOf(file, "the top level", parsed);
495
498
  }
496
499
 
497
- /** The address another instance on this host is dialled at, when its file
498
- * states no public one.
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.
502
+ *
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
+ }
517
+
518
+ /** Read, check, and apply, which is the one thing startup and reload both do.
499
519
  *
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;
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 };
507
542
  }
508
543
 
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;
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);
512
547
  }
513
548
 
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;
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
+ }
520
561
  }
521
562
 
522
563
  /** Put the declarations a config file writes against beside the files that
@@ -538,27 +579,28 @@ export function writeConfigTypes(configDir: string): string {
538
579
  * a file read again in the same process after being edited — a supervisor
539
580
  * asked to add an instance, a test writing two configs — would otherwise be
540
581
  * the first read over again. */
541
- async function called(file: string, ctx: Record<string, unknown>): Promise<unknown> {
582
+ async function called(
583
+ file: string,
584
+ ctx: Record<string, unknown>,
585
+ ): Promise<{ value?: unknown; problem?: string }> {
542
586
  let module: { default?: unknown };
543
587
  try {
544
588
  module = (await import(`${file}?mtime=${String(statSync(file).mtimeMs)}`)) as {
545
589
  default?: unknown;
546
590
  };
547
591
  } catch (cause) {
548
- throw new ConfigError(file, `cannot be loaded (${String(cause)})`);
592
+ return { problem: `cannot be loaded (${String(cause)})` };
549
593
  }
550
594
  const define = module.default;
551
595
  if (typeof define !== "function") {
552
- throw new ConfigError(
553
- file,
554
- "must default export a function taking { config } and returning it",
555
- );
596
+ return { problem: "must default export a function taking { config } and returning it" };
556
597
  }
557
598
  try {
558
- return await (define as (given: unknown) => unknown)(ctx);
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) };
559
602
  } catch (cause) {
560
- if (cause instanceof ConfigError) throw cause;
561
- throw new ConfigError(file, `threw while being read (${String(cause)})`);
603
+ return { problem: `threw while being read (${String(cause)})` };
562
604
  }
563
605
  }
564
606
 
@@ -567,45 +609,71 @@ async function called(file: string, ctx: Record<string, unknown>): Promise<unkno
567
609
  function settingsOf(
568
610
  file: string,
569
611
  returned: unknown,
612
+ endpoints: readonly EndpointRow[],
570
613
  wantsDir: boolean,
571
- ): { dir: string; name: string; config: InstanceConfig } {
572
- const fields = objectOf(file, "what the config function returned", returned);
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>;
573
620
  for (const name of Object.keys(fields)) {
574
621
  if ((INSTANCE_FIELDS as readonly string[]).includes(name)) {
575
622
  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
- );
623
+ return at(file, `${name} belongs to an ${INSTANCES_DIR}/ file, which this is not`);
589
624
  }
625
+ if (name === "endpoints") continue;
590
626
  if (!(FIELDS as readonly string[]).includes(name)) {
591
- throw new ConfigError(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
627
+ return at(file, `unknown field ${name}; expected ${FIELDS.join(", ")}`);
592
628
  }
593
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
+ }
594
639
  const dir = fields["dir"];
595
640
  if (wantsDir && (typeof dir !== "string" || !isAbsolute(dir))) {
596
- throw new ConfigError(file, "dir must be the absolute config home this instance answers for");
641
+ return at(file, "dir must be the absolute config home this instance answers for");
597
642
  }
598
643
  const name = fields["name"];
599
644
  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");
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
+ );
601
655
  }
602
656
  return {
603
657
  dir: wantsDir ? (dir as string) : "",
604
658
  name: typeof name === "string" ? name : "",
605
- config: parseConfig(file, fields),
659
+ config: { ...config, endpoints },
606
660
  };
607
661
  }
608
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
+
609
677
  /** A copy nothing can write to, for the values a config function builds on
610
678
  * rather than edits: what `builtin` and `default` are is settled before the
611
679
  * file runs, so a file that tried to edit one is told so where it did it. */
@@ -621,23 +689,19 @@ function deepFreeze<T>(value: T): T {
621
689
 
622
690
  /** The mutable copy a config function edits and returns.
623
691
  *
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. */
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. */
628
694
  function copied(value: InstanceConfig): Record<string, unknown> {
629
- const { peers: _derived, ...written } = structuredClone(value);
630
- return written as unknown as Record<string, unknown>;
695
+ return structuredClone(value) as unknown as Record<string, unknown>;
631
696
  }
632
697
 
633
698
  /** One instance's settings, read at the shape the instance uses them. */
634
699
  export function parseConfig(file: string, fields: Record<string, unknown>): InstanceConfig {
635
700
  return {
636
701
  harness: harnessOf(file, fields["harness"]),
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: [],
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: [],
641
705
  ...(fields["endpoint"] === undefined
642
706
  ? {}
643
707
  : { endpoint: endpointOf(file, "endpoint", fields["endpoint"]) }),