@crvouga/dev-fleet 0.3.0 → 0.4.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.
Files changed (77) hide show
  1. package/README.md +22 -16
  2. package/ansible/github/git.yml +66 -0
  3. package/ansible/macos/policy.json +11 -1
  4. package/ansible/monitoring/compose.yml +10 -0
  5. package/ansible/monitoring/container-watchdog.py +118 -0
  6. package/ansible/monitoring/hub-darwin.yml +146 -0
  7. package/ansible/net/remote-probe.sh +5 -0
  8. package/ansible/ubuntu/baseline.yml +2 -0
  9. package/ansible/ubuntu/disk-gc.py +25 -3
  10. package/ansible/ubuntu/disk-guard.py +24 -5
  11. package/ansible/ubuntu/policy.json +50 -1
  12. package/ansible/ubuntu/tasks/browser.yml +56 -0
  13. package/ansible/ubuntu/tasks/docker.yml +52 -0
  14. package/ansible/ubuntu/tasks/resources.yml +2 -2
  15. package/ansible/ubuntu/tasks/services.yml +13 -0
  16. package/bootstrap/doctor.ts +32 -6
  17. package/bootstrap/fleet-remote-bootstrap.sh +31 -4
  18. package/bootstrap/setup.ts +23 -8
  19. package/bootstrap/worker-tools.ts +118 -0
  20. package/cliproxy/README.md +1 -1
  21. package/cliproxy/compose.yaml +4 -0
  22. package/cliproxy/scripts/checkpoint.py +1 -1
  23. package/docs/authentication.md +90 -0
  24. package/docs/declared.md +2 -0
  25. package/docs/machine-setup.md +22 -12
  26. package/docs/operations.md +87 -31
  27. package/docs/runbooks.md +380 -0
  28. package/docs/state.md +13 -7
  29. package/docs/ubuntu-sync.md +7 -6
  30. package/examples/fleet.json +19 -1
  31. package/llms.txt +2 -0
  32. package/mise.toml +7 -0
  33. package/package.json +2 -2
  34. package/schema/check-ids.json +522 -0
  35. package/schema/fleet.schema.json +178 -1
  36. package/schema/output/check.schema.json +23 -0
  37. package/schema/output/error.schema.json +14 -0
  38. package/schema/output/fleet-report.schema.json +79 -0
  39. package/schema/output/fleet-sync.schema.json +112 -0
  40. package/schema/output/report.schema.json +38 -0
  41. package/src/auth-checks.ts +3 -0
  42. package/src/cli/agent-guide.ts +5 -3
  43. package/src/cli/fleet-report.ts +126 -0
  44. package/src/cli/fleet-sync-manager.ts +11 -0
  45. package/src/cli/fleet-sync-plan.ts +200 -0
  46. package/src/cli/fleet-sync-remote.ts +3 -1
  47. package/src/cli/fleet-sync-schedule.ts +142 -35
  48. package/src/cli/fleet-sync.ts +90 -8
  49. package/src/cli/fleet-watch.ts +198 -0
  50. package/src/cli/fleet.ts +4 -0
  51. package/src/cli/register-fleet.ts +51 -0
  52. package/src/cli/registry.ts +13 -0
  53. package/src/cli/schedule-logs.ts +36 -0
  54. package/src/clock.ts +63 -0
  55. package/src/definition.ts +142 -5
  56. package/src/git-identity.ts +160 -0
  57. package/src/machine-health.ts +51 -4
  58. package/src/manager-health.ts +205 -0
  59. package/src/manager-sync.ts +12 -2
  60. package/src/monitoring/hub-backup.ts +63 -17
  61. package/src/monitoring/manager-hub.ts +44 -2
  62. package/src/node-converge-linux.ts +16 -5
  63. package/src/notify.ts +239 -0
  64. package/src/remote-bootstrap.ts +87 -25
  65. package/src/remote-operation.ts +213 -0
  66. package/src/schedule-check.ts +40 -0
  67. package/src/secrets.ts +26 -8
  68. package/src/ssh.ts +10 -2
  69. package/src/state/history.ts +172 -0
  70. package/src/state/store.ts +11 -4
  71. package/src/tailscale.ts +51 -0
  72. package/src/toolchain-checks.ts +8 -3
  73. package/src/worker-capability-checks.ts +167 -0
  74. package/src/worker-capacity.ts +19 -7
  75. package/src/worker-host-checks.ts +32 -0
  76. package/src/worker-policy.ts +33 -0
  77. package/src/worker-readiness.ts +10 -1
