@crvouga/dev-fleet 0.8.0 → 0.9.1

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 (205) hide show
  1. package/README.md +65 -9
  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 +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  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/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capability-checks.ts +55 -16
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-exposure.ts +388 -0
  195. package/src/worker-features.ts +201 -0
  196. package/src/worker-host-checks.ts +71 -20
  197. package/src/worker-manifest.ts +27 -4
  198. package/src/worker-parking.ts +70 -0
  199. package/src/worker-policy.ts +17 -1
  200. package/src/worker-readiness.ts +15 -3
  201. package/src/worker-reboot.ts +129 -0
  202. package/src/worker-records.ts +305 -0
  203. package/src/worker-seed.ts +16 -11
  204. package/src/worker-sizing.ts +76 -0
  205. package/src/cli/schedule-logs.ts +0 -36
package/src/definition.ts CHANGED
@@ -5,6 +5,11 @@ import {
5
5
  type WorkerPolicy,
6
6
  } from './worker-policy';
7
7
  import type { ToolProfile } from '../bootstrap/worker-tools';
8
+ import {
9
+ featuresFault,
10
+ type Preset,
11
+ type Session,
12
+ } from '../bootstrap/worker-features';
8
13
  import { readFileSync } from 'node:fs';
9
14
  import { FleetUsageError } from './errors';
10
15
  import { parseConfig, record } from './config';
