@crvouga/dev-fleet 0.7.0 → 0.8.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/src/definition.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  parseWorkerPolicy,
3
+ policyFault,
3
4
  toolProfiles,
4
5
  type WorkerPolicy,
5
6
  } from './worker-policy';
@@ -17,7 +18,7 @@ import {
17
18
  type T3Definition,
18
19
  } from './ide/definition';
19
20
  import { parseNotify, type NotifyConfig } from './notify';
20
- import { ubuntuRelease } from './platform/ubuntu-release';
21
+ import { UBUNTU_RELEASES, ubuntuRelease } from './platform/ubuntu-release';
21
22
  import { parseGithubPolicy, type GithubPolicy } from './github/policy';
22
23
 
23
24
  export interface MachineDefinition {
@@ -54,6 +55,11 @@ export interface FleetDefinition {
54
55
  manager: ManagerDefinition;
55
56
  /** Stateless, replaceable Linux boxes; clients of the manager's services. */
56
57
  workers: MachineDefinition[];
58
+ /** The worker that proves a revision new to the fleet before the others
59
+ * receive it, when it is reachable. Without one, the first reachable
60
+ * worker in the order above.
61
+ */
62
+ canary?: string;
57
63
  services: {
58
64
  /** Public HTTPS origin of the CLI proxy every node uses. */
59
65
  proxy: { endpoint: string; accounts?: AccountPolicy };
@@ -127,94 +133,170 @@ export interface GitIdentity {
127
133
  }
128
134
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
129
135
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
130
- function fail(): never {
131
- throw new FleetUsageError(
132
- 'Invalid Fleet definition. Check version 3, one macOS manager, unique Linux worker IDs, singleton services and network metadata in fleet.json.'
133
- );
136
+ const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
137
+ const MANAGER_USER = /^[a-z_][a-z0-9_.-]{0,31}$/;
138
+
139
+ /** What is wrong with a fleet definition: the first rule it breaks, where. */
140
+ export interface DefinitionProblem {
141
+ /** Stable: DEFINITION_INVALID, DEFINITION_MISSING, DEFINITION_UNKNOWN,
142
+ * DEFINITION_DUPLICATE or DEFINITION_UNREADABLE. */
143
+ code: string;
144
+ /** A JSONPath into fleet.json, such as `$.workers[1].network.sshPort`. */
145
+ path: string;
146
+ expected: string;
147
+ /** The kind of value found, never more than a short excerpt of it. */
148
+ actual: string;
134
149
  }
135
- function keys(value: Record<string, unknown>, allowed: string[]): boolean {
136
- return Object.keys(value).every((key) => allowed.includes(key));
150
+
151
+ export class DefinitionError extends FleetUsageError {
152
+ constructor(readonly problem: DefinitionProblem) {
153
+ super(
154
+ `Invalid fleet definition at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`
155
+ );
156
+ }
137
157
  }
138
- function network(value: unknown): NonNullable<MachineDefinition['network']> {
139
- if (
140
- !record(value) ||
141
- !keys(value, ['tailscaleName', 'user', 'sshPort', 't3Port'])
142
- )
143
- return fail();
144
- if (
145
- typeof value['tailscaleName'] !== 'string' ||
146
- !HOST.test(value['tailscaleName'])
147
- )
148
- return fail();
149
- if (
150
- typeof value['user'] !== 'string' ||
151
- !/^[a-z_][a-z0-9_-]{0,31}$/.test(value['user'])
152
- )
153
- return fail();
154
- for (const port of ['sshPort', 't3Port'])
155
- if (!validPort(value[port])) return fail();
156
- return value as NonNullable<MachineDefinition['network']>;
158
+
159
+ /** A short description of a value, for a person to recognise it. */
160
+ function describe(value: unknown): string {
161
+ if (value === undefined) return 'nothing';
162
+ if (value === null) return 'null';
163
+ if (Array.isArray(value)) return `an array of ${value.length}`;
164
+ if (typeof value === 'object') return 'an object';
165
+ const text = JSON.stringify(value);
166
+ return text.length > 64 ? `${text.slice(0, 61)}...` : text;
157
167
  }
158
- function validPort(value: unknown): boolean {
159
- return (
160
- Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535
161
- );
168
+
169
+ function fail(
170
+ path: string,
171
+ expected: string,
172
+ actual: unknown,
173
+ code = actual === undefined ? 'DEFINITION_MISSING' : 'DEFINITION_INVALID'
174
+ ): never {
175
+ throw new DefinitionError({
176
+ code,
177
+ path,
178
+ expected,
179
+ actual: describe(actual),
180
+ });
162
181
  }
163
- function worker(value: unknown): MachineDefinition {
164
- if (
165
- !record(value) ||
166
- !keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
167
- )
168
- return fail();
169
- if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
170
- if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
171
- return fail();
182
+
183
+ function object(value: unknown, path: string): Record<string, unknown> {
184
+ if (!record(value)) return fail(path, 'an object', value);
185
+ return value;
186
+ }
187
+
188
+ function keys(value: Record<string, unknown>, allowed: string[], path: string) {
189
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
190
+ if (unknown !== undefined)
191
+ fail(
192
+ `${path}.${unknown}`,
193
+ `one of ${allowed.filter((key) => key !== '$schema').join(', ')}`,
194
+ value[unknown],
195
+ 'DEFINITION_UNKNOWN'
196
+ );
197
+ }
198
+
199
+ function text(
200
+ value: unknown,
201
+ pattern: RegExp,
202
+ path: string,
203
+ expected: string
204
+ ): string {
205
+ if (typeof value !== 'string' || !pattern.test(value))
206
+ return fail(path, expected, value);
207
+ return value;
208
+ }
209
+
210
+ function constant<T>(value: unknown, wanted: T, path: string): T {
211
+ if (JSON.stringify(value) !== JSON.stringify(wanted))
212
+ return fail(path, JSON.stringify(wanted), value);
213
+ return wanted;
214
+ }
215
+
216
+ function network(
217
+ value: unknown,
218
+ path: string
219
+ ): NonNullable<MachineDefinition['network']> {
220
+ const fields = object(value, path);
221
+ keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port'], path);
222
+ return {
223
+ tailscaleName: text(
224
+ fields['tailscaleName'],
225
+ HOST,
226
+ `${path}.tailscaleName`,
227
+ 'a host name'
228
+ ),
229
+ user: text(fields['user'], USER, `${path}.user`, 'a login name'),
230
+ sshPort: port(fields['sshPort'], `${path}.sshPort`),
231
+ t3Port: port(fields['t3Port'], `${path}.t3Port`),
232
+ };
233
+ }
234
+ function port(value: unknown, path: string): number {
235
+ if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
236
+ return fail(path, 'a port from 1 to 65535', value);
237
+ return value as number;
238
+ }
239
+ function worker(value: unknown, path: string): MachineDefinition {
240
+ const fields = object(value, path);
241
+ keys(fields, ['id', 'ssh', 'os', 'network', 'toolProfiles'], path);
242
+ const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
243
+ const ssh = text(fields['ssh'], HOST, `${path}.ssh`, 'an SSH host alias');
172
244
  // Workers are the Linux boxes; any other platform is not a worker.
173
- if (value['os'] !== 'linux') return fail();
245
+ constant(fields['os'], 'linux', `${path}.os`);
174
246
  return {
175
- id: value['id'],
176
- ssh: value['ssh'],
247
+ id,
248
+ ssh,
177
249
  os: 'linux',
178
- ...(value['network'] ? { network: network(value['network']) } : {}),
179
- ...(value['toolProfiles'] === undefined
250
+ ...(fields['network']
251
+ ? { network: network(fields['network'], `${path}.network`) }
252
+ : {}),
253
+ ...(fields['toolProfiles'] === undefined
180
254
  ? {}
181
- : { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
255
+ : {
256
+ toolProfiles:
257
+ toolProfiles(fields['toolProfiles']) ??
258
+ fail(
259
+ `${path}.toolProfiles`,
260
+ 'a list of known tool profiles, each once',
261
+ fields['toolProfiles']
262
+ ),
263
+ }),
182
264
  };
183
265
  }
184
266
  function manager(value: unknown): ManagerDefinition {
185
- if (
186
- !record(value) ||
187
- !keys(value, ['id', 'os', 'network', 'neverSleepOnPower']) ||
188
- typeof value['id'] !== 'string' ||
189
- !ID.test(value['id']) ||
190
- value['os'] !== 'macOS' ||
191
- value['neverSleepOnPower'] !== true
192
- )
193
- return fail();
267
+ const path = '$.manager';
268
+ const fields = object(value, path);
269
+ keys(fields, ['id', 'os', 'network', 'neverSleepOnPower'], path);
270
+ const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
271
+ // The manager must be the Mac: a Linux machine is a worker.
272
+ constant(fields['os'], 'macOS', `${path}.os`);
273
+ const net = object(fields['network'], `${path}.network`);
274
+ keys(net, ['tailscaleName', 'user'], `${path}.network`);
275
+ const tailscaleName = text(
276
+ net['tailscaleName'],
277
+ HOST,
278
+ `${path}.network.tailscaleName`,
279
+ 'a host name'
280
+ );
281
+ const user = text(
282
+ net['user'],
283
+ MANAGER_USER,
284
+ `${path}.network.user`,
285
+ 'a login name'
286
+ );
287
+ constant(fields['neverSleepOnPower'], true, `${path}.neverSleepOnPower`);
194
288
  return {
195
- id: value['id'],
289
+ id,
196
290
  os: 'macOS',
197
- network: managerNetwork(value['network']),
291
+ network: { tailscaleName, user },
198
292
  neverSleepOnPower: true,
199
293
  };
200
294
  }
201
- function managerNetwork(value: unknown): ManagerDefinition['network'] {
202
- if (
203
- !record(value) ||
204
- !keys(value, ['tailscaleName', 'user']) ||
205
- typeof value['tailscaleName'] !== 'string' ||
206
- !HOST.test(value['tailscaleName']) ||
207
- typeof value['user'] !== 'string' ||
208
- !/^[a-z_][a-z0-9_.-]{0,31}$/.test(value['user'])
209
- )
210
- return fail();
211
- return { tailscaleName: value['tailscaleName'], user: value['user'] };
212
- }
213
295
  export function parseDefinition(value: unknown): FleetDefinition {
214
- if (
215
- !record(value) ||
216
- value['version'] !== 3 ||
217
- !keys(value, [
296
+ const fields = object(value, '$');
297
+ keys(
298
+ fields,
299
+ [
218
300
  '$schema',
219
301
  'version',
220
302
  'source',
@@ -222,6 +304,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
222
304
  'state',
223
305
  'manager',
224
306
  'workers',
307
+ 'canary',
225
308
  'services',
226
309
  'ubuntu',
227
310
  'worker',
@@ -230,216 +313,317 @@ export function parseDefinition(value: unknown): FleetDefinition {
230
313
  'git',
231
314
  'notify',
232
315
  'github',
233
- ])
234
- )
235
- return fail();
236
- if (!Array.isArray(value['workers']) || value['workers'].length > 64)
237
- return fail();
238
- const lead = manager(value['manager']);
239
- const workers = value['workers'].map(worker);
240
- const ids = [lead.id, ...workers.map((entry) => entry.id)];
241
- if (new Set(ids).size !== ids.length) return fail();
316
+ ],
317
+ '$'
318
+ );
319
+ constant(fields['version'], 3, '$.version');
320
+ const list = fields['workers'];
321
+ if (!Array.isArray(list) || list.length > 64)
322
+ return fail('$.workers', 'an array of at most 64 workers', list);
323
+ const lead = manager(fields['manager']);
324
+ const workers = list.map((entry, index) =>
325
+ worker(entry, `$.workers[${index}]`)
326
+ );
327
+ const seen = new Set([lead.id]);
328
+ workers.forEach((entry, index) => {
329
+ if (seen.has(entry.id))
330
+ fail(
331
+ `$.workers[${index}].id`,
332
+ 'an id no other machine has',
333
+ entry.id,
334
+ 'DEFINITION_DUPLICATE'
335
+ );
336
+ seen.add(entry.id);
337
+ });
242
338
  return {
243
339
  version: 3,
244
- source: source(value['source']),
245
- secrets: secrets(value['secrets']),
246
- ...(value['state'] === undefined ? {} : { state: state(value['state']) }),
340
+ source: source(fields['source']),
341
+ secrets: secrets(fields['secrets']),
342
+ ...(fields['state'] === undefined ? {} : { state: state(fields['state']) }),
247
343
  manager: lead,
248
344
  workers,
249
- services: services(value['services']),
250
- ubuntu: baseline(value['ubuntu']),
251
- ...optionalSections(value),
252
- };
253
- }
254
- /** The sections a fleet may leave out, kept only when named: an earlier
255
- * revision must still read the copy.
256
- */
257
- function optionalSections(
258
- value: Record<string, unknown>
259
- ): Pick<
260
- FleetDefinition,
261
- 'worker' | 'ides' | 't3' | 'git' | 'notify' | 'github'
262
- > {
263
- return {
264
- ...(value['worker'] === undefined
345
+ ...(fields['canary'] === undefined
346
+ ? {}
347
+ : { canary: canary(fields['canary'], workers) }),
348
+ services: services(fields['services']),
349
+ ubuntu: baseline(fields['ubuntu']),
350
+ ...(fields['worker'] === undefined
265
351
  ? {}
266
- : { worker: parseWorkerPolicy(value['worker']) }),
267
- ...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
268
- ...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
269
- ...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
270
- ...(value['notify'] === undefined
352
+ : { worker: policy(fields['worker']) }),
353
+ // Kept only when named: an earlier revision must still read the copy.
354
+ ...(fields['ides'] === undefined
271
355
  ? {}
272
- : { notify: parseNotify(value['notify']) ?? fail() }),
273
- ...(value['github'] === undefined
356
+ : { ides: block('$.ides', () => parseIdes(fields['ides'])) }),
357
+ ...(fields['t3'] === undefined
274
358
  ? {}
275
- : { github: parseGithubPolicy(value['github']) }),
359
+ : { t3: block('$.t3', () => parseT3(fields['t3'])) }),
360
+ ...(fields['git'] === undefined ? {} : { git: gitIdentity(fields['git']) }),
361
+ ...(fields['notify'] === undefined
362
+ ? {}
363
+ : {
364
+ notify:
365
+ parseNotify(fields['notify']) ??
366
+ fail(
367
+ '$.notify',
368
+ 'targets (webhook, ntfy) and repeatHours from 0 to 720',
369
+ fields['notify']
370
+ ),
371
+ }),
372
+ ...(fields['github'] === undefined
373
+ ? {}
374
+ : {
375
+ github: block('$.github', () => parseGithubPolicy(fields['github'])),
376
+ }),
276
377
  };
277
378
  }
379
+ /** The canary is one of the declared workers. */
380
+ function canary(value: unknown, workers: MachineDefinition[]): string {
381
+ if (typeof value !== 'string' || !workers.some((entry) => entry.id === value))
382
+ return fail('$.canary', 'the id of a declared worker', value);
383
+ return value;
384
+ }
278
385
  /** Singleton services name no host: they always run on the manager. */
279
386
  function services(value: unknown): FleetDefinition['services'] {
280
- if (!record(value) || !keys(value, ['proxy', 'monitoring'])) return fail();
281
- const { proxy, monitoring } = value;
282
- if (
283
- !record(proxy) ||
284
- !keys(proxy, ['endpoint', 'accounts']) ||
285
- !origin(proxy['endpoint'])
286
- )
287
- return fail();
387
+ const fields = object(value, '$.services');
388
+ keys(fields, ['proxy', 'monitoring'], '$.services');
389
+ const proxy = object(fields['proxy'], '$.services.proxy');
390
+ keys(proxy, ['endpoint', 'accounts'], '$.services.proxy');
288
391
  return {
289
392
  proxy: {
290
- endpoint: proxy['endpoint'],
393
+ endpoint: text(
394
+ proxy['endpoint'],
395
+ ORIGIN,
396
+ '$.services.proxy.endpoint',
397
+ 'an https origin without a path'
398
+ ),
291
399
  ...(proxy['accounts'] === undefined
292
400
  ? {}
293
401
  : { accounts: accountPolicy(proxy['accounts']) }),
294
402
  },
295
- monitoring: monitoringService(monitoring),
403
+ monitoring: monitoringService(fields['monitoring']),
296
404
  };
297
405
  }
406
+ /** A block parsed elsewhere: its own message says what it expects. */
407
+ function block<T>(path: string, parse: () => T): T {
408
+ try {
409
+ return parse();
410
+ } catch (error) {
411
+ if (error instanceof DefinitionError || !(error instanceof Error))
412
+ throw error;
413
+ return fail(path, error.message, 'something else');
414
+ }
415
+ }
298
416
  const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
299
417
  function accountPolicy(value: unknown): AccountPolicy {
300
- if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
301
- return fail();
302
- const { minimumUsable, selection } = value;
418
+ const path = '$.services.proxy.accounts';
419
+ const fields = object(value, path);
420
+ keys(fields, ['minimumUsable', 'selection'], path);
421
+ const { minimumUsable, selection } = fields;
303
422
  if (
304
423
  minimumUsable !== undefined &&
305
424
  !(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
306
425
  )
307
- return fail();
426
+ fail(`${path}.minimumUsable`, 'an integer of at least 1', minimumUsable);
308
427
  return {
309
428
  ...(minimumUsable === undefined
310
429
  ? {}
311
430
  : { minimumUsable: minimumUsable as number }),
312
431
  ...(selection === undefined
313
432
  ? {}
314
- : { selection: selectionPolicies(selection) }),
433
+ : { selection: selectionPolicies(selection, `${path}.selection`) }),
315
434
  };
316
435
  }
317
- function selectionPolicies(value: unknown): Record<string, AccountSelection> {
318
- if (!record(value)) return fail();
436
+ function selectionPolicies(
437
+ value: unknown,
438
+ path: string
439
+ ): Record<string, AccountSelection> {
440
+ const fields = object(value, path);
319
441
  const providers: Record<string, AccountSelection> = {};
320
- for (const [provider, entry] of Object.entries(value)) {
321
- if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider)) return fail();
322
- providers[provider] = accountSelection(entry);
442
+ for (const [provider, entry] of Object.entries(fields)) {
443
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider))
444
+ fail(`${path}.${provider}`, 'a provider name', provider);
445
+ providers[provider] = accountSelection(entry, `${path}.${provider}`);
323
446
  }
324
447
  return providers;
325
448
  }
326
- function accountSelection(entry: unknown): AccountSelection {
327
- if (
328
- !record(entry) ||
329
- !keys(entry, ['strategy', 'order']) ||
330
- !STRATEGIES.includes(String(entry['strategy']))
331
- )
332
- return fail();
333
- const { order } = entry;
449
+ function accountSelection(entry: unknown, path: string): AccountSelection {
450
+ const fields = object(entry, path);
451
+ keys(fields, ['strategy', 'order'], path);
452
+ if (!STRATEGIES.includes(String(fields['strategy'])))
453
+ fail(
454
+ `${path}.strategy`,
455
+ `one of ${STRATEGIES.join(', ')}`,
456
+ fields['strategy']
457
+ );
458
+ const { order } = fields;
334
459
  if (
335
460
  order !== undefined &&
336
- !(Array.isArray(order) && order.every((label) => account(label)))
461
+ !(
462
+ Array.isArray(order) &&
463
+ order.every((label) => typeof label === 'string' && ACCOUNT.test(label))
464
+ )
337
465
  )
338
- return fail();
466
+ fail(`${path}.order`, 'a list of account email addresses', order);
339
467
  return {
340
- strategy: entry['strategy'] as AccountSelection['strategy'],
468
+ strategy: fields['strategy'] as AccountSelection['strategy'],
341
469
  ...(order ? { order: order as string[] } : {}),
342
470
  };
343
471
  }
344
472
  function monitoringService(
345
473
  value: unknown
346
474
  ): FleetDefinition['services']['monitoring'] {
347
- const fields = [
348
- 'hubPort',
349
- 'publicUrl',
350
- 'managerAccount',
351
- 'viewerAccount',
352
- 'backups',
353
- ];
354
- if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
355
- return fail();
356
- const { publicUrl, managerAccount, viewerAccount } = value;
357
- if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
358
- return fail();
475
+ const path = '$.services.monitoring';
476
+ const fields = object(value, path);
477
+ keys(
478
+ fields,
479
+ ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount', 'backups'],
480
+ path
481
+ );
482
+ constant(fields['hubPort'], 8443, `${path}.hubPort`);
359
483
  return {
360
484
  hubPort: 8443,
361
- publicUrl,
362
- managerAccount,
363
- viewerAccount,
364
- ...(value['backups'] === undefined
485
+ publicUrl: text(
486
+ fields['publicUrl'],
487
+ ORIGIN,
488
+ `${path}.publicUrl`,
489
+ 'an https origin without a path'
490
+ ),
491
+ managerAccount: text(
492
+ fields['managerAccount'],
493
+ ACCOUNT,
494
+ `${path}.managerAccount`,
495
+ 'an email address'
496
+ ),
497
+ viewerAccount: text(
498
+ fields['viewerAccount'],
499
+ ACCOUNT,
500
+ `${path}.viewerAccount`,
501
+ 'an email address'
502
+ ),
503
+ ...(fields['backups'] === undefined
365
504
  ? {}
366
- : { backups: hubBackups(value['backups']) }),
505
+ : { backups: hubBackups(fields['backups'], `${path}.backups`) }),
367
506
  };
368
507
  }
369
- const text = (value: unknown, pattern: RegExp): value is string =>
370
- typeof value === 'string' && pattern.test(value);
371
- function hubBackups(value: unknown): HubBackups {
372
- if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
373
- return fail();
374
- const { schedule, keep } = value;
375
- if (
376
- !text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
377
- !Number.isInteger(keep) ||
378
- Number(keep) < 1 ||
379
- Number(keep) > 365
380
- )
381
- return fail();
508
+ const ORIGIN = /^https:\/\/[A-Za-z0-9.-]{1,253}$/;
509
+ const ACCOUNT = /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/;
510
+ function hubBackups(value: unknown, path: string): HubBackups {
511
+ const fields = object(value, path);
512
+ keys(fields, ['schedule', 'keep', 'storage'], path);
513
+ const { keep } = fields;
514
+ if (!Number.isInteger(keep) || Number(keep) < 1 || Number(keep) > 365)
515
+ fail(`${path}.keep`, 'an integer from 1 to 365', keep);
382
516
  return {
383
- schedule,
517
+ schedule: text(
518
+ fields['schedule'],
519
+ /^[0-9*/,-]+( [0-9*/,-]+){4}$/,
520
+ `${path}.schedule`,
521
+ 'a cron expression of five fields'
522
+ ),
384
523
  keep: Number(keep),
385
- storage: backupStorage(value['storage']),
524
+ storage: backupStorage(fields['storage'], `${path}.storage`),
386
525
  };
387
526
  }
388
- function backupStorage(value: unknown): HubBackups['storage'] {
389
- if (
390
- !record(value) ||
391
- !keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
392
- )
393
- return fail();
394
- const { bucket, endpoint, region, forcePathStyle } = value;
395
- if (
396
- !text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
397
- !text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
398
- !text(region, /^[a-z0-9-]{1,32}$/) ||
399
- (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
400
- )
401
- return fail();
527
+ function backupStorage(value: unknown, path: string): HubBackups['storage'] {
528
+ const fields = object(value, path);
529
+ keys(fields, ['bucket', 'endpoint', 'region', 'forcePathStyle'], path);
530
+ const { forcePathStyle } = fields;
531
+ if (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
532
+ fail(`${path}.forcePathStyle`, 'true or false', forcePathStyle);
402
533
  return {
403
- bucket,
404
- endpoint,
405
- region,
406
- ...(forcePathStyle === undefined ? {} : { forcePathStyle }),
534
+ bucket: text(
535
+ fields['bucket'],
536
+ /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
537
+ `${path}.bucket`,
538
+ 'an S3 bucket name'
539
+ ),
540
+ endpoint: text(
541
+ fields['endpoint'],
542
+ /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/,
543
+ `${path}.endpoint`,
544
+ 'an https origin, with a port if needed'
545
+ ),
546
+ region: text(
547
+ fields['region'],
548
+ /^[a-z0-9-]{1,32}$/,
549
+ `${path}.region`,
550
+ 'a region name'
551
+ ),
552
+ ...(forcePathStyle === undefined
553
+ ? {}
554
+ : { forcePathStyle: forcePathStyle as boolean }),
407
555
  };
408
556
  }
409
- const origin = (value: unknown): value is string =>
410
- typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
411
- const account = (value: unknown): value is string =>
412
- typeof value === 'string' &&
413
- /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
414
557
  function gitIdentity(value: unknown): GitIdentity {
415
- if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
416
- return fail();
417
- const { name, email, verifyRepository } = value;
418
- // Written into a quoted Git configuration value: no quote, backslash or
419
- // line break.
420
- if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
421
- return fail();
422
- if (!account(email)) return fail();
423
- if (
424
- verifyRepository !== undefined &&
425
- (typeof verifyRepository !== 'string' ||
426
- !/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
427
- verifyRepository
428
- ))
429
- )
430
- return fail();
558
+ const path = '$.git';
559
+ const fields = object(value, path);
560
+ keys(fields, ['name', 'email', 'verifyRepository'], path);
561
+ const { verifyRepository } = fields;
431
562
  return {
432
- name,
433
- email,
434
- ...(verifyRepository === undefined ? {} : { verifyRepository }),
563
+ // Written into a quoted Git configuration value: no quote, backslash or
564
+ // line break.
565
+ name: text(
566
+ fields['name'],
567
+ /^[^"\\\n\r]{1,100}$/,
568
+ `${path}.name`,
569
+ 'a name without quotes, backslashes or line breaks'
570
+ ),
571
+ email: text(fields['email'], ACCOUNT, `${path}.email`, 'an email address'),
572
+ ...(verifyRepository === undefined
573
+ ? {}
574
+ : {
575
+ verifyRepository: text(
576
+ verifyRepository,
577
+ /^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/,
578
+ `${path}.verifyRepository`,
579
+ 'an https URL of a GitHub repository'
580
+ ),
581
+ }),
435
582
  };
436
583
  }
437
584
  function secrets(value: unknown): SecretsConfig {
438
- return parseSecretsConfig(value) ?? fail();
585
+ return (
586
+ parseSecretsConfig(value) ??
587
+ fail(
588
+ '$.secrets',
589
+ 'a provider block: env, file, sops or vault with its settings',
590
+ value
591
+ )
592
+ );
593
+ }
594
+ const POLICY: Record<string, string> = {
595
+ requiredCapabilities:
596
+ 'every capability: github, vault, t3, toolchain, workstation, worker',
597
+ githubLogin: 'a GitHub login',
598
+ vaultUser: 'a Vault user name',
599
+ toolchain: '"mise.lock"',
600
+ agentProfile: '"proxy"',
601
+ unattendedSudo: 'true',
602
+ persistentT3: 'true',
603
+ neverSleep: 'true',
604
+ toolProfiles: 'a list of known tool profiles, each once',
605
+ tools: 'a list of tool names from dev-fleet’s mise.toml, each once',
606
+ };
607
+ function policy(value: unknown): WorkerPolicy {
608
+ const fields = object(value, '$.worker');
609
+ keys(fields, Object.keys(POLICY), '$.worker');
610
+ const field = policyFault(fields);
611
+ if (field !== null)
612
+ fail(`$.worker.${field}`, POLICY[field] ?? 'a valid value', fields[field]);
613
+ return parseWorkerPolicy(fields);
439
614
  }
440
615
  /** Says what is wrong with the block itself: a bucket has several settings. */
441
616
  function state(value: unknown): BucketLocation {
442
- const bucket = parseLocation(value);
617
+ let bucket;
618
+ try {
619
+ bucket = parseLocation(value);
620
+ } catch (error) {
621
+ return fail(
622
+ '$.state',
623
+ error instanceof Error ? error.message : 'a bucket or a directory',
624
+ value
625
+ );
626
+ }
443
627
  if (bucket.kind === 'directory') return { directory: bucket.path };
444
628
  const { bucket: name, endpoint, region } = bucket;
445
629
  return {
@@ -450,36 +634,52 @@ function state(value: unknown): BucketLocation {
450
634
  };
451
635
  }
452
636
  function source(value: unknown): FleetDefinition['source'] {
453
- if (
454
- !record(value) ||
455
- !keys(value, ['repository']) ||
456
- typeof value['repository'] !== 'string' ||
457
- !/^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/.test(
458
- value['repository']
459
- )
460
- )
461
- return fail();
462
- return { repository: value['repository'] };
637
+ const fields = object(value, '$.source');
638
+ keys(fields, ['repository'], '$.source');
639
+ return {
640
+ repository: text(
641
+ fields['repository'],
642
+ /^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/,
643
+ '$.source.repository',
644
+ 'an https URL of a Git repository ending in .git'
645
+ ),
646
+ };
463
647
  }
464
648
  function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
465
- if (!record(ubuntu) || !keys(ubuntu, ['release', 'architectures']))
466
- return fail();
649
+ const fields = object(ubuntu, '$.ubuntu');
650
+ keys(fields, ['release', 'architectures'], '$.ubuntu');
467
651
  const accepted =
468
- typeof ubuntu['release'] === 'string'
469
- ? ubuntuRelease(ubuntu['release'])
652
+ typeof fields['release'] === 'string'
653
+ ? ubuntuRelease(fields['release'])
470
654
  : undefined;
471
- if (
472
- !accepted ||
473
- JSON.stringify(ubuntu['architectures']) !==
474
- JSON.stringify(accepted.architectures)
475
- )
476
- return fail();
655
+ if (!accepted)
656
+ return fail(
657
+ '$.ubuntu.release',
658
+ `one of ${UBUNTU_RELEASES.map((entry) => entry.release).join(', ')}`,
659
+ fields['release']
660
+ );
661
+ constant(
662
+ fields['architectures'],
663
+ accepted.architectures,
664
+ '$.ubuntu.architectures'
665
+ );
477
666
  return {
478
667
  release: accepted.release,
479
668
  architectures: [...accepted.architectures],
480
669
  };
481
670
  }
482
671
 
672
+ /** The first rule a fleet.json breaks, or null when it is valid. */
673
+ export function definitionProblem(value: unknown): DefinitionProblem | null {
674
+ try {
675
+ parseDefinition(value);
676
+ return null;
677
+ } catch (error) {
678
+ if (error instanceof DefinitionError) return error.problem;
679
+ throw error;
680
+ }
681
+ }
682
+
483
683
  /** The configuration as every machine keeps it at
484
684
  * ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
485
685
  * on the manager reaches the bucket, and a launcher that still runs the
@@ -492,11 +692,18 @@ export function installedDefinition(definition: FleetDefinition): string {
492
692
  }
493
693
 
494
694
  export function loadDefinition(path: string): FleetDefinition {
695
+ let value: unknown;
495
696
  try {
496
- return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
697
+ value = JSON.parse(readFileSync(path, 'utf8'));
497
698
  } catch {
498
- return fail();
699
+ return fail(
700
+ '$',
701
+ 'a readable JSON file',
702
+ 'unreadable',
703
+ 'DEFINITION_UNREADABLE'
704
+ );
499
705
  }
706
+ return parseDefinition(value);
500
707
  }
501
708
  /** Local v1 roster supplies paths/requirements only for explicitly enrolled workers. */
502
709
  export function enrolledRoster(