@@ -0,0 +1,198 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { fleet, secret } from '../fleet-config';
4
+ import {
5
+ deliver,
6
+ observe,
7
+ parseMemory,
8
+ transitions,
9
+ type Notification,
10
+ type NotifyConfig,
11
+ } from '../notify';
12
+ import { connection } from '../state/connection';
13
+ import type { Bucket } from '../state/bucket';
14
+ import type { SecretsProvider } from '../secrets';
15
+ import type { Check, NodeReport } from '../types';
16
+ import type { CommandOutcome } from './contracts';
17
+
18
+ export interface FleetWatchOptions {
19
+ test?: boolean;
20
+ }
21
+
22
+ /** What the watcher remembers between runs, kept in the bucket beside the
23
+ * fleet state. Its time is the heartbeat an off-machine check reads.
24
+ */
25
+ export interface WatchStore {
26
+ read: () => Promise<string | null>;
27
+ write: (text: string) => Promise<void>;
28
+ }
29
+
30
+ export interface FleetWatchServices {
31
+ notify: NotifyConfig | undefined;
32
+ /** The machines as `fleet-status --workers` sees them now. */
33
+ status: () => Promise<NodeReport[]>;
34
+ store: WatchStore | null;
35
+ secrets: SecretsProvider;
36
+ send: typeof fetch;
37
+ now: () => Date;
38
+ }
39
+
40
+ /** Compares the fleet's health with the previous run and notifies on
41
+ * transitions only. `--test` sends one test notification and nothing else.
42
+ */
43
+ export async function fleetWatch(
44
+ options: FleetWatchOptions,
45
+ services: FleetWatchServices
46
+ ): Promise<CommandOutcome> {
47
+ const { notify } = services;
48
+ if (options.test) {
49
+ const checks = notify
50
+ ? await deliver(
51
+ notify,
52
+ { event: 'test', node: 'fleet-watch', checks: [] },
53
+ services.secrets,
54
+ services.send
55
+ )
56
+ : [unconfigured('fail')];
57
+ return outcome(checks, [
58
+ { event: 'test', node: 'fleet-watch', checks: [] },
59
+ ]);
60
+ }
61
+ const previous = services.store
62
+ ? parseMemory(await services.store.read().catch(() => null))
63
+ : null;
64
+ const observed = observe(await services.status());
65
+ const { notifications, memory } = transitions(
66
+ previous,
67
+ observed,
68
+ services.now(),
69
+ notify?.repeatHours ?? 0
70
+ );
71
+ const checks: Check[] = [];
72
+ for (const notification of notifications)
73
+ if (notify)
74
+ checks.push(
75
+ ...(await deliver(
76
+ notify,
77
+ notification,
78
+ services.secrets,
79
+ services.send
80
+ ))
81
+ );
82
+ if (!notify) checks.push(unconfigured('warn'));
83
+ checks.push(await heartbeat(services.store, JSON.stringify(memory)));
84
+ return outcome(checks, notifications);
85
+ }
86
+
87
+ function unconfigured(status: 'warn' | 'fail'): Check {
88
+ return {
89
+ id: 'notify:configured',
90
+ status,
91
+ summary: 'NOTIFY_NOT_CONFIGURED',
92
+ remedy: {
93
+ action:
94
+ 'Add a "notify" block to fleet.json with its targets, and store each target address as its secret (notify-webhook-url, notify-ntfy-url).',
95
+ },
96
+ };
97
+ }
98
+
99
+ async function heartbeat(
100
+ store: WatchStore | null,
101
+ text: string
102
+ ): Promise<Check> {
103
+ const id = 'watch:heartbeat';
104
+ if (!store)
105
+ return {
106
+ id,
107
+ status: 'warn',
108
+ summary: 'WATCH_HEARTBEAT_NO_BUCKET',
109
+ remedy: {
110
+ action:
111
+ 'Name the bucket under "state" in fleet.json: the heartbeat and what the watcher remembers are kept there.',
112
+ },
113
+ };
114
+ return store.write(text).then(
115
+ () => ({
116
+ id,
117
+ status: 'pass' as const,
118
+ summary: 'The heartbeat and the health of each machine were stored.',
119
+ }),
120
+ () => ({
121
+ id,
122
+ status: 'fail' as const,
123
+ summary: 'WATCH_HEARTBEAT_UNWRITTEN',
124
+ remedy: {
125
+ action:
126
+ 'Check the bucket credentials this run was given (devfleet fleet-state shows the connection).',
127
+ },
128
+ })
129
+ );
130
+ }
131
+
132
+ function outcome(
133
+ checks: Check[],
134
+ notifications: Notification[]
135
+ ): CommandOutcome {
136
+ const ok = !checks.some((check) => check.status === 'fail');
137
+ const lines = [
138
+ ...notifications.map(
139
+ (entry) =>
140
+ `${entry.event} ${entry.node}${entry.checks.length ? `: ${entry.checks.join(', ')}` : ''}`
141
+ ),
142
+ ...checks.map((check) => `${check.status} ${check.id} ${check.summary}`),
143
+ ];
144
+ return {
145
+ exitCode: ok ? 0 : 1,
146
+ data: { version: 1, command: 'fleet-watch', ok, notifications, checks },
147
+ text: lines.join('\n') || 'Nothing changed.',
148
+ };
149
+ }
150
+
151
+ /** The watcher's object in the bucket, beside the fleet state. */
152
+ export const WATCH_OBJECT = 'watch.json';
153
+
154
+ export function bucketStore(bucket: Bucket | null): WatchStore | null {
155
+ if (!bucket) return null;
156
+ if (bucket.kind === 'directory') {
157
+ const path = join(bucket.path, WATCH_OBJECT);
158
+ return {
159
+ read: () => readFile(path, 'utf8').catch(() => null),
160
+ write: async (text) => {
161
+ await mkdir(dirname(path), { recursive: true });
162
+ await writeFile(path, text, { mode: 0o600 });
163
+ },
164
+ };
165
+ }
166
+ const client = new Bun.S3Client({
167
+ bucket: bucket.bucket,
168
+ ...(bucket.endpoint ? { endpoint: bucket.endpoint } : {}),
169
+ ...(bucket.region ? { region: bucket.region } : {}),
170
+ ...(bucket.accessKeyId && bucket.secretAccessKey
171
+ ? {
172
+ accessKeyId: bucket.accessKeyId,
173
+ secretAccessKey: bucket.secretAccessKey,
174
+ }
175
+ : {}),
176
+ });
177
+ const file = client.file(`${bucket.prefix}/${WATCH_OBJECT}`);
178
+ return {
179
+ read: async () => ((await file.exists()) ? file.text() : null),
180
+ write: async (text) => {
181
+ await file.write(text, { type: 'application/json' });
182
+ },
183
+ };
184
+ }
185
+
186
+ /** The services of a real run, on the manager. */
187
+ export function watchServices(
188
+ status: () => Promise<NodeReport[]>
189
+ ): FleetWatchServices {
190
+ return {
191
+ notify: fleet.notify,
192
+ status,
193
+ store: bucketStore(connection().bucket),
194
+ secrets: secret,
195
+ send: fetch,
196
+ now: () => new Date(),
197
+ };
198
+ }
package/src/cli/fleet.ts CHANGED
@@ -14,6 +14,8 @@ import { addNode, nodeFromOptions, readRoster } from './roster';
14
14
  import { executeTailnet } from './tailnet';