@@ -20,19 +25,84 @@ import {
20
25
  import { parseNotify, type NotifyConfig } from './notify';
21
26
  import { UBUNTU_RELEASES, ubuntuRelease } from './platform/ubuntu-release';
22
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';
23
34
 
24
35
  export interface MachineDefinition {
25
36
  id: string;
26
- 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;
27
42
  os: 'linux';
28
43
  /** Tool profiles this worker adds to the fleet-wide ones. */
29
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;
30
53
  network?: {
31
54
  tailscaleName: string;
32
55
  user: string;
33
56
  sshPort: number;
34
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;
35
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;
36
106
  }
37
107
  /** The singleton manager: the human's machine. It runs fleet-sync and hosts
38
108
  * every singleton service; it is never converged over SSH. */
@@ -41,6 +111,8 @@ export interface ManagerDefinition {
41
111
  os: 'macOS';
42
112
  network: { tailscaleName: string; user: string };
43
113
  neverSleepOnPower: true;
114
+ /** How often its scheduled jobs run, when not the policy's default. */
115
+ schedule?: { syncMinutes?: number; watchMinutes?: number };
44
116
  }
45
117
  export interface FleetDefinition {
46
118
  version: 3;
@@ -91,6 +163,12 @@ export interface FleetDefinition {
91
163
  ubuntu: { release: string; architectures: string[] };
92
164
  /** How every node signs in to GitHub; defaults when absent. */
93
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;
94
172
  }
95
173
  /** How provider accounts are watched and chosen (src/accounts/). */
96
174
  export interface AccountPolicy {
@@ -130,6 +208,11 @@ export interface GitIdentity {
130
208
  * credentials, proving it can push (`worker:git-push-auth`).
131
209
  */
132
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';
133
216
  }
134
217
  const ID = /^[a-z][a-z0-9-]{0,62}$/;
135
218
  const HOST = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,252}$/;
@@ -218,7 +301,7 @@ function network(
218
301
  path: string
219
302
  ): NonNullable<MachineDefinition['network']> {
220
303
  const fields = object(value, path);
221
- keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port'], path);
304
+ keys(fields, ['tailscaleName', 'user', 'sshPort', 't3Port', 'hostKey'], path);
222
305
  return {
223
306
  tailscaleName: text(
224
307
  fields['tailscaleName'],
@@ -229,6 +312,113 @@ function network(
229
312
  user: text(fields['user'], USER, `${path}.user`, 'a login name'),
230
313
  sshPort: port(fields['sshPort'], `${path}.sshPort`),
231
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[] {
373
+ if (
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
+ )
381
+ )
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'];
390
+ if (
391
+ hours !== undefined &&
392
+ (typeof hours !== 'number' || !(hours >= 1) || hours > 8760)
393
+ )
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;
405
+ }
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`) }),
232
422
  };
233
423
  }
234
424
  function port(value: unknown, path: string): number {
@@ -238,18 +428,44 @@ function port(value: unknown, path: string): number {
238
428
  }
239
429
  function worker(value: unknown, path: string): MachineDefinition {
240
430
  const fields = object(value, path);
241
- keys(fields, ['id', 'ssh', 'os', 'network', 'toolProfiles'], 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
+ );
242
448
  const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
243
- const ssh = text(fields['ssh'], HOST, `${path}.ssh`, 'an SSH host alias');
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
+ );
244
459
  // Workers are the Linux boxes; any other platform is not a worker.
245
460
  constant(fields['os'], 'linux', `${path}.os`);
246
461
  return {
247
462
  id,
248
- ssh,
463
+ ...(ssh === undefined ? {} : { ssh }),
249
464
  os: 'linux',
250
465
  ...(fields['network']
251
466
  ? { network: network(fields['network'], `${path}.network`) }
252
467
  : {}),
468
+ ...described(fields, path),
253
469
  ...(fields['toolProfiles'] === undefined
254
470
  ? {}
255
471
  : {
@@ -261,12 +477,60 @@ function worker(value: unknown, path: string): MachineDefinition {
261
477
  fields['toolProfiles']
262
478
  ),
263
479
  }),
480
+ ...(fields['exposure'] === undefined
481
+ ? {}
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
+ }),
264
500
  };
265
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
+ }
266
530
  function manager(value: unknown): ManagerDefinition {
267
531
  const path = '$.manager';
268
532
  const fields = object(value, path);
269
- keys(fields, ['id', 'os', 'network', 'neverSleepOnPower'], path);
533
+ keys(fields, ['id', 'os', 'network', 'neverSleepOnPower', 'schedule'], path);
270
534
  const id = text(fields['id'], ID, `${path}.id`, 'a lowercase id');
271
535
  // The manager must be the Mac: a Linux machine is a worker.
272
536
  constant(fields['os'], 'macOS', `${path}.os`);
@@ -290,8 +554,32 @@ function manager(value: unknown): ManagerDefinition {
290
554
  os: 'macOS',
291
555
  network: { tailscaleName, user },
292
556
  neverSleepOnPower: true,
557
+ ...(fields['schedule'] === undefined
558
+ ? {}
559
+ : { schedule: schedule(fields['schedule'], `${path}.schedule`) }),
293
560
  };
294
561
  }
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;
582
+ }
295
583
  export function parseDefinition(value: unknown): FleetDefinition {
296
584
  const fields = object(value, '$');
297
585
  keys(
@@ -313,6 +601,8 @@ export function parseDefinition(value: unknown): FleetDefinition {
313
601
  'git',
314
602
  'notify',
315
603
  'github',
604
+ 'tailnet',
605
+ 'parking',
316
606
  ],
317
607
  '$'
318
608
  );
@@ -369,11 +659,37 @@ export function parseDefinition(value: unknown): FleetDefinition {
369
659
  fields['notify']
370
660
  ),
371
661
  }),
662
+ ...operations(fields),
663
+ };
664
+ }
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'> {
670
+ return {
372
671
  ...(fields['github'] === undefined
373
672
  ? {}
374
673
  : {
375
674
  github: block('$.github', () => parseGithubPolicy(fields['github'])),
376
675
  }),
676
+ ...(fields['parking'] === undefined
677
+ ? {}
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
+ ),
377
693
  };
378
694
  }
379
695
  /** The canary is one of the declared workers. */
@@ -557,8 +873,10 @@ function backupStorage(value: unknown, path: string): HubBackups['storage'] {
557
873
  function gitIdentity(value: unknown): GitIdentity {
558
874
  const path = '$.git';
559
875
  const fields = object(value, path);
560
- keys(fields, ['name', 'email', 'verifyRepository'], path);
561
- const { verifyRepository } = fields;
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);
562
880
  return {
563
881
  // Written into a quoted Git configuration value: no quote, backslash or
564
882
  // line break.
@@ -579,6 +897,7 @@ function gitIdentity(value: unknown): GitIdentity {
579
897
  'an https URL of a GitHub repository'
580
898
  ),
581
899
  }),
900
+ ...(signing === 'ssh' ? { signing } : {}),
582
901
  };
583
902
  }
584
903
  function secrets(value: unknown): SecretsConfig {
@@ -603,10 +922,14 @@ const POLICY: Record<string, string> = {
603
922
  neverSleep: 'true',
604
923
  toolProfiles: 'a list of known tool profiles, each once',
605
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',
606
927
  };
607
928
  function policy(value: unknown): WorkerPolicy {
608
929
  const fields = object(value, '$.worker');
609
930
  keys(fields, Object.keys(POLICY), '$.worker');
931
+ if (fields['features'] !== undefined)
932
+ features(fields['features'], '$.worker.features');
610
933
  const field = policyFault(fields);
611
934
  if (field !== null)
612
935
  fail(`$.worker.${field}`, POLICY[field] ?? 'a valid value', fields[field]);
@@ -717,7 +1040,7 @@ export function enrolledRoster(
717
1040
  throw new FleetUsageError(
718
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.'
719
1042
  );
720
- if (declared.ssh !== node.ssh)
1043
+ if (sshTarget(declared) !== node.ssh)
721
1044
  throw new FleetUsageError(
722
1045
  'Fleet SSH assignment conflicts with the local roster. Review the migration; discovery cannot overwrite explicit assignments.'
723
1046
  );
@@ -42,7 +42,8 @@ const EXPECTED: Record<string, string> = {
42
42
  */
43
43
  export function diagnostic(node: string, check: Check): Diagnostic {
44
44
  const phase = check.id.split(':')[0]!;
45
- const failed = check.status !== 'pass';
45
+ // A check that does not apply to this machine asks nothing of anyone.
46
+ const failed = check.status !== 'pass' && check.status !== 'not-applicable';
46
47
  const code = /^[A-Z][A-Z0-9_]{2,100}$/.test(check.summary)
47
48
  ? check.summary
48
49
  : `${check.id.replaceAll(/[^a-z0-9]/g, '_').toUpperCase()}_${failed ? 'UNVERIFIED' : 'VERIFIED'}`;
@@ -59,7 +60,9 @@ export function diagnostic(node: string, check: Check): Diagnostic {
59
60
  observed: check.summary,
60
61
  scope: scope(check.id),
61
62
  durationMs: check.phaseDurationMs ?? null,
62
- retryable: failed && /OFFLINE|UNREACHABLE|METRICS_STALE|TIMEOUT/.test(code),
63
+ retryable:
64
+ failed &&
65
+ /OFFLINE|UNREACHABLE|METRICS_STALE|TIMEOUT|FIRST_BOOT_PENDING/.test(code),
63
66
  actionRequired: failed,
64
67
  remedy: {
65
68
  owner: owner(phase),
@@ -0,0 +1,102 @@
1
+ import type { FleetDefinition } from './definition';
2
+ import { record } from './config';
3
+
4
+ /** What a worker opens beyond loopback and the tailnet (docs/ubuntu-sync.md,
5
+ * Exposure). Declared fleet-wide as `worker.exposure` and per worker as
6
+ * `exposure`; a field a worker names replaces the fleet-wide one. Applied by
7
+ * ansible/ubuntu/tasks/firewall.yml and ssh.yml, measured as worker:exposure
8
+ * and worker:public-reachability.
9
+ */
10
+ export interface ExposurePolicy {
11
+ /** Break-glass: the SSH port stays open on every interface. */
12
+ publicSsh: boolean;
13
+ /** Opened on every interface; a listener on one is allowed. */
14
+ publicPorts: PublicPort[];
15
+ /** A listener outside the allow-list that no fleet service owns, such as
16
+ * an agent's development server: reported as a warning or a failure. The
17
+ * firewall closes it either way.
18
+ */
19
+ otherListeners: 'warn' | 'fail';
20
+ }
21
+ export interface PublicPort {
22
+ protocol: 'tcp' | 'udp';
23
+ port: number;
24
+ }
25
+ export type ExposureDeclaration = Partial<ExposurePolicy>;
26
+
27
+ /** Nothing public: inbound only over loopback and the tailnet. */
28
+ export const DEFAULT_EXPOSURE: ExposurePolicy = {
29
+ publicSsh: false,
30
+ publicPorts: [],
31
+ otherListeners: 'warn',
32
+ };
33
+
34
+ export const EXPOSURE_EXPECTED =
35
+ 'publicSsh (true or false), publicPorts (a list of {protocol: tcp or udp, port}, each once) and otherListeners ("warn" or "fail")';
36
+
37
+ function publicPort(value: unknown): PublicPort | null {
38
+ if (!record(value)) return null;
39
+ const { protocol, port } = value;
40
+ if (Object.keys(value).some((key) => key !== 'protocol' && key !== 'port'))
41
+ return null;
42
+ if (protocol !== 'tcp' && protocol !== 'udp') return null;
43
+ if (!Number.isInteger(port) || Number(port) < 1 || Number(port) > 65535)
44
+ return null;
45
+ return { protocol, port: port as number };
46
+ }
47
+
48
+ /** A list of ports, each once; null when it is not one. */
49
+ function publicPorts(value: unknown): PublicPort[] | null {
50
+ if (!Array.isArray(value) || value.length > 32) return null;
51
+ const parsed = value.map(publicPort);
52
+ if (parsed.some((entry) => entry === null)) return null;
53
+ const ports = parsed as PublicPort[];
54
+ const keys = ports.map((entry) => `${entry.protocol}/${entry.port}`);
55
+ return new Set(keys).size === keys.length ? ports : null;
56
+ }
57
+
58
+ const FIELDS: Record<string, (value: unknown) => boolean> = {
59
+ publicSsh: (value) => typeof value === 'boolean',
60
+ publicPorts: (value) => publicPorts(value) !== null,
61
+ otherListeners: (value) => value === 'warn' || value === 'fail',
62
+ };
63
+
64
+ /** An exposure declaration, or null when it is not one. */
65
+ export function parseExposure(value: unknown): ExposureDeclaration | null {
66
+ if (!record(value)) return null;
67
+ const valid = Object.entries(value).every(
68
+ ([key, field]) => Object.hasOwn(FIELDS, key) && FIELDS[key]!(field)
69
+ );
70
+ return valid ? ({ ...value } as ExposureDeclaration) : null;
71
+ }
72
+
73
+ /** The policy one worker runs under: the default, then the fleet-wide
74
+ * declaration, then the worker's own.
75
+ */
76
+ export function effectiveExposure(
77
+ fleetWide: ExposureDeclaration | undefined,
78
+ own: ExposureDeclaration | undefined
79
+ ): ExposurePolicy {
80
+ return { ...DEFAULT_EXPOSURE, ...fleetWide, ...own };
81
+ }
82
+
83
+ /** What a worker opens and listens on, from the fleet configuration. */
84
+ export interface WorkerExposure {
85
+ policy: ExposurePolicy;
86
+ /** The port sshd listens on (network.sshPort; 22 when undeclared). */
87
+ sshPort: number;
88
+ /** The port T3 serves on loopback (network.t3Port; 3773). */
89
+ t3Port: number;
90
+ }
91
+
92
+ export function workerExposure(
93
+ definition: Pick<FleetDefinition, 'worker' | 'workers'>,
94
+ node: string
95
+ ): WorkerExposure {
96
+ const machine = definition.workers.find((entry) => entry.id === node);
97
+ return {
98
+ policy: effectiveExposure(definition.worker?.exposure, machine?.exposure),
99
+ sshPort: machine?.network?.sshPort ?? 22,
100
+ t3Port: machine?.network?.t3Port ?? 3773,
101
+ };
102
+ }
@@ -24,6 +24,7 @@ import {
24
24
  import { isOperator, stateDefinition } from './state/operator';
25
25
  import type { BucketLocation } from './state/bucket';
26
26
  import { definitionPath, stateRoot } from './state/paths';
27
+ import { expandRecords } from './worker-records';
27
28
 
28
29
  /** Where every machine keeps the fleet configuration fleet-sync approved.
29
30
  * Launchers and scheduled jobs get no environment and read it from here.
@@ -96,7 +97,8 @@ function read(path: string): FleetDefinition {
96
97
  `The fleet configuration at ${path} is not readable JSON.`
97
98
  );
98
99
  }
99
- return parseDefinition(value);
100
+ // A project's fleet.json may name generated worker records beside it.
101
+ return parseDefinition(expandRecords(value, dirname(path)));
100
102
  }
101
103
 
102
104
  function load(): FleetDefinition {