@crvouga/dev-fleet 0.7.0 → 0.9.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 (206) hide show
  1. package/README.md +88 -7
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. package/src/cli/schedule-logs.ts +0 -36
package/src/definition.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import {
2
2
  parseWorkerPolicy,
3
+ policyFault,
3
4
  toolProfiles,
4
5
  type WorkerPolicy,
5
6
  } from './worker-policy';
6
7
  import type { ToolProfile } from '../bootstrap/worker-tools';
8
+ import {
9
+ featuresFault,
10
+ type Preset,
11
+ type Session,
12
+ } from '../bootstrap/worker-features';
7
13
  import { readFileSync } from 'node:fs';
8
14
  import { FleetUsageError } from './errors';
9
15
  import { parseConfig, record } from './config';
@@ -17,21 +23,86 @@ import {
17
23
  type T3Definition,
18
24
  } from './ide/definition';
19
25
  import { parseNotify, type NotifyConfig } from './notify';
20
- import { ubuntuRelease } from './platform/ubuntu-release';
26
+ import { UBUNTU_RELEASES, ubuntuRelease } from './platform/ubuntu-release';
21
27
  import { parseGithubPolicy, type GithubPolicy } from './github/policy';
28
+ import {
29
+ EXPOSURE_EXPECTED,
30
+ parseExposure,
31
+ type ExposureDeclaration,
32
+ } from './exposure';
33
+ import { parseTailnetConfig, type TailnetConfig } from './tailnet-control';
22
34
 
23
35
  export interface MachineDefinition {
24
36
  id: string;
25
- ssh: string;
37
+ /** A Host alias in the operator's own SSH configuration: an optional
38
+ * override. Without one the worker is reached from `network` alone, with
39
+ * its host key pinned in the fleet state (src/ssh-connection.ts).
40
+ */
41
+ ssh?: string;
26
42
  os: 'linux';
27
43
  /** Tool profiles this worker adds to the fleet-wide ones. */
28
44
  toolProfiles?: ToolProfile[];
45
+ /** This worker's exposure, over the fleet-wide one (src/exposure.ts). */
46
+ exposure?: ExposureDeclaration;
47
+ /** This worker's features, over the fleet-wide `worker.features`. */
48
+ features?: FeaturesDefinition;
49
+ /** Who restarts this machine after a power loss when it is a virtual
50
+ * machine, such as "provider restarts instances"; shown with
51
+ * worker:power-loss-recovery, which does not apply to one. */
52
+ powerRecoveryNote?: string;
29
53
  network?: {
30
54
  tailscaleName: string;
31
55
  user: string;
32
56
  sshPort: number;
33
57
  t3Port: number;
58
+ /** The SSH host public key, `TYPE BASE64`, given as data (a worker
59
+ * record): the pinned key, ahead of one pinned in the fleet state.
60
+ */
61
+ hostKey?: string;
34
62
  };
63
+ /** Free-form labels from a worker record; only displayed. */
64
+ labels?: Record<string, string>;
65
+ /** What the worker costs while it runs; only displayed and summed. */
66
+ billing?: WorkerBilling;
67
+ /** `records`: read from a file or command `workerRecords` names, so it is
68
+ * retired by itself when its record disappears.
69
+ */
70
+ origin?: 'records';
71
+ }
72
+ export interface WorkerBilling {
73
+ /** The declared cost of one hour of running. No pricing API is asked. */
74
+ hourlyRate: number;
75
+ /** ISO 4217 code, such as USD. */
76
+ currency?: string;
77
+ note?: string;
78
+ }
79
+ /** Workers that are stopped on purpose, and when an idle one is reported. */
80
+ export interface ParkingPolicy {
81
+ /** A worker with no agent activity for this long is `worker:idle`. */
82
+ idleAfterHours?: number;
83
+ /** fleet-watch parks a worker idle longer than idleAfterHours. Off unless
84
+ * true.
85
+ */
86
+ autoPark?: boolean;
87
+ /** Run with the worker ID as the last argument when a worker is parked
88
+ * (stop) or resumed (start): an infrastructure tool, never a provider API
89
+ * dev-fleet calls itself.
90
+ */
91
+ stop?: string[];
92
+ start?: string[];
93
+ }
94
+
95
+ /** The name SSH connects to: the alias, or the declared tailnet name. */
96
+ export function sshTarget(worker: MachineDefinition): string {
97
+ return worker.ssh ?? worker.network!.tailscaleName;
98
+ }
99
+ /** Declared worker features (bootstrap/worker-features.ts): a preset, then
100
+ * single features over it. Absent, a worker has containers and a desktop
101
+ * session, as every worker had before features existed. */
102
+ export interface FeaturesDefinition {
103
+ preset?: Preset;
104
+ containers?: boolean;
105
+ session?: Session;
35
106
  }
36
107
  /** The singleton manager: the human's machine. It runs fleet-sync and hosts
37
108
  * every singleton service; it is never converged over SSH. */
@@ -40,6 +111,8 @@ export interface ManagerDefinition {
40
111
  os: 'macOS';
41
112
  network: { tailscaleName: string; user: string };
42
113
  neverSleepOnPower: true;
114
+ /** How often its scheduled jobs run, when not the policy's default. */
115
+ schedule?: { syncMinutes?: number; watchMinutes?: number };
43
116
  }