15
15
  import { probeSource } from './probe-source';
16
16
  import { executeRepairCommand } from './repair';
17
+ import { scheduleCheck } from '../schedule-check';
18
+ import { readHistory } from '../state/history';
17
19
 
18
20
  export interface FleetServices {
19
21
  run: Runner;
@@ -125,6 +127,8 @@ async function diagnose(
125
127
  services.run
126
128
  );
127
129
  if (!options.local && settings.missingRoster) reports.push(missingRoster());
130
+ const schedule = scheduleCheck(readHistory(), new Date());
131
+ if (schedule) reports[0]?.checks.push(schedule);
128
132
  const report = {
129
133
  ...makeReport('doctor', reports),
130
134
  coverage: {
@@ -8,6 +8,9 @@ import { fleetSync, type FleetSyncOptions } from './fleet-sync';
8
8
  import { syncNode, type NodeSyncOptions } from './node-sync';
9
9
  import { fleetState, type FleetStateOptions } from './fleet-state';
10
10
  import { fleetAccounts, type FleetAccountsOptions } from './fleet-accounts';
11
+ import { fleetReport, type FleetReportOptions } from './fleet-report';
12
+ import { fleetWatch, watchServices } from './fleet-watch';
13
+ import type { NodeReport } from '../types';
11
14
 
12
15
  type Complete = (outcome: CommandOutcome) => void;
13
16
 
@@ -282,6 +285,52 @@ function registerState({ program, complete }: Registration) {
282
285
  });
283
286
  }
284
287
 
288
+ function registerReport({ program, complete }: Registration) {
289
+ program
290
+ .command('fleet-report')
291
+ .description(
292
+ 'Report how reliably fleet-sync ran: success rate, duration, failing checks and time not ready'
293
+ )
294
+ .option('--since <duration>', 'How far back: 30d (default), 12h or 90m')
295
+ .action((_options, command: Command) => {
296
+ complete(fleetReport(command.optsWithGlobals<FleetReportOptions>()));
297
+ });
298
+ }
299
+
300
+ function registerWatch({ program, defaults, complete }: Registration) {
301
+ program
302
+ .command('fleet-watch')
303
+ .description(
304
+ "Check the workers and notify the fleet's targets when one becomes unhealthy or recovers"
305
+ )
306
+ .option('--test', 'Send one test notification to every target and stop')
307
+ .addHelpText(
308
+ 'after',
309
+ '\nRuns fleet-status --workers, compares it with the previous run kept in the bucket and notifies only on a change (and again every repeatHours while a worker stays unhealthy). Targets come from "notify" in fleet.json; their addresses are secrets. Every run stores a heartbeat in the bucket for an off-machine check.'
310
+ )
311
+ .action(async (_options, command: Command) => {
312
+ const options = command.optsWithGlobals<
313
+ CliOptions & { test?: boolean }
314
+ >();
315
+ const settings = await withSettings(defaults, options);
316
+ const status = async (): Promise<NodeReport[]> => {
317
+ const report = await executeFleet(
318
+ 'health',
319
+ { ...options, workers: true, managed: true },
320
+ settings
321
+ );
322
+ const data = report.data as { nodes?: NodeReport[] };
323
+ return data.nodes ?? [];
324
+ };
325
+ complete(
326
+ await fleetWatch(
327
+ { ...(options.test ? { test: true } : {}) },
328
+ watchServices(status)
329
+ )
330
+ );
331
+ });
332
+ }
333
+
285
334
  export function registerFleet(
286
335
  program: Command,
287
336
  defaults: Settings,
@@ -296,6 +345,8 @@ export function registerFleet(
296
345
  );
297
346
  registerSync(registration);
298
347
  registerState(registration);
348
+ registerReport(registration);
349
+ registerWatch(registration);
299
350
  registerRepair(registration);
300
351
  registerNodes(registration);
301
352
  registerAccounts(registration);
@@ -50,6 +50,13 @@ export const COMMANDS: CommandSpec[] = [
50
50
  'Read-only canonical worker readiness, dependencies, services and authentication; lists every remaining gap.',
51
51
  ['workers', 'readiness', 'health', 'canonical'],
52
52
  ],
53
+ [
54
+ 'fleet-watch',
55
+ 'Notify when a worker becomes unhealthy or recovers',
56
+ 'Runs fleet-status --workers, compares it with the previous run kept in the bucket and notifies the targets fleet.json names under "notify" on a change only. Stores a heartbeat for an off-machine check. --test sends one test notification.',
57
+ ['notify', 'alert', 'webhook', 'ntfy', 'heartbeat', 'watch', 'monitor'],
58
+ true,
59
+ ],
53
60
  [
54
61
  'fleet-discover',
55
62
  'Discover Tailscale computers',
@@ -90,6 +97,12 @@ export const COMMANDS: CommandSpec[] = [
90
97
  ['state', 'bucket', 'restore', 'recover', 'disposable'],
91
98
  true,
92
99
  ],
100
+ [
101
+ 'fleet-report',
102
+ 'Report how reliably fleet-sync runs',
103
+ 'Read-only: success rate, mean duration, the most frequent failing check IDs and how long each machine was not ready, from the history of fleet-sync runs kept in the fleet state. --since 30d (default), 12h or 90m.',
104
+ ['history', 'reliability', 'report', 'failures', 'sync'],
105
+ ],
93
106
  [
94
107
  'fleet-node-sync',
95
108
  'Sync this node',
@@ -0,0 +1,36 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /** The most a scheduled job's log may hold; a longer one keeps its newest
5
+ * quarter.
6
+ */
7
+ export const SCHEDULE_LOG_BYTES = 1024 * 1024;
8
+
9
+ /** Caps the schedule's own logs (`<label>.out.log`, `<label>.err.log`) at the
10
+ * start of every scheduled run. launchd and systemd append to them, so the
11
+ * newest lines are what is kept. Nothing else in the directory is touched.
12
+ */
13
+ export function capScheduleLogs(
14
+ directory: string,
15
+ limit = SCHEDULE_LOG_BYTES
16
+ ): void {
17
+ let names: string[];
18
+ try {
19
+ names = readdirSync(directory);
20
+ } catch {
21
+ return;
22
+ }
23
+ for (const name of names) {
24
+ if (!/^[a-z0-9-]+\.(out|err)\.log$/.test(name)) continue;
25
+ const path = join(directory, name);
26
+ try {
27
+ const stat = statSync(path);
28
+ if (!stat.isFile() || stat.size <= limit) continue;
29
+ const kept = readFileSync(path).subarray(stat.size - limit / 4);
30
+ const start = kept.indexOf(10) + 1;
31
+ writeFileSync(path, kept.subarray(start), { mode: 0o600 });
32
+ } catch {
33
+ // A log that cannot be capped is capped by the next run.
34
+ }
35
+ }
36
+ }
package/src/clock.ts ADDED
@@ -0,0 +1,63 @@
1
+ import policy from '../ansible/ubuntu/policy.json';
2
+ import type { HealthSample } from './machine-health';
3
+ import type { Check } from './types';
4
+
5
+ const { warnOffsetSeconds, failOffsetSeconds } = policy.clock;
6
+
7
+ /** A skewed clock breaks TLS, token validation, apt signatures and Vault
8
+ * logins with errors that never mention time. The clock passes when it is
9
+ * synchronised to a time source and, where a reference time was measured,
10
+ * within the offset floors of `policy.json`.
11
+ *
12
+ * `offsetSeconds` is the difference to the reference (the manager's clock),
13
+ * when one was measured.
14
+ */
15
+ export function clockCheck(
16
+ id: string,
17
+ sample: Pick<HealthSample, 'clockSynced' | 'timeService'>,
18
+ offsetSeconds?: number
19
+ ): Check {
20
+ const offset =
21
+ offsetSeconds === undefined ? undefined : Math.abs(offsetSeconds);
22
+ const measured =
23
+ offset === undefined ? '' : `, ${offset.toFixed(1)} s from the manager`;
24
+ const remedy = clockRemedy(sample.timeService);
25
+ if (sample.clockSynced === undefined && offset === undefined)
26
+ return {
27
+ id,
28
+ status: 'skip',
29
+ summary: 'CLOCK_UNOBSERVED: the clock synchronisation could not be read.',
30
+ };
31
+ if (offset !== undefined && offset > failOffsetSeconds)
32
+ return { id, status: 'fail', summary: `CLOCK_SKEWED${measured}`, remedy };
33
+ if (sample.clockSynced === 0 && sample.timeService === 0)
34
+ return {
35
+ id,
36
+ status: 'fail',
37
+ summary: `CLOCK_NOT_SYNCHRONISED: no time service is enabled${measured}`,
38
+ remedy,
39
+ };
40
+ if (sample.clockSynced === 0)
41
+ return {
42
+ id,
43
+ status: 'warn',
44
+ summary: `CLOCK_NOT_SYNCHRONISED${measured}`,
45
+ remedy,
46
+ };
47
+ if (offset !== undefined && offset > warnOffsetSeconds)
48
+ return { id, status: 'warn', summary: `CLOCK_DRIFTING${measured}`, remedy };
49
+ return {
50
+ id,
51
+ status: 'pass',
52
+ summary: `The clock is synchronised to a time source${measured}.`,
53
+ };
54
+ }
55
+
56
+ function clockRemedy(timeService: number | undefined): { action: string } {
57
+ return {
58
+ action:
59
+ timeService === 0
60
+ ? 'Enable the time service: apply the approved fleet-sync revision, which keeps chrony or systemd-timesyncd enabled on workers (timedatectl set-ntp true is the same setting).'
61
+ : 'Check that the time service reaches a time server (timedatectl timesync-status) and that UDP port 123 is not blocked.',
62
+ };
63
+ }
package/src/definition.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { parseWorkerPolicy, type WorkerPolicy } from './worker-policy';
1
+ import {
2
+ parseWorkerPolicy,
3
+ toolProfiles,
4
+ type WorkerPolicy,
5
+ } from './worker-policy';
6
+ import type { ToolProfile } from '../bootstrap/worker-tools';
2
7
  import { readFileSync } from 'node:fs';
3
8
  import { FleetUsageError } from './errors';
4
9
  import { parseConfig, record } from './config';
@@ -11,11 +16,14 @@ import {
11
16
  type IdeId,
12
17
  type T3Definition,
13
18
  } from './ide/definition';
19
+ import { parseNotify, type NotifyConfig } from './notify';
14
20
 
15
21
  export interface MachineDefinition {
16
22
  id: string;
17
23
  ssh: string;
18
24
  os: 'linux' | 'macOS' | 'windows';
25
+ /** Tool profiles this worker adds to the fleet-wide ones. */
26
+ toolProfiles?: ToolProfile[];
19
27
  network?: {
20
28
  tailscaleName: string;
21
29
  user: string;
@@ -55,6 +63,8 @@ export interface FleetDefinition {
55
63
  managerAccount: string;
56
64
  /** Read-only Beszel account visitors are signed in as. */
57
65
  viewerAccount: string;
66
+ /** Scheduled off-machine backups fleet-sync sets on the hub. */
67
+ backups?: HubBackups;
58
68
  };
59
69
  };
60
70
  worker?: WorkerPolicy;
@@ -62,6 +72,12 @@ export interface FleetDefinition {
62
72
  ides?: IdeId[];
63
73
  /** What every machine's T3 holds beyond its provider instances. */
64
74
  t3?: T3Definition;
75
+ /** The Git identity and GitHub credential helper every node, manager
76
+ * included, commits and pushes with.
77
+ */
78
+ git?: GitIdentity;
79
+ /** Where fleet-watch sends notifications; absent, nobody is notified. */
80
+ notify?: NotifyConfig;
65
81
  ubuntu: { release: '26.04'; architectures: ['amd64', 'arm64'] };
66
82
  }
67
83
  /** How provider accounts are watched and chosen (src/accounts/). */
@@ -80,6 +96,29 @@ export interface AccountSelection {
80
96
  */
81
97
  order?: string[];
82
98
  }
99
+ export interface HubBackups {
100
+ /** Cron expression of the hub's backup schedule. */
101
+ schedule: string;
102
+ /** How many scheduled backups the hub keeps. */
103
+ keep: number;
104
+ /** S3-compatible storage off the manager; its keys are secrets. */
105
+ storage: {
106
+ bucket: string;
107
+ endpoint: string;
108
+ region: string;
109
+ forcePathStyle?: boolean;
110
+ };
111
+ }
112
+ export interface GitIdentity {
113
+ /** `user.name` of every commit. */
114
+ name: string;
115
+ /** `user.email` of every commit. */
116
+ email: string;
117
+ /** A repository each node must reach through git with its own
118
+ * credentials, proving it can push (`worker:git-push-auth`).
119
+ */
120
+ verifyRepository?: string;
121
+ }
83
122
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
84
123
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
85
124
  function fail(): never {
@@ -116,7 +155,10 @@ function validPort(value: unknown): boolean {
116
155
  );
117
156
  }
118
157
  function worker(value: unknown): MachineDefinition {
119
- if (!record(value) || !keys(value, ['id', 'ssh', 'os', 'network']))
158
+ if (
159
+ !record(value) ||
160
+ !keys(value, ['id', 'ssh', 'os', 'network', 'toolProfiles'])
161
+ )
120
162
  return fail();
121
163
  if (typeof value['id'] !== 'string' || !ID.test(value['id'])) return fail();
122
164
  if (typeof value['ssh'] !== 'string' || !HOST.test(value['ssh']))
@@ -128,6 +170,9 @@ function worker(value: unknown): MachineDefinition {
128
170
  ssh: value['ssh'],
129
171
  os: 'linux',
130
172
  ...(value['network'] ? { network: network(value['network']) } : {}),
173
+ ...(value['toolProfiles'] === undefined
174
+ ? {}
175
+ : { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
131
176
  };
132
177
  }
133
178
  function manager(value: unknown): ManagerDefinition {
@@ -176,6 +221,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
176
221
  'worker',
177
222
  'ides',
178
223
  't3',
224
+ 'git',
225
+ 'notify',
179
226
  ])
180
227
  )
181
228
  return fail();
@@ -194,12 +241,25 @@ export function parseDefinition(value: unknown): FleetDefinition {
194
241
  workers,
195
242
  services: services(value['services']),
196
243
  ubuntu: baseline(value['ubuntu']),
244
+ ...optionalSections(value),
245
+ };
246
+ }
247
+ /** The sections a fleet may leave out, kept only when named: an earlier
248
+ * revision must still read the copy.
249
+ */
250
+ function optionalSections(
251
+ value: Record<string, unknown>
252
+ ): Pick<FleetDefinition, 'worker' | 'ides' | 't3' | 'git' | 'notify'> {
253
+ return {
197
254
  ...(value['worker'] === undefined
198
255
  ? {}
199
256
  : { worker: parseWorkerPolicy(value['worker']) }),
200
- // Kept only when named: an earlier revision must still read the copy.
201
257
  ...(value['ides'] === undefined ? {} : { ides: parseIdes(value['ides']) }),
202
258
  ...(value['t3'] === undefined ? {} : { t3: parseT3(value['t3']) }),
259
+ ...(value['git'] === undefined ? {} : { git: gitIdentity(value['git']) }),
260
+ ...(value['notify'] === undefined
261
+ ? {}
262
+ : { notify: parseNotify(value['notify']) ?? fail() }),
203
263
  };
204
264
  }
205
265
  /** Singleton services name no host: they always run on the manager. */
@@ -271,19 +331,96 @@ function accountSelection(entry: unknown): AccountSelection {
271
331
  function monitoringService(
272
332
  value: unknown
273
333
  ): FleetDefinition['services']['monitoring'] {
274
- const fields = ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount'];
334
+ const fields = [
335
+ 'hubPort',
336
+ 'publicUrl',
337
+ 'managerAccount',
338
+ 'viewerAccount',
339
+ 'backups',
340
+ ];
275
341
  if (!record(value) || !keys(value, fields) || value['hubPort'] !== 8443)
276
342
  return fail();
277
343
  const { publicUrl, managerAccount, viewerAccount } = value;
278
344
  if (!origin(publicUrl) || !account(managerAccount) || !account(viewerAccount))
279
345
  return fail();
280
- return { hubPort: 8443, publicUrl, managerAccount, viewerAccount };
346
+ return {
347
+ hubPort: 8443,
348
+ publicUrl,
349
+ managerAccount,
350
+ viewerAccount,
351
+ ...(value['backups'] === undefined
352
+ ? {}
353
+ : { backups: hubBackups(value['backups']) }),
354
+ };
355
+ }
356
+ const text = (value: unknown, pattern: RegExp): value is string =>
357
+ typeof value === 'string' && pattern.test(value);
358
+ function hubBackups(value: unknown): HubBackups {
359
+ if (!record(value) || !keys(value, ['schedule', 'keep', 'storage']))
360
+ return fail();
361
+ const { schedule, keep } = value;
362
+ if (
363
+ !text(schedule, /^[0-9*/,-]+( [0-9*/,-]+){4}$/) ||
364
+ !Number.isInteger(keep) ||
365
+ Number(keep) < 1 ||
366
+ Number(keep) > 365
367
+ )
368
+ return fail();
369
+ return {
370
+ schedule,
371
+ keep: Number(keep),
372
+ storage: backupStorage(value['storage']),
373
+ };
374
+ }
375
+ function backupStorage(value: unknown): HubBackups['storage'] {
376
+ if (
377
+ !record(value) ||
378
+ !keys(value, ['bucket', 'endpoint', 'region', 'forcePathStyle'])
379
+ )
380
+ return fail();
381
+ const { bucket, endpoint, region, forcePathStyle } = value;
382
+ if (
383
+ !text(bucket, /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/) ||
384
+ !text(endpoint, /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/) ||
385
+ !text(region, /^[a-z0-9-]{1,32}$/) ||
386
+ (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
387
+ )
388
+ return fail();
389
+ return {
390
+ bucket,
391
+ endpoint,
392
+ region,
393
+ ...(forcePathStyle === undefined ? {} : { forcePathStyle }),
394
+ };
281
395
  }
282
396
  const origin = (value: unknown): value is string =>
283
397
  typeof value === 'string' && /^https:\/\/[A-Za-z0-9.-]{1,253}$/.test(value);
284
398
  const account = (value: unknown): value is string =>
285
399
  typeof value === 'string' &&
286
400
  /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/.test(value);
401
+ function gitIdentity(value: unknown): GitIdentity {
402
+ if (!record(value) || !keys(value, ['name', 'email', 'verifyRepository']))
403
+ return fail();
404
+ const { name, email, verifyRepository } = value;
405
+ // Written into a quoted Git configuration value: no quote, backslash or
406
+ // line break.
407
+ if (typeof name !== 'string' || !/^[^"\\\n\r]{1,100}$/.test(name))
408
+ return fail();
409
+ if (!account(email)) return fail();
410
+ if (
411
+ verifyRepository !== undefined &&
412
+ (typeof verifyRepository !== 'string' ||
413
+ !/^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/.test(
414
+ verifyRepository
415
+ ))
416
+ )
417
+ return fail();
418
+ return {
419
+ name,
420
+ email,
421
+ ...(verifyRepository === undefined ? {} : { verifyRepository }),
422
+ };
423
+ }
287
424
  function secrets(value: unknown): SecretsConfig {
288
425
  return parseSecretsConfig(value) ?? fail();
289
426
  }