@ccmsg/cli 0.10.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/cli.ts +301 -198
- package/src/daemon/registry.ts +151 -128
- package/src/daemon/supervise.ts +16 -4
- package/src/instance/ccmsg-config.d.ts +10 -9
- package/src/instance/config.ts +374 -310
- package/src/instance/identity.ts +3 -3
- package/src/instance/instance.ts +63 -25
- package/src/instance/paths.ts +24 -6
- package/src/mesh/index.ts +0 -1
- package/src/mesh/mesh.ts +18 -61
- package/src/mesh/wire.ts +1 -29
- package/src/mesh/probe.ts +0 -105
package/src/instance/config.ts
CHANGED
|
@@ -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
|
|
121
|
+
/** Every instance of the mesh, this one among them (§7.1).
|
|
122
122
|
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
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
|
-
|
|
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"
|
|
211
|
+
const INSTANCE_FIELDS = ["dir", "name"] as const;
|
|
218
212
|
|
|
219
|
-
/**
|
|
213
|
+
/** The mesh, as data: who is in it and where each one is reached.
|
|
220
214
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
-
*
|
|
228
|
-
*
|
|
229
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
|
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
|
-
/**
|
|
255
|
+
/** Everything that was read, checked, and is therefore what runs.
|
|
260
256
|
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
readonly
|
|
268
|
-
readonly
|
|
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
|
-
/**
|
|
277
|
-
* and
|
|
278
|
-
*
|
|
279
|
-
export
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
/**
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
295
|
-
*
|
|
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.
|
|
297
304
|
*
|
|
298
|
-
* The functions are
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
export async function
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
/**
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
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
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
(
|
|
455
|
-
|
|
456
|
-
|
|
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:
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
/**
|
|
467
|
-
export function
|
|
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(
|
|
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
|
-
|
|
491
|
-
} catch
|
|
492
|
-
|
|
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
|
-
/**
|
|
498
|
-
*
|
|
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
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
/**
|
|
510
|
-
export
|
|
511
|
-
return (
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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(
|
|
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
|
-
|
|
592
|
+
return { problem: `cannot be loaded (${String(cause)})` };
|
|
549
593
|
}
|
|
550
594
|
const define = module.default;
|
|
551
595
|
if (typeof define !== "function") {
|
|
552
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
625
|
-
* is
|
|
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
|
-
|
|
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
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
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"]) }),
|