44
117
  export interface FleetDefinition {
45
118
  version: 3;
@@ -54,6 +127,11 @@ export interface FleetDefinition {
54
127
  manager: ManagerDefinition;
55
128
  /** Stateless, replaceable Linux boxes; clients of the manager's services. */
56
129
  workers: MachineDefinition[];
130
+ /** The worker that proves a revision new to the fleet before the others
131
+ * receive it, when it is reachable. Without one, the first reachable
132
+ * worker in the order above.
133
+ */
134
+ canary?: string;
57
135
  services: {
58
136
  /** Public HTTPS origin of the CLI proxy every node uses. */
59
137
  proxy: { endpoint: string; accounts?: AccountPolicy };
@@ -85,6 +163,12 @@ export interface FleetDefinition {
85
163
  ubuntu: { release: string; architectures: string[] };
86
164
  /** How every node signs in to GitHub; defaults when absent. */
87
165
  github?: GithubPolicy;
166
+ /** The tailnet control plane new workers are enrolled with, unattended.
167
+ * Absent, a worker joins the tailnet through its own login.
168
+ */
169
+ tailnet?: TailnetConfig;
170
+ /** Parking and idle reporting (docs/topology.md). */
171
+ parking?: ParkingPolicy;
88
172
  }
89
173
  /** How provider accounts are watched and chosen (src/accounts/). */
90
174
  export interface AccountPolicy {
@@ -124,97 +208,383 @@ export interface GitIdentity {
124
208
  * credentials, proving it can push (`worker:git-push-auth`).
125
209
  */
126
210
  verifyRepository?: string;
211
+ /** Sign every commit and tag. `ssh`: each node signs with a key of its own,
212
+ * made on the node by `ansible/github/git.yml`; the private key never
213
+ * leaves it (`worker:git-signing`).
214
+ */
215
+ signing?: 'ssh';
127
216
  }
128
217
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
129
218
  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
- );
219
+ const USER = /^[a-z_][a-z0-9_-]{0,31}$/;
220
+ const MANAGER_USER = /^[a-z_][a-z0-9_.-]{0,31}$/;
221
+
222
+ /** What is wrong with a fleet definition: the first rule it breaks, where. */
223
+ export interface DefinitionProblem {
224
+ /** Stable: DEFINITION_INVALID, DEFINITION_MISSING, DEFINITION_UNKNOWN,
225
+ * DEFINITION_DUPLICATE or DEFINITION_UNREADABLE. */
226
+ code: string;
227
+ /** A JSONPath into fleet.json, such as `$.workers[1].network.sshPort`. */
228
+ path: string;
229
+ expected: string;
230
+ /** The kind of value found, never more than a short excerpt of it. */
231
+ actual: string;
134
232
  }
135
- function keys(value: Record<string, unknown>, allowed: string[]): boolean {
136
- return Object.keys(value).every((key) => allowed.includes(key));
233
+
234
+ export class DefinitionError extends FleetUsageError {
235
+ constructor(readonly problem: DefinitionProblem) {
236
+ super(
237
+ `Invalid fleet definition at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`
238
+ );
239
+ }
137
240
  }
138
- function network(value: unknown): NonNullable<MachineDefinition['network']> {
139
- if (
140
- !record(value) ||
141
- !keys(value, ['tailscaleName', 'user', 'sshPort', 't3Port'])
142
- )
143
- return fail();
241
+
242
+ /** A short description of a value, for a person to recognise it. */
243
+ function describe(value: unknown): string {
244
+ if (value === undefined) return 'nothing';
245
+ if (value === null) return 'null';
246
+ if (Array.isArray(value)) return `an array of ${value.length}`;
247
+ if (typeof value === 'object') return 'an object';
248
+ const text = JSON.stringify(value);
249
+ return text.length > 64 ? `${text.slice(0, 61)}...` : text;
250
+ }
251
+
252
+ function fail(
253
+ path: string,
254
+ expected: string,
255
+ actual: unknown,
256
+ code = actual === undefined ? 'DEFINITION_MISSING' : 'DEFINITION_INVALID'
257
+ ): never {
258
+ throw new DefinitionError({
259
+ code,
260
+ path,
261
+ expected,
262
+ actual: describe(actual),
263
+ });
264
+ }
265
+
266
+ function object(value: unknown, path: string): Record<string, unknown> {
267
+ if (!record(value)) return fail(path, 'an object', value);
268
+ return value;
269
+ }
270
+
271
+ function keys(value: Record<string, unknown>, allowed: string[], path: string) {
272
+ const unknown = Object.keys(value).find((key) => !allowed.includes(key));
273
+ if (unknown !== undefined)
274
+ fail(
275
+ `${path}.${unknown}`,
276
+ `one of ${allowed.filter((key) => key !== '$schema').join(', ')}`,
277
+ value[unknown],
278
+ 'DEFINITION_UNKNOWN'
279
+ );
280
+ }
281
+
282
+ function text(
283
+ value: unknown,
284
+ pattern: RegExp,
285
+ path: string,
286
+ expected: string
287
+ ): string {
288
+ if (typeof value !== 'string' || !pattern.test(value))
289
+ return fail(path, expected, value);
290
+ return value;
291
+ }
292
+
293
+ function constant<T>(value: unknown, wanted: T, path: string): T {
294
+ if (JSON.stringify(value) !== JSON.stringify(wanted))
295
+ return fail(path, JSON.stringify(wanted), value);
296
+ return wanted;
297
+ }
298
+
299
+ function network(
300
+ value: unknown,
301
+ path: string
302
+ ): NonNullable<MachineDefinition['network']> {
303
+ const fields = object(value, path);
304
+ keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port', 'hostKey'], path);
305
+ return {
306
+ tailscaleName: text(
307
+ fields['tailscaleName'],
308
+ HOST,
309
+ `${path}.tailscaleName`,
310
+ 'a host name'
311
+ ),
312
+ user: text(fields['user'], USER, `${path}.user`, 'a login name'),
313
+ sshPort: port(fields['sshPort'], `${path}.sshPort`),
314
+ t3Port: port(fields['t3Port'], `${path}.t3Port`),
315
+ ...(fields['hostKey'] === undefined
316
+ ? {}
317
+ : {
318
+ hostKey: text(
319
+ fields['hostKey'],
320
+ HOST_KEY,
321
+ `${path}.hostKey`,
322
+ 'one SSH public key, "TYPE BASE64"'
323
+ ),
324
+ }),
325
+ };
326
+ }
327
+ /** One OpenSSH host public key without a comment. */
328
+ export const HOST_KEY =
329
+ /^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$/;
330
+ const LABEL = /^[a-z][a-z0-9_.-]{0,31}$/;
331
+ function labels(value: unknown, path: string): Record<string, string> {
332
+ const fields = object(value, path);
333
+ const entries = Object.entries(fields);
334
+ if (entries.length > 32) fail(path, 'at most 32 labels', value);
335
+ for (const [name, label] of entries) {
336
+ if (!LABEL.test(name))
337
+ fail(`${path}.${name}`, 'a lowercase label name', name);
338
+ text(label, /^[^\x00-\x1f\x7f]{0,128}$/, `${path}.${name}`, 'a short text');
339
+ }
340
+ return fields as Record<string, string>;
341
+ }
342
+ function billing(value: unknown, path: string): WorkerBilling {
343
+ const fields = object(value, path);
344
+ keys(fields, ['hourlyRate', 'currency', 'note'], path);
345
+ const rate = fields['hourlyRate'];
346
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate < 0)
347
+ fail(`${path}.hourlyRate`, 'a number of at least 0', rate);
348
+ return {
349
+ hourlyRate: rate as number,
350
+ ...(fields['currency'] === undefined
351
+ ? {}
352
+ : {
353
+ currency: text(
354
+ fields['currency'],
355
+ /^[A-Z]{3}$/,
356
+ `${path}.currency`,
357
+ 'a three-letter currency code'
358
+ ),
359
+ }),
360
+ ...(fields['note'] === undefined
361
+ ? {}
362
+ : {
363
+ note: text(
364
+ fields['note'],
365
+ /^[^\x00-\x1f\x7f]{1,200}$/,
366
+ `${path}.note`,
367
+ 'a short text'
368
+ ),
369
+ }),
370
+ };
371
+ }
372
+ function argv(value: unknown, path: string): string[] {
144
373
  if (
145
- typeof value['tailscaleName'] !== 'string' ||
146
- !HOST.test(value['tailscaleName'])
374
+ !Array.isArray(value) ||
375
+ value.length < 1 ||
376
+ value.length > 64 ||
377
+ !value.every(
378
+ (entry) =>
379
+ typeof entry === 'string' && /^[^\x00-\x1f\x7f]{1,1024}$/.test(entry)
380
+ )
147
381
  )
148
- return fail();
382
+ return fail(path, 'a command as a list of arguments', value);
383
+ return value as string[];
384
+ }
385
+ function idleHours(
386
+ fields: Record<string, unknown>,
387
+ path: string
388
+ ): number | undefined {
389
+ const hours = fields['idleAfterHours'];
149
390
  if (
150
- typeof value['user'] !== 'string' ||
151
- !/^[a-z_][a-z0-9_-]{0,31}$/.test(value['user'])
391
+ hours !== undefined &&
392
+ (typeof hours !== 'number' || !(hours >= 1) || hours > 8760)
152
393
  )
153
- return fail();
154
- for (const port of ['sshPort', 't3Port'])
155
- if (!validPort(value[port])) return fail();
156
- return value as NonNullable<MachineDefinition['network']>;
394
+ fail(`${path}.idleAfterHours`, 'a number of hours from 1 to 8760', hours);
395
+ const autoPark = fields['autoPark'];
396
+ if (autoPark !== undefined && typeof autoPark !== 'boolean')
397
+ fail(`${path}.autoPark`, 'true or false', autoPark);
398
+ if (autoPark === true && hours === undefined)
399
+ fail(
400
+ `${path}.idleAfterHours`,
401
+ 'a number of hours, since autoPark is on',
402
+ hours
403
+ );
404
+ return hours as number | undefined;
157
405
  }
158
- function validPort(value: unknown): boolean {
159
- return (
160
- Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65535
161
- );
406
+ function parking(value: unknown): ParkingPolicy {
407
+ const path = '$.parking';
408
+ const fields = object(value, path);
409
+ keys(fields, ['idleAfterHours', 'autoPark', 'stop', 'start'], path);
410
+ const hours = idleHours(fields, path);
411
+ return {
412
+ ...(hours === undefined ? {} : { idleAfterHours: hours }),
413
+ ...(fields['autoPark'] === undefined
414
+ ? {}
415
+ : { autoPark: fields['autoPark'] as boolean }),
416
+ ...(fields['stop'] === undefined
417
+ ? {}
418
+ : { stop: argv(fields['stop'], `${path}.stop`) }),
419
+ ...(fields['start'] === undefined
420
+ ? {}
421
+ : { start: argv(fields['start'], `${path}.start`) }),
422
+ };
162
423
  }
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();
424
+ function port(value: unknown, path: string): number {
425
+ if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > 65535)
426
+ return fail(path, 'a port from 1 to 65535', value);
427
+ return value as number;
428
+ }
429
+ function worker(value: unknown, path: string): MachineDefinition {
430
+ const fields = object(value, path);
431
+ keys(
432
+ fields,
433
+ [
434
+ 'id',
435
+ 'ssh',
436
+ 'os',
437
+ 'network',
438
+ 'toolProfiles',
439
+ 'exposure',
440
+ 'features',
441
+ 'powerRecoveryNote',
442
+ 'labels',
443
+ 'billing',
444
+ 'origin',
445
+ ],
446
+ path
447
+ );
448
+ const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
449
+ // Without an alias the worker is reached from its network block.
450
+ const ssh =
451
+ fields['ssh'] === undefined && fields['network'] !== undefined
452
+ ? undefined
453
+ : text(
454
+ fields['ssh'],
455
+ HOST,
456
+ `${path}.ssh`,
457
+ 'an SSH host alias, or a network block to connect from'
458
+ );
172
459
  // Workers are the Linux boxes; any other platform is not a worker.
173
- if (value['os'] !== 'linux') return fail();
460
+ constant(fields['os'], 'linux', `${path}.os`);
174
461
  return {
175
- id: value['id'],
176
- ssh: value['ssh'],
462
+ id,
463
+ ...(ssh === undefined ? {} : { ssh }),
177
464
  os: 'linux',
178
- ...(value['network'] ? { network: network(value['network']) } : {}),
179
- ...(value['toolProfiles'] === undefined
465
+ ...(fields['network']
466
+ ? { network: network(fields['network'], `${path}.network`) }
467
+ : {}),
468
+ ...described(fields, path),
469
+ ...(fields['toolProfiles'] === undefined
470
+ ? {}
471
+ : {
472
+ toolProfiles:
473
+ toolProfiles(fields['toolProfiles']) ??
474
+ fail(
475
+ `${path}.toolProfiles`,
476
+ 'a list of known tool profiles, each once',
477
+ fields['toolProfiles']
478
+ ),
479
+ }),
480
+ ...(fields['exposure'] === undefined
180
481
  ? {}
181
- : { toolProfiles: toolProfiles(value['toolProfiles']) ?? fail() }),
482
+ : {
483
+ exposure:
484
+ parseExposure(fields['exposure']) ??
485
+ fail(`${path}.exposure`, EXPOSURE_EXPECTED, fields['exposure']),
486
+ }),
487
+ ...(fields['features'] === undefined
488
+ ? {}
489
+ : { features: features(fields['features'], `${path}.features`) }),
490
+ ...(fields['powerRecoveryNote'] === undefined
491
+ ? {}
492
+ : {
493
+ powerRecoveryNote: text(
494
+ fields['powerRecoveryNote'],
495
+ /^[^\n\r]{1,120}$/,
496
+ `${path}.powerRecoveryNote`,
497
+ 'one line of at most 120 characters'
498
+ ),
499
+ }),
182
500
  };
183
501
  }
502
+ /** What a worker record says about a worker beside how to reach it: its
503
+ * labels, what it costs and that it came from a record.
504
+ */
505
+ function described(
506
+ fields: Record<string, unknown>,
507
+ path: string
508
+ ): Pick<MachineDefinition, 'labels' | 'billing' | 'origin'> {
509
+ return {
510
+ ...(fields['labels'] === undefined
511
+ ? {}
512
+ : { labels: labels(fields['labels'], `${path}.labels`) }),
513
+ ...(fields['billing'] === undefined
514
+ ? {}
515
+ : { billing: billing(fields['billing'], `${path}.billing`) }),
516
+ ...(fields['origin'] === undefined
517
+ ? {}
518
+ : { origin: constant(fields['origin'], 'records', `${path}.origin`) }),
519
+ };
520
+ }
521
+ /** A `features` block: fleet-wide under `worker`, or one worker's own. */
522
+ export function features(value: unknown, path: string): FeaturesDefinition {
523
+ const fields = object(value, path);
524
+ keys(fields, ['preset', 'containers', 'session'], path);
525
+ const fault = featuresFault(fields);
526
+ if (fault !== null)
527
+ fail(`${path}.${fault.field}`, fault.expected, fields[fault.field]);
528
+ return fields as FeaturesDefinition;
529
+ }
184
530
  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();
531
+ const path = '$.manager';
532
+ const fields = object(value, path);
533
+ keys(fields, ['id', 'os', 'network', 'neverSleepOnPower', 'schedule'], path);
534
+ const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
535
+ // The manager must be the Mac: a Linux machine is a worker.
536
+ constant(fields['os'], 'macOS', `${path}.os`);
537
+ const net = object(fields['network'], `${path}.network`);
538
+ keys(net, ['tailscaleName', 'user'], `${path}.network`);
539
+ const tailscaleName = text(
540
+ net['tailscaleName'],
541
+ HOST,
542
+ `${path}.network.tailscaleName`,
543
+ 'a host name'
544
+ );
545
+ const user = text(
546
+ net['user'],
547
+ MANAGER_USER,
548
+ `${path}.network.user`,
549
+ 'a login name'
550
+ );
551
+ constant(fields['neverSleepOnPower'], true, `${path}.neverSleepOnPower`);
194
552
  return {
195
- id: value['id'],
553
+ id,
196
554
  os: 'macOS',
197
- network: managerNetwork(value['network']),
555
+ network: { tailscaleName, user },
198
556
  neverSleepOnPower: true,
557
+ ...(fields['schedule'] === undefined
558
+ ? {}
559
+ : { schedule: schedule(fields['schedule'], `${path}.schedule`) }),
199
560
  };
200
561
  }
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'] };
562
+
563
+ function schedule(
564
+ value: unknown,
565
+ path: string
566
+ ): NonNullable<ManagerDefinition['schedule']> {
567
+ const fields = object(value, path);
568
+ keys(fields, ['syncMinutes', 'watchMinutes'], path);
569
+ const result: NonNullable<ManagerDefinition['schedule']> = {};
570
+ for (const key of ['syncMinutes', 'watchMinutes'] as const) {
571
+ const minutes = fields[key];
572
+ if (minutes === undefined) continue;
573
+ if (
574
+ !Number.isInteger(minutes) ||
575
+ Number(minutes) < 5 ||
576
+ Number(minutes) > 1440
577
+ )
578
+ fail(`${path}.${key}`, 'whole minutes from 5 to 1440', minutes);
579
+ result[key] = minutes as number;
580
+ }
581
+ return result;
212
582
  }
213
583
  export function parseDefinition(value: unknown): FleetDefinition {
214
- if (
215
- !record(value) ||
216
- value['version'] !== 3 ||
217
- !keys(value, [
584
+ const fields = object(value, '$');
585
+ keys(
586
+ fields,
587
+ [
218
588
  '$schema',
219
589
  'version',
220
590
  'source',
@@ -222,6 +592,7 @@ export function parseDefinition(value: unknown): FleetDefinition {
222
592
  'state',
223
593
  'manager',
224
594
  'workers',
595
+ 'canary',
225
596
  'services',
226
597
  'ubuntu',
227
598
  'worker',
@@ -230,216 +601,352 @@ export function parseDefinition(value: unknown): FleetDefinition {
230
601
  'git',
231
602
  'notify',
232
603
  '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();
604
+ 'tailnet',
605
+ 'parking',
606
+ ],
607
+ '$'
608
+ );
609
+ constant(fields['version'], 3, '$.version');
610
+ const list = fields['workers'];
611
+ if (!Array.isArray(list) || list.length > 64)
612
+ return fail('$.workers', 'an array of at most 64 workers', list);
613
+ const lead = manager(fields['manager']);
614
+ const workers = list.map((entry, index) =>
615
+ worker(entry, `$.workers[${index}]`)
616
+ );
617
+ const seen = new Set([lead.id]);
618
+ workers.forEach((entry, index) => {
619
+ if (seen.has(entry.id))
620
+ fail(
621
+ `$.workers[${index}].id`,
622
+ 'an id no other machine has',
623
+ entry.id,
624
+ 'DEFINITION_DUPLICATE'
625
+ );
626
+ seen.add(entry.id);
627
+ });
242
628
  return {
243
629
  version: 3,
244
- source: source(value['source']),
245
- secrets: secrets(value['secrets']),
246
- ...(value['state'] === undefined ? {} : { state: state(value['state']) }),
630
+ source: source(fields['source']),
631
+ secrets: secrets(fields['secrets']),
632
+ ...(fields['state'] === undefined ? {} : { state: state(fields['state']) }),
247
633
  manager: lead,
248
634
  workers,
249
- services: services(value['services']),
250
- ubuntu: baseline(value['ubuntu']),
251
- ...optionalSections(value),
635
+ ...(fields['canary'] === undefined
636
+ ? {}
637
+ : { canary: canary(fields['canary'], workers) }),
638
+ services: services(fields['services']),
639
+ ubuntu: baseline(fields['ubuntu']),
640
+ ...(fields['worker'] === undefined
641
+ ? {}
642
+ : { worker: policy(fields['worker']) }),
643
+ // Kept only when named: an earlier revision must still read the copy.
644
+ ...(fields['ides'] === undefined
645
+ ? {}
646
+ : { ides: block('$.ides', () => parseIdes(fields['ides'])) }),
647
+ ...(fields['t3'] === undefined
648
+ ? {}
649
+ : { t3: block('$.t3', () => parseT3(fields['t3'])) }),
650
+ ...(fields['git'] === undefined ? {} : { git: gitIdentity(fields['git']) }),
651
+ ...(fields['notify'] === undefined
652
+ ? {}
653
+ : {
654
+ notify:
655
+ parseNotify(fields['notify']) ??
656
+ fail(
657
+ '$.notify',
658
+ 'targets (webhook, ntfy) and repeatHours from 0 to 720',
659
+ fields['notify']
660
+ ),
661
+ }),
662
+ ...operations(fields),
252
663
  };
253
664
  }
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
- > {
665
+ /** How every node signs in to GitHub, how workers are parked, and the
666
+ * tailnet control plane they are enrolled with. */
667
+ function operations(
668
+ fields: Record<string, unknown>
669
+ ): Pick<FleetDefinition, 'github' | 'parking' | 'tailnet'> {
263
670
  return {
264
- ...(value['worker'] === undefined
671
+ ...(fields['github'] === undefined
265
672
  ? {}
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
673
+ : {
674
+ github: block('$.github', () => parseGithubPolicy(fields['github'])),
675
+ }),
676
+ ...(fields['parking'] === undefined
271
677
  ? {}
272
- : { notify: parseNotify(value['notify']) ?? fail() }),
273
- ...(value['github'] === undefined
274
- ? {}
275
- : { github: parseGithubPolicy(value['github']) }),
678
+ : { parking: parking(fields['parking']) }),
679
+ ...tailnet(fields['tailnet']),
680
+ };
681
+ }
682
+ /** The tailnet control plane new workers are enrolled with, when declared. */
683
+ function tailnet(value: unknown): { tailnet?: TailnetConfig } {
684
+ if (value === undefined) return {};
685
+ return {
686
+ tailnet:
687
+ parseTailnetConfig(value) ??
688
+ fail(
689
+ '$.tailnet',
690
+ 'api (tailscale or headscale), an https url (required, with user, for headscale) and a tag:name',
691
+ value
692
+ ),
276
693
  };
277
694
  }
695
+ /** The canary is one of the declared workers. */
696
+ function canary(value: unknown, workers: MachineDefinition[]): string {
697
+ if (typeof value !== 'string' || !workers.some((entry) => entry.id === value))
698
+ return fail('$.canary', 'the id of a declared worker', value);
699
+ return value;
700
+ }
278
701
  /** Singleton services name no host: they always run on the manager. */
279
702
  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();
703
+ const fields = object(value, '$.services');
704
+ keys(fields, ['proxy', 'monitoring'], '$.services');
705
+ const proxy = object(fields['proxy'], '$.services.proxy');
706
+ keys(proxy, ['endpoint', 'accounts'], '$.services.proxy');
288
707
  return {
289
708
  proxy: {
290
- endpoint: proxy['endpoint'],
709
+ endpoint: text(
710
+ proxy['endpoint'],
711
+ ORIGIN,
712
+ '$.services.proxy.endpoint',
713
+ 'an https origin without a path'
714
+ ),
291
715
  ...(proxy['accounts'] === undefined
292
716
  ? {}
293
717
  : { accounts: accountPolicy(proxy['accounts']) }),
294
718
  },
295
- monitoring: monitoringService(monitoring),
719
+ monitoring: monitoringService(fields['monitoring']),
296
720
  };
297
721
  }
722
+ /** A block parsed elsewhere: its own message says what it expects. */
723
+ function block<T>(path: string, parse: () => T): T {
724
+ try {
725
+ return parse();
726
+ } catch (error) {
727
+ if (error instanceof DefinitionError || !(error instanceof Error))
728
+ throw error;
729
+ return fail(path, error.message, 'something else');
730
+ }
731
+ }
298
732
  const STRATEGIES = ['fill-first', 'round-robin', 'least-recently-limited'];
299
733
  function accountPolicy(value: unknown): AccountPolicy {
300
- if (!record(value) || !keys(value, ['minimumUsable', 'selection']))
301
- return fail();
302
- const { minimumUsable, selection } = value;
734
+ const path = '$.services.proxy.accounts';
735
+ const fields = object(value, path);
736
+ keys(fields, ['minimumUsable', 'selection'], path);
737
+ const { minimumUsable, selection } = fields;
303
738
  if (
304
739
  minimumUsable !== undefined &&
305
740
  !(Number.isInteger(minimumUsable) && Number(minimumUsable) >= 1)
306
741
  )
307
- return fail();
742
+ fail(`${path}.minimumUsable`, 'an integer of at least 1', minimumUsable);
308
743
  return {
309
744
  ...(minimumUsable === undefined
310
745
  ? {}
311
746
  : { minimumUsable: minimumUsable as number }),
312
747
  ...(selection === undefined
313
748
  ? {}
314
- : { selection: selectionPolicies(selection) }),
749
+ : { selection: selectionPolicies(selection, `${path}.selection`) }),
315
750
  };
316
751
  }
317
- function selectionPolicies(value: unknown): Record<string, AccountSelection> {
318
- if (!record(value)) return fail();
752
+ function selectionPolicies(
753
+ value: unknown,
754
+ path: string
755
+ ): Record<string, AccountSelection> {
756
+ const fields = object(value, path);
319
757
  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);
758
+ for (const [provider, entry] of Object.entries(fields)) {
759
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(provider))
760
+ fail(`${path}.${provider}`, 'a provider name', provider);
761
+ providers[provider] = accountSelection(entry, `${path}.${provider}`);
323
762
  }
324
763
  return providers;
325
764
  }
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;
765
+ function accountSelection(entry: unknown, path: string): AccountSelection {
766
+ const fields = object(entry, path);
767
+ keys(fields, ['strategy', 'order'], path);
768
+ if (!STRATEGIES.includes(String(fields['strategy'])))
769
+ fail(
770
+ `${path}.strategy`,
771
+ `one of ${STRATEGIES.join(', ')}`,
772
+ fields['strategy']
773
+ );
774
+ const { order } = fields;
334
775
  if (
335
776
  order !== undefined &&
336
- !(Array.isArray(order) && order.every((label) => account(label)))
777
+ !(
778
+ Array.isArray(order) &&
779
+ order.every((label) => typeof label === 'string' && ACCOUNT.test(label))
780
+ )
337
781
  )
338
- return fail();
782
+ fail(`${path}.order`, 'a list of account email addresses', order);
339
783
  return {
340
- strategy: entry['strategy'] as AccountSelection['strategy'],
784
+ strategy: fields['strategy'] as AccountSelection['strategy'],
341
785
  ...(order ? { order: order as string[] } : {}),
342
786
  };
343
787
  }
344
788
  function monitoringService(
345
789
  value: unknown
346
790
  ): 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();
791
+ const path = '$.services.monitoring';
792
+ const fields = object(value, path);
793
+ keys(
794
+ fields,
795
+ ['hubPort', 'publicUrl', 'managerAccount', 'viewerAccount', 'backups'],
796
+ path
797
+ );
798
+ constant(fields['hubPort'], 8443, `${path}.hubPort`);
359
799
  return {
360
800
  hubPort: 8443,
361
- publicUrl,
362
- managerAccount,
363
- viewerAccount,
364
- ...(value['backups'] === undefined
801
+ publicUrl: text(
802
+ fields['publicUrl'],
803
+ ORIGIN,
804
+ `${path}.publicUrl`,
805
+ 'an https origin without a path'
806
+ ),
807
+ managerAccount: text(
808
+ fields['managerAccount'],
809
+ ACCOUNT,
810
+ `${path}.managerAccount`,
811
+ 'an email address'
812
+ ),
813
+ viewerAccount: text(
814
+ fields['viewerAccount'],
815
+ ACCOUNT,
816
+ `${path}.viewerAccount`,
817
+ 'an email address'
818
+ ),
819
+ ...(fields['backups'] === undefined
365
820
  ? {}
366
- : { backups: hubBackups(value['backups']) }),
821
+ : { backups: hubBackups(fields['backups'], `${path}.backups`) }),
367
822
  };
368
823
  }
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();
824
+ const ORIGIN = /^https:\/\/[A-Za-z0-9.-]{1,253}$/;
825
+ const ACCOUNT = /^[^\s@]{1,64}@[A-Za-z0-9.-]{1,253}$/;
826
+ function hubBackups(value: unknown, path: string): HubBackups {
827
+ const fields = object(value, path);
828
+ keys(fields, ['schedule', 'keep', 'storage'], path);
829
+ const { keep } = fields;
830
+ if (!Number.isInteger(keep) || Number(keep) < 1 || Number(keep) > 365)
831
+ fail(`${path}.keep`, 'an integer from 1 to 365', keep);
382
832
  return {
383
- schedule,
833
+ schedule: text(
834
+ fields['schedule'],
835
+ /^[0-9*/,-]+( [0-9*/,-]+){4}$/,
836
+ `${path}.schedule`,
837
+ 'a cron expression of five fields'
838
+ ),
384
839
  keep: Number(keep),
385
- storage: backupStorage(value['storage']),
840
+ storage: backupStorage(fields['storage'], `${path}.storage`),
386
841
  };
387
842
  }
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();
843
+ function backupStorage(value: unknown, path: string): HubBackups['storage'] {
844
+ const fields = object(value, path);
845
+ keys(fields, ['bucket', 'endpoint', 'region', 'forcePathStyle'], path);
846
+ const { forcePathStyle } = fields;
847
+ if (forcePathStyle !== undefined && typeof forcePathStyle !== 'boolean')
848
+ fail(`${path}.forcePathStyle`, 'true or false', forcePathStyle);
402
849
  return {
403
- bucket,
404
- endpoint,
405
- region,
406
- ...(forcePathStyle === undefined ? {} : { forcePathStyle }),
850
+ bucket: text(
851
+ fields['bucket'],
852
+ /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/,
853
+ `${path}.bucket`,
854
+ 'an S3 bucket name'
855
+ ),
856
+ endpoint: text(
857
+ fields['endpoint'],
858
+ /^https:\/\/[A-Za-z0-9.-]{1,253}(:[0-9]{1,5})?$/,
859
+ `${path}.endpoint`,
860
+ 'an https origin, with a port if needed'
861
+ ),
862
+ region: text(
863
+ fields['region'],
864
+ /^[a-z0-9-]{1,32}$/,
865
+ `${path}.region`,
866
+ 'a region name'
867
+ ),
868
+ ...(forcePathStyle === undefined
869
+ ? {}
870
+ : { forcePathStyle: forcePathStyle as boolean }),
407
871
  };
408
872
  }
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
873
  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();
874
+ const path = '$.git';
875
+ const fields = object(value, path);
876
+ keys(fields, ['name', 'email', 'verifyRepository', 'signing'], path);
877
+ const { verifyRepository, signing } = fields;
878
+ if (signing !== undefined && signing !== 'ssh')
879
+ fail(`${path}.signing`, '"ssh"', signing);
431
880
  return {
432
- name,
433
- email,
434
- ...(verifyRepository === undefined ? {} : { verifyRepository }),
881
+ // Written into a quoted Git configuration value: no quote, backslash or
882
+ // line break.
883
+ name: text(
884
+ fields['name'],
885
+ /^[^"\\\n\r]{1,100}$/,
886
+ `${path}.name`,
887
+ 'a name without quotes, backslashes or line breaks'
888
+ ),
889
+ email: text(fields['email'], ACCOUNT, `${path}.email`, 'an email address'),
890
+ ...(verifyRepository === undefined
891
+ ? {}
892
+ : {
893
+ verifyRepository: text(
894
+ verifyRepository,
895
+ /^https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/,
896
+ `${path}.verifyRepository`,
897
+ 'an https URL of a GitHub repository'
898
+ ),
899
+ }),
900
+ ...(signing === 'ssh' ? { signing } : {}),
435
901
  };
436
902
  }
437
903
  function secrets(value: unknown): SecretsConfig {
438
- return parseSecretsConfig(value) ?? fail();
904
+ return (
905
+ parseSecretsConfig(value) ??
906
+ fail(
907
+ '$.secrets',
908
+ 'a provider block: env, file, sops or vault with its settings',
909
+ value
910
+ )
911
+ );
912
+ }
913
+ const POLICY: Record<string, string> = {
914
+ requiredCapabilities:
915
+ 'every capability: github, vault, t3, toolchain, workstation, worker',
916
+ githubLogin: 'a GitHub login',
917
+ vaultUser: 'a Vault user name',
918
+ toolchain: '"mise.lock"',
919
+ agentProfile: '"proxy"',
920
+ unattendedSudo: 'true',
921
+ persistentT3: 'true',
922
+ neverSleep: 'true',
923
+ toolProfiles: 'a list of known tool profiles, each once',
924
+ tools: 'a list of tool names from dev-fleet’s mise.toml, each once',
925
+ exposure: EXPOSURE_EXPECTED,
926
+ features: 'preset, containers and session',
927
+ };
928
+ function policy(value: unknown): WorkerPolicy {
929
+ const fields = object(value, '$.worker');
930
+ keys(fields, Object.keys(POLICY), '$.worker');
931
+ if (fields['features'] !== undefined)
932
+ features(fields['features'], '$.worker.features');
933
+ const field = policyFault(fields);
934
+ if (field !== null)
935
+ fail(`$.worker.${field}`, POLICY[field] ?? 'a valid value', fields[field]);
936
+ return parseWorkerPolicy(fields);
439
937
  }
440
938
  /** Says what is wrong with the block itself: a bucket has several settings. */
441
939
  function state(value: unknown): BucketLocation {
442
- const bucket = parseLocation(value);
940
+ let bucket;
941
+ try {
942
+ bucket = parseLocation(value);
943
+ } catch (error) {
944
+ return fail(
945
+ '$.state',
946
+ error instanceof Error ? error.message : 'a bucket or a directory',
947
+ value
948
+ );
949
+ }
443
950
  if (bucket.kind === 'directory') return { directory: bucket.path };
444
951
  const { bucket: name, endpoint, region } = bucket;
445
952
  return {
@@ -450,36 +957,52 @@ function state(value: unknown): BucketLocation {
450
957
  };
451
958
  }
452
959
  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'] };
960
+ const fields = object(value, '$.source');
961
+ keys(fields, ['repository'], '$.source');
962
+ return {
963
+ repository: text(
964
+ fields['repository'],
965
+ /^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._/-]+\.git$/,
966
+ '$.source.repository',
967
+ 'an https URL of a Git repository ending in .git'
968
+ ),
969
+ };
463
970
  }
464
971
  function baseline(ubuntu: unknown): FleetDefinition['ubuntu'] {
465
- if (!record(ubuntu) || !keys(ubuntu, ['release', 'architectures']))
466
- return fail();
972
+ const fields = object(ubuntu, '$.ubuntu');
973
+ keys(fields, ['release', 'architectures'], '$.ubuntu');
467
974
  const accepted =
468
- typeof ubuntu['release'] === 'string'
469
- ? ubuntuRelease(ubuntu['release'])
975
+ typeof fields['release'] === 'string'
976
+ ? ubuntuRelease(fields['release'])
470
977
  : undefined;
471
- if (
472
- !accepted ||
473
- JSON.stringify(ubuntu['architectures']) !==
474
- JSON.stringify(accepted.architectures)
475
- )
476
- return fail();
978
+ if (!accepted)
979
+ return fail(
980
+ '$.ubuntu.release',
981
+ `one of ${UBUNTU_RELEASES.map((entry) => entry.release).join(', ')}`,
982
+ fields['release']
983
+ );
984
+ constant(
985
+ fields['architectures'],
986
+ accepted.architectures,
987
+ '$.ubuntu.architectures'
988
+ );
477
989
  return {
478
990
  release: accepted.release,
479
991
  architectures: [...accepted.architectures],
480
992
  };
481
993
  }
482
994
 
995
+ /** The first rule a fleet.json breaks, or null when it is valid. */
996
+ export function definitionProblem(value: unknown): DefinitionProblem | null {
997
+ try {
998
+ parseDefinition(value);
999
+ return null;
1000
+ } catch (error) {
1001
+ if (error instanceof DefinitionError) return error.problem;
1002
+ throw error;
1003
+ }
1004
+ }
1005
+
483
1006
  /** The configuration as every machine keeps it at
484
1007
  * ~/.config/dev-fleet/fleet.json: without the bucket. Only the devfleet CLI
485
1008
  * on the manager reaches the bucket, and a launcher that still runs the
@@ -492,11 +1015,18 @@ export function installedDefinition(definition: FleetDefinition): string {
492
1015
  }
493
1016
 
494
1017
  export function loadDefinition(path: string): FleetDefinition {
1018
+ let value: unknown;
495
1019
  try {
496
- return parseDefinition(JSON.parse(readFileSync(path, 'utf8')));
1020
+ value = JSON.parse(readFileSync(path, 'utf8'));
497
1021
  } catch {
498
- return fail();
1022
+ return fail(
1023
+ '$',
1024
+ 'a readable JSON file',
1025
+ 'unreadable',
1026
+ 'DEFINITION_UNREADABLE'
1027
+ );
499
1028
  }
1029
+ return parseDefinition(value);
500
1030
  }
501
1031
  /** Local v1 roster supplies paths/requirements only for explicitly enrolled workers. */
502
1032
  export function enrolledRoster(
@@ -510,7 +1040,7 @@ export function enrolledRoster(
510
1040
  throw new FleetUsageError(
511
1041
  'Roster contains a node that is not a declared worker. Add the worker to fleet.json in Git before rollout; the manager is never a roster node.'
512
1042
  );
513
- if (declared.ssh !== node.ssh)
1043
+ if (sshTarget(declared) !== node.ssh)
514
1044
  throw new FleetUsageError(
515
1045
  'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
516
1046
  );