@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/README.md +28 -3
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +13 -5
- package/ansible/macos/worker-host-key.yml +33 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +32 -31
- package/docs/declared.md +24 -24
- package/docs/manager-setup.md +27 -0
- package/docs/runbooks.md +15 -1
- package/docs/topology.md +15 -6
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +32 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +1 -0
- package/src/cli/fleet-manifest.ts +102 -0
- package/src/cli/fleet-node-lifecycle.ts +455 -0
- package/src/cli/fleet-node-seed.ts +151 -0
- package/src/cli/fleet-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +50 -5
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +12 -3
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +22 -2
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +136 -0
- package/src/cli/registry.ts +33 -0
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/secrets.ts +3 -0
- package/src/worker-credentials.ts +201 -0
- package/src/worker-manifest.ts +201 -0
- package/src/worker-policy.ts +21 -35
- package/src/worker-seed.ts +138 -0
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
245
|
+
constant(fields['os'], 'linux', `${path}.os`);
|
|
174
246
|
return {
|
|
175
|
-
id
|
|
176
|
-
ssh
|
|
247
|
+
id,
|
|
248
|
+
ssh,
|
|
177
249
|
os: 'linux',
|
|
178
|
-
...(
|
|
179
|
-
|
|
250
|
+
...(fields['network']
|
|
251
|
+
? { network: network(fields['network'], `${path}.network`) }
|
|
252
|
+
: {}),
|
|
253
|
+
...(fields['toolProfiles'] === undefined
|
|
180
254
|
? {}
|
|
181
|
-
: {
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
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
|
|
289
|
+
id,
|
|
196
290
|
os: 'macOS',
|
|
197
|
-
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
|
|
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(
|
|
245
|
-
secrets: secrets(
|
|
246
|
-
...(
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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:
|
|
267
|
-
|
|
268
|
-
...(
|
|
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
|
-
: {
|
|
273
|
-
...(
|
|
356
|
+
: { ides: block('$.ides', () => parseIdes(fields['ides'])) }),
|
|
357
|
+
...(fields['t3'] === undefined
|
|
274
358
|
? {}
|
|
275
|
-
: {
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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:
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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(
|
|
318
|
-
|
|
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(
|
|
321
|
-
if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider))
|
|
322
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
!(
|
|
461
|
+
!(
|
|
462
|
+
Array.isArray(order) &&
|
|
463
|
+
order.every((label) => typeof label === 'string' && ACCOUNT.test(label))
|
|
464
|
+
)
|
|
337
465
|
)
|
|
338
|
-
|
|
466
|
+
fail(`${path}.order`, 'a list of account email addresses', order);
|
|
339
467
|
return {
|
|
340
|
-
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
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
'viewerAccount',
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
505
|
+
: { backups: hubBackups(fields['backups'], `${path}.backups`) }),
|
|
367
506
|
};
|
|
368
507
|
}
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
function hubBackups(value: unknown): HubBackups {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const {
|
|
375
|
-
if (
|
|
376
|
-
|
|
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(
|
|
524
|
+
storage: backupStorage(fields['storage'], `${path}.storage`),
|
|
386
525
|
};
|
|
387
526
|
}
|
|
388
|
-
function backupStorage(value: unknown): HubBackups['storage'] {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
)
|
|
393
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
466
|
-
|
|
649
|
+
const fields = object(ubuntu, '$.ubuntu');
|
|
650
|
+
keys(fields, ['release', 'architectures'], '$.ubuntu');
|
|
467
651
|
const accepted =
|
|
468
|
-
typeof
|
|
469
|
-
? ubuntuRelease(
|
|
652
|
+
typeof fields['release'] === 'string'
|
|
653
|
+
? ubuntuRelease(fields['release'])
|
|
470
654
|
: undefined;
|
|
471
|
-
if (
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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(
|