@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
@@ -1,3 +1,4 @@
1
+ import type { ManagerReport } from '../manager-sync';
1
2
  import { localCheckoutRevision } from '../machine/local-source';
2
3
  import {
3
4
  definitionSource,
@@ -7,7 +8,7 @@ import {
7
8
  import { managerFailure } from '../manager-failure';
8
9
  import { managerText, stdoutColor } from './fleet-sync-output';
9
10
  import { managerSchedule, type ScheduleAction } from './fleet-sync-schedule';
10
- import { enrollManager } from './fleet-sync-enrollment';
11
+ import { enrollManager, type EnrollmentResult } from './fleet-sync-enrollment';
11
12
  import { authorizeManager } from './fleet-sync-vault';
12
13
  import { homedir } from 'node:os';
13
14
  import { join } from 'node:path';
@@ -19,14 +20,14 @@ import {
19
20
  type Enrollment,
20
21
  } from '../machine/state';
21
22
  import { loadEnrollment } from '../state/enrollment';
22
- import { legacyHome } from '../state/paths';
23
+ import { legacyHome, stateRoot } from '../state/paths';
24
+ import { stateDefinition } from '../state/operator';
23
25
  import {
24
26
  appendRun,
25
27
  summarizeRun,
26
28
  type SyncRun,
27
29
  type Trigger,
28
30
  } from '../state/history';
29
- import { capScheduleLogs } from './schedule-logs';
30
31
  import {
31
32
  DIVERGED_REMEDY,
32
33
  mutateState,
@@ -57,12 +58,22 @@ import {
57
58
 
58
59
  export interface FleetSyncOptions {
59
60
  revision?: string;
61
+ /** Converge to the revision applied before the current one. */
62
+ rollback?: boolean;
60
63
  dryRun?: boolean;
61
64
  apply?: boolean;
62
65
  yes?: boolean;
63
66
  json?: boolean;
64
67
  nonInteractive?: boolean;
65
68
  node?: string[];
69
+ /** The worker to prove a revision new to the fleet on first, when it is
70
+ * reachable; overrides `canary` in fleet.json.
71
+ */
72
+ canary?: string;
73
+ /** Opt-in: a worker without a token of its own may be logged in with the
74
+ * fleet's shared Vault user, typed on its own terminal.
75
+ */
76
+ vaultPasswordLogin?: boolean;
66
77
  all?: boolean;
67
78
  schedule?: ScheduleAction;
68
79
  /** `accounts`: only the account-derived configuration, on every node. */
@@ -76,6 +87,23 @@ export interface FleetSyncOptions {
76
87
  expectedPlanFingerprint?: string;
77
88
  /** Take the manager lease from another manager before it expired. */
78
89
  takeOver?: boolean;
90
+ /** Restart a worker's T3 for a new release or policy even while agent
91
+ * turns run, which ends them; by default the restart waits for idle.
92
+ */
93
+ restartBusy?: boolean;
94
+ /** Opt-in: reboot each worker whose installed updates wait for a reboot,
95
+ * one at a time, canary first, only while it runs no agent turn, and
96
+ * verify it is ready before the next.
97
+ */
98
+ maintenanceReboot?: boolean;
99
+ /** With --maintenance-reboot: reboot even while agent turns run. */
100
+ force?: boolean;
101
+ /** Internal: what this run's enrollment did, for the report. */
102
+ enrollment?: EnrollmentResult;
103
+ /** Internal: the workers that converged in an earlier run. One that never
104
+ * did may be rebooted once after its first converge.
105
+ */
106
+ readyBefore?: string[];
79
107
  }
80
108
  export function machineRoot(): string {
81
109
  const root = join(homedir(), '.local/share/personal/fleet/manager');
@@ -105,7 +133,6 @@ export async function fleetSync(
105
133
  });
106
134
  if (options.dryRun || options.schedule) return sync();
107
135
  const trigger = runTrigger();
108
- if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
109
136
  const startedAt = new Date();
110
137
  const outcome = await sync();
111
138
  await recordRun(
@@ -177,17 +204,8 @@ export async function managerSync(
177
204
  ): Promise<CommandOutcome> {
178
205
  await ensurePrivateRoot(root);
179
206
  const state = await loadEnrollment(legacyHome() ? root : null);
180
- const revision = await selectedRevision(
181
- options.revision,
182
- state?.approvedRevision,
183
- settings,
184
- run
185
- );
186
- if (!isRevision(revision))
187
- return actionRequired(
188
- 'FLEET_REVISION_REQUIRED',
189
- 'No revision to converge to. Run the published dev-fleet package (it names the commit it was built from), or select a full commit SHA with --revision.'
190
- );
207
+ const revision = await managerRevision(options, state, settings, run);
208
+ if (typeof revision !== 'string') return revision;
191
209
  if (options.schedule)
192
210
  return scheduleRequest(options, revision, settings, run);
193
211
  const rehomed = await rehome(
@@ -229,6 +247,33 @@ export async function managerSync(
229
247
  });
230
248
  }
231
249
 
250
+ /** The revision the manager converges to, or why there is none. */
251
+ async function managerRevision(
252
+ options: FleetSyncOptions,
253
+ state: Enrollment | null,
254
+ settings: Settings,
255
+ run: Runner
256
+ ): Promise<string | CommandOutcome> {
257
+ const explicit = requestedRevision(options, state);
258
+ if (explicit === null)
259
+ return actionRequired(
260
+ 'FLEET_ROLLBACK_UNAVAILABLE',
261
+ 'Nothing was changed: the fleet state records no revision applied before the approved one. Install the dev-fleet version to go back to and run fleet-sync.'
262
+ );
263
+ const revision = await selectedRevision(
264
+ explicit,
265
+ state?.approvedRevision,
266
+ settings,
267
+ run
268
+ );
269
+ if (!isRevision(revision))
270
+ return actionRequired(
271
+ 'FLEET_REVISION_REQUIRED',
272
+ 'No revision to converge to. Run the published dev-fleet package (it names the commit it was built from), or select a full commit SHA with --revision.'
273
+ );
274
+ return revision;
275
+ }
276
+
232
277
  /** The preview: the read-only report of every machine, and the plan of what
233
278
  * a sync would change on them.
234
279
  */
@@ -253,6 +298,11 @@ async function preview(
253
298
  .map((node) => node.node)
254
299
  .filter((node): node is string => typeof node === 'string'),
255
300
  sources: planSources(settings.workspace, context.root),
301
+ restartBusy: options.restartBusy === true,
302
+ definitions: {
303
+ applied: appliedDefinition(),
304
+ selected: canonicalDefinition,
305
+ },
256
306
  },
257
307
  context.run
258
308
  );
@@ -263,6 +313,16 @@ async function preview(
263
313
  };
264
314
  }
265
315
 
316
+ /** The fleet configuration the fleet state records as last applied. */
317
+ function appliedDefinition(): unknown {
318
+ try {
319
+ const body = stateDefinition(stateRoot());
320
+ return body === null ? null : (JSON.parse(body) as unknown);
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
266
326
  interface ApplyContext {
267
327
  settings: Settings;
268
328
  run: Runner;
@@ -279,15 +339,28 @@ async function authorizeAndApply(
279
339
  context: ApplyContext
280
340
  ): Promise<CommandOutcome> {
281
341
  const { settings, run, revision } = context;
282
- const request = { ...options, revision, apply: true, yes: true };
283
- if (!interactive(options)) return applyLocked(root, state, request, context);
284
- await enrollManager(settings.config, run);
342
+ const request = {
343
+ ...options,
344
+ revision,
345
+ apply: true,
346
+ yes: true,
347
+ readyBefore: readyBefore(state),
348
+ };
349
+ const person = interactive(options);
350
+ // A run without a person enrolls a worker only when every fact it needs
351
+ // is verified; it never prompts.
352
+ const enrollment = await enrollManager(settings.config, run, {
353
+ interactive: person,
354
+ joinable: Boolean(canonicalDefinition.tailnet),
355
+ });
356
+ if (!person)
357
+ return applyLocked(root, state, { ...request, enrollment }, context);
285
358
  // Needed to hand each node its own Vault token; nodes report any gap.
286
359
  await authorizeManager({ interactive: true }, run);
287
360
  return applyLocked(
288
361
  root,
289
362
  state,
290
- { ...request, interactiveAuthorization: true },
363
+ { ...request, enrollment, interactiveAuthorization: true },
291
364
  context
292
365
  );
293
366
  }
@@ -308,6 +381,7 @@ function applyLocked(
308
381
  ...(definitionSource().kind === 'file'
309
382
  ? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
310
383
  : {}),
384
+ scoped: Boolean(request.node?.length),
311
385
  });
312
386
  return withMachineLock(root, async () => {
313
387
  record.approve();
@@ -316,7 +390,7 @@ function applyLocked(
316
390
  { ...request, rolledOut: state?.appliedRevision === revision },
317
391
  settings,
318
392
  run,
319
- (report) => record.settle(report.ok)
393
+ (report) => record.settle(report.ok, appliedRevisions(report))
320
394
  );
321
395
  } finally {
322
396
  await record.abandon();
@@ -349,6 +423,12 @@ async function offManager(
349
423
  forward: ForwardSpawn | undefined
350
424
  ): Promise<CommandOutcome> {
351
425
  const definition = canonicalDefinition;
426
+ // Only the manager's fleet state knows the revision applied before.
427
+ if (options.rollback)
428
+ return actionRequired(
429
+ 'FLEET_NOT_MANAGER',
430
+ `fleet-sync --rollback runs only on the manager (${definition.manager.id}); nothing was changed.`
431
+ );
352
432
  const revision =
353
433
  options.revision ??
354
434
  packageRevision() ??
@@ -356,7 +436,7 @@ async function offManager(
356
436
  if (forwardingDisabled())
357
437
  return actionRequired(
358
438
  'FLEET_NOT_MANAGER',
359
- `fleet-sync runs only on the manager (${definition.manager.id}); nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
439
+ `fleet-sync runs only on the manager (${definition.manager.id}), which is always a macOS machine; nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
360
440
  revision ?? null
361
441
  );
362
442
  const runScript = revision
@@ -405,11 +485,42 @@ function validate(options: FleetSyncOptions): void {
405
485
  '--revision requires an exact lowercase 40-character commit SHA.'
406
486
  );
407
487
  validateOnly(options);
488
+ validateRollback(options);
489
+ if (options.restartBusy && (options.only || options.schedule))
490
+ throw new FleetUsageError(
491
+ '--restart-busy applies to one full sync: it takes neither --only nor --schedule.'
492
+ );
493
+ validateMaintenance(options);
408
494
  if (options.dryRun && (options.apply || options.yes))
409
495
  throw new FleetUsageError(
410
496
  '--dry-run cannot be combined with apply confirmation.'
411
497
  );
412
498
  }
499
+ function validateMaintenance(options: FleetSyncOptions): void {
500
+ if (options.force && !options.maintenanceReboot)
501
+ throw new FleetUsageError(
502
+ '--force only goes with --maintenance-reboot: it reboots a worker even while agent turns run.'
503
+ );
504
+ if (
505
+ options.maintenanceReboot &&
506
+ (options.dryRun || options.schedule || options.only)
507
+ )
508
+ throw new FleetUsageError(
509
+ '--maintenance-reboot reboots workers; it takes neither --dry-run, --schedule nor --only.'
510
+ );
511
+ }
512
+ function validateRollback(options: FleetSyncOptions): void {
513
+ if (!options.rollback) return;
514
+ if (options.revision !== undefined || options.schedule)
515
+ throw new FleetUsageError(
516
+ '--rollback chooses the revision itself: drop --revision and --schedule.'
517
+ );
518
+ if (options.only)
519
+ throw new FleetUsageError(
520
+ '--rollback converges the whole fleet; it takes no --only.'
521
+ );
522
+ }
523
+
413
524
  function validateOnly(options: FleetSyncOptions): void {
414
525
  if (options.only !== undefined && options.only !== 'accounts')
415
526
  throw new FleetUsageError('--only takes one scope: accounts.');
@@ -441,6 +552,17 @@ async function onlyAccounts(
441
552
  return syncAccounts(options, settings, { revision, run });
442
553
  }
443
554
 
555
+ /** The workers that converged before this run. A fleet state written before
556
+ * it recorded each worker's revision counts every worker of a fleet that
557
+ * was applied as converged.
558
+ */
559
+ function readyBefore(state: Enrollment | null): string[] {
560
+ if (state?.workers) return Object.keys(state.workers);
561
+ return state?.appliedRevision
562
+ ? canonicalDefinition.workers.map((worker) => worker.id)
563
+ : [];
564
+ }
565
+
444
566
  function interactive(options: FleetSyncOptions): boolean {
445
567
  return (
446
568
  ![
@@ -507,6 +629,24 @@ function validateSchedule(schedule: unknown): void {
507
629
  throw new FleetUsageError('Unknown manager schedule action.');
508
630
  }
509
631
 
632
+ /** The revision a request names: --revision, or for --rollback the one
633
+ * applied before the approved one (null when the fleet state records none).
634
+ * After an approved revision that never became the applied one (its canary
635
+ * failed), that is the applied revision itself.
636
+ */
637
+ function requestedRevision(
638
+ options: FleetSyncOptions,
639
+ state: Enrollment | null
640
+ ): string | undefined | null {
641
+ if (!options.rollback) return options.revision;
642
+ if (
643
+ state?.appliedRevision &&
644
+ state.appliedRevision !== state.approvedRevision
645
+ )
646
+ return state.appliedRevision;
647
+ return state?.previousRevision ?? null;
648
+ }
649
+
510
650
  async function selectedRevision(
511
651
  explicit: string | undefined,
512
652
  approved: string | undefined,
@@ -520,3 +660,14 @@ async function selectedRevision(
520
660
  approved
521
661
  );
522
662
  }
663
+
664
+ /** The revision each worker holds after this run, for the fleet state: a
665
+ * worker that converged keeps its progress even when the fleet is not ready.
666
+ */
667
+ function appliedRevisions(report: ManagerReport): Record<string, string> {
668
+ return Object.fromEntries(
669
+ report.nodes
670
+ .filter((node) => node.role !== 'manager' && node.appliedRevision)
671
+ .map((node) => [node.node, node.appliedRevision!])
672
+ );
673
+ }
@@ -0,0 +1,277 @@
1
+ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import {
5
+ DefinitionError,
6
+ parseDefinition,
7
+ sshTarget,
8
+ type DefinitionProblem,
9
+ type FleetDefinition,
10
+ } from '../definition';
11
+ import { FleetUsageError } from '../errors';
12
+ import { PACKAGE_ROOT } from '../package-info';
13
+ import { ansibleCommand, ansibleConfig } from '../platform/ansible';
14
+ import { shellQuote } from '../ssh';
15
+ import {
16
+ connectionOptions,
17
+ declaredConnection,
18
+ knownHostsPath,
19
+ SSH_TRANSPORT,
20
+ sshOptionArgs,
21
+ } from '../ssh-connection';
22
+ import {
23
+ expandRecords,
24
+ runRecordCommand,
25
+ type RecordCommand,
26
+ } from '../worker-records';
27
+ import { runCommand } from '../runner';
28
+ import type { Check, Runner } from '../types';
29
+ import type { CommandOutcome } from './contracts';
30
+
31
+ export interface FleetValidateOptions {
32
+ file?: string;
33
+ ansible?: boolean;
34
+ json?: boolean;
35
+ }
36
+
37
+ export interface FleetValidateServices {
38
+ run: Runner;
39
+ env: NodeJS.ProcessEnv;
40
+ platform: NodeJS.Platform;
41
+ /** The package's Ansible playbooks, relative to `root`. */
42
+ root: string;
43
+ /** Runs a command `workerRecords` names. */
44
+ records?: RecordCommand;
45
+ }
46
+
47
+ const SERVICES: FleetValidateServices = {
48
+ run: runCommand,
49
+ env: process.env,
50
+ platform: process.platform,
51
+ root: PACKAGE_ROOT,
52
+ };
53
+
54
+ /** The playbooks a definition must be able to drive, in the package. */
55
+ const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
56
+
57
+ /** The Ansible inventory a definition describes: the manager, and every
58
+ * worker in the `workers` group in the order it is declared. Host names are
59
+ * the fleet ids; a machine is reached as fleet-sync reaches it.
60
+ */
61
+ export function fleetInventory(
62
+ definition: FleetDefinition,
63
+ knownHosts = knownHostsPath()
64
+ ): unknown {
65
+ const workers = Object.fromEntries(
66
+ definition.workers.map((worker) => {
67
+ const declared = declaredConnection(worker);
68
+ // The same options a direct session takes (src/ssh-connection.ts).
69
+ const options = [
70
+ ...SSH_TRANSPORT,
71
+ ...(declared ? connectionOptions(declared, knownHosts) : []),
72
+ ];
73
+ return [
74
+ worker.id,
75
+ {
76
+ ansible_host: sshTarget(worker),
77
+ ...(worker.network
78
+ ? {
79
+ ansible_user: worker.network.user,
80
+ ansible_port: worker.network.sshPort,
81
+ }
82
+ : {}),
83
+ ansible_ssh_common_args: sshOptionArgs(options)
84
+ .map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
85
+ .join(' '),
86
+ },
87
+ ];
88
+ })
89
+ );
90
+ return {
91
+ all: {
92
+ children: {
93
+ manager: {
94
+ hosts: {
95
+ [definition.manager.id]: {
96
+ ansible_connection: 'local',
97
+ ansible_user: definition.manager.network.user,
98
+ },
99
+ },
100
+ },
101
+ workers: { hosts: workers },
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ /** Reads and judges one fleet.json. Nothing is contacted: with `--ansible`
108
+ * the pinned Ansible renders the inventory and checks the playbooks' syntax
109
+ * on this machine.
110
+ */
111
+ export async function fleetValidate(
112
+ options: FleetValidateOptions,
113
+ services: FleetValidateServices = SERVICES
114
+ ): Promise<CommandOutcome> {
115
+ const path = options.file ?? services.env['DEVFLEET_CONFIG'];
116
+ if (!path)
117
+ throw new FleetUsageError(
118
+ 'No fleet configuration to validate. Run devfleet from the project that holds your fleet.json, or pass --file PATH.'
119
+ );
120
+ let definition: FleetDefinition;
121
+ try {
122
+ definition = parseDefinition(
123
+ expandRecords(
124
+ await readJson(path),
125
+ dirname(path),
126
+ services.records ?? runRecordCommand
127
+ )
128
+ );
129
+ } catch (error) {
130
+ if (error instanceof DefinitionError) return invalid(path, error.problem);
131
+ throw error;
132
+ }
133
+ const checks: Check[] = [
134
+ {
135
+ id: 'definition:schema',
136
+ status: 'pass',
137
+ summary: `${path} is a valid fleet definition: a manager and ${definition.workers.length} worker(s).`,
138
+ },
139
+ ];
140
+ if (options.ansible)
141
+ checks.push(...(await ansibleChecks(definition, services)));
142
+ const ok = checks.every((check) => check.status !== 'fail');
143
+ return {
144
+ exitCode: ok ? 0 : 1,
145
+ data: { version: 1, command: 'fleet-validate', ok, path, checks },
146
+ text: checks
147
+ .map(
148
+ (check) => `${check.status.toUpperCase()} ${check.id}: ${check.summary}`
149
+ )
150
+ .join('\n'),
151
+ };
152
+ }
153
+
154
+ async function readJson(path: string): Promise<unknown> {
155
+ let text: string;
156
+ try {
157
+ text = await readFile(path, 'utf8');
158
+ } catch {
159
+ throw new DefinitionError({
160
+ code: 'DEFINITION_UNREADABLE',
161
+ path: '$',
162
+ expected: 'a readable file',
163
+ actual: 'no file',
164
+ });
165
+ }
166
+ try {
167
+ return JSON.parse(text) as unknown;
168
+ } catch {
169
+ throw new DefinitionError({
170
+ code: 'DEFINITION_UNREADABLE',
171
+ path: '$',
172
+ expected: 'JSON',
173
+ actual: 'text that is not JSON',
174
+ });
175
+ }
176
+ }
177
+
178
+ function invalid(path: string, problem: DefinitionProblem): CommandOutcome {
179
+ const data = {
180
+ version: 1,
181
+ command: 'fleet-validate',
182
+ ok: false,
183
+ path,
184
+ problem,
185
+ };
186
+ return {
187
+ exitCode: 1,
188
+ data,
189
+ text: `${problem.code} at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`,
190
+ };
191
+ }
192
+
193
+ /** Renders the inventory, lists it with `ansible-inventory` and checks the
194
+ * syntax of every playbook against it.
195
+ */
196
+ async function ansibleChecks(
197
+ definition: FleetDefinition,
198
+ services: FleetValidateServices
199
+ ): Promise<Check[]> {
200
+ const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
201
+ const inventory = join(directory, 'inventory.json');
202
+ try {
203
+ await writeFile(
204
+ inventory,
205
+ JSON.stringify(fleetInventory(definition, knownHostsPath(services.env))),
206
+ { mode: 0o600 }
207
+ );
208
+ const playbook = ansibleCommand(services.platform, services.root);
209
+ const tool = (name: string) => [...playbook.slice(0, -1), name];
210
+ const listed = await services.run(
211
+ [...tool('ansible-inventory'), '-i', inventory, '--list'],
212
+ { timeoutMs: 120_000, env: ansibleConfig(services.root) }
213
+ );
214
+ const checks: Check[] = [
215
+ listed.ok
216
+ ? {
217
+ id: 'definition:inventory',
218
+ status: 'pass',
219
+ summary: 'Ansible reads the inventory the definition describes.',
220
+ }
221
+ : {
222
+ id: 'definition:inventory',
223
+ status: 'fail',
224
+ summary: 'DEFINITION_INVENTORY_REJECTED',
225
+ remedy: {
226
+ action:
227
+ 'Run devfleet fleet-validate --ansible on the manager after fleet-sync installed the pinned Ansible; report the failure as an issue if it persists.',
228
+ },
229
+ },
230
+ ];
231
+ const failed: string[] = [];
232
+ for (const file of await playbooks(services.root)) {
233
+ const result = await services.run(
234
+ [
235
+ ...playbook,
236
+ '-i',
237
+ inventory,
238
+ '--syntax-check',
239
+ join(services.root, file),
240
+ ],
241
+ { timeoutMs: 120_000, env: ansibleConfig(services.root) }
242
+ );
243
+ if (!result.ok) failed.push(file);
244
+ }
245
+ checks.push(
246
+ failed.length
247
+ ? {
248
+ id: 'definition:playbooks',
249
+ status: 'fail',
250
+ summary: `DEFINITION_PLAYBOOK_SYNTAX: ${failed.join(', ')}`,
251
+ remedy: {
252
+ action:
253
+ 'The installed dev-fleet package carries a playbook Ansible cannot parse; report it as an issue.',
254
+ },
255
+ }
256
+ : {
257
+ id: 'definition:playbooks',
258
+ status: 'pass',
259
+ summary: 'Every playbook passes ansible-playbook --syntax-check.',
260
+ }
261
+ );
262
+ return checks;
263
+ } finally {
264
+ await rm(directory, { recursive: true, force: true });
265
+ }
266
+ }
267
+
268
+ /** Every top-level playbook the fleet applies, sorted. */
269
+ async function playbooks(root: string): Promise<string[]> {
270
+ const found: string[] = [];
271
+ for (const directory of PLAYBOOK_DIRECTORIES) {
272
+ const entries = await readdir(join(root, directory)).catch(() => []);
273
+ for (const entry of entries)
274
+ if (entry.endsWith('.yml')) found.push(join(directory, entry));
275
+ }
276
+ return found.sort();
277
+ }
@@ -35,6 +35,10 @@ export interface FleetWatchServices {
35
35
  secrets: SecretsProvider;
36
36
  send: typeof fetch;
37
37
  now: () => Date;
38
+ /** Parks the workers `worker:idle` warns about; only when fleet.json turns
39
+ * parking.autoPark on. One check per worker it parked or could not.
40
+ */
41
+ autoPark?: (reports: NodeReport[]) => Promise<Check[]>;
38
42
  }
39
43
 
40
44
  /** Compares the fleet's health with the previous run and notifies on
@@ -61,7 +65,8 @@ export async function fleetWatch(
61
65
  const previous = services.store
62
66
  ? parseMemory(await services.store.read().catch(() => null))
63
67
  : null;
64
- const observed = observe(await services.status());
68
+ const reports = await services.status();
69
+ const observed = observe(reports);
65
70
  const { notifications, memory } = transitions(
66
71
  previous,
67
72
  observed,
@@ -80,6 +85,7 @@ export async function fleetWatch(
80
85
  ))
81
86
  );
82
87
  if (!notify) checks.push(unconfigured('warn'));
88
+ if (services.autoPark) checks.push(...(await services.autoPark(reports)));
83
89
  checks.push(await heartbeat(services.store, JSON.stringify(memory)));
84
90
  return outcome(checks, notifications);
85
91
  }