@crvouga/dev-fleet 0.8.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 (204) 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-capacity.ts +31 -0
  193. package/src/worker-exposure.ts +388 -0
  194. package/src/worker-features.ts +201 -0
  195. package/src/worker-host-checks.ts +71 -20
  196. package/src/worker-manifest.ts +27 -4
  197. package/src/worker-parking.ts +70 -0
  198. package/src/worker-policy.ts +17 -1
  199. package/src/worker-readiness.ts +15 -3
  200. package/src/worker-reboot.ts +129 -0
  201. package/src/worker-records.ts +305 -0
  202. package/src/worker-seed.ts +16 -11
  203. package/src/worker-sizing.ts +76 -0
  204. package/src/cli/schedule-logs.ts +0 -36
@@ -8,7 +8,7 @@ import {
8
8
  import { managerFailure } from '../manager-failure';
9
9
  import { managerText, stdoutColor } from './fleet-sync-output';
10
10
  import { managerSchedule, type ScheduleAction } from './fleet-sync-schedule';
11
- import { enrollManager } from './fleet-sync-enrollment';
11
+ import { enrollManager, type EnrollmentResult } from './fleet-sync-enrollment';
12
12
  import { authorizeManager } from './fleet-sync-vault';
13
13
  import { homedir } from 'node:os';
14
14
  import { join } from 'node:path';
@@ -20,14 +20,14 @@ import {
20
20
  type Enrollment,
21
21
  } from '../machine/state';
22
22
  import { loadEnrollment } from '../state/enrollment';
23
- import { legacyHome } from '../state/paths';
23
+ import { legacyHome, stateRoot } from '../state/paths';
24
+ import { stateDefinition } from '../state/operator';
24
25
  import {
25
26
  appendRun,
26
27
  summarizeRun,
27
28
  type SyncRun,
28
29
  type Trigger,
29
30
  } from '../state/history';
30
- import { capScheduleLogs } from './schedule-logs';
31
31
  import {
32
32
  DIVERGED_REMEDY,
33
33
  mutateState,
@@ -58,6 +58,8 @@ import {
58
58
 
59
59
  export interface FleetSyncOptions {
60
60
  revision?: string;
61
+ /** Converge to the revision applied before the current one. */
62
+ rollback?: boolean;
61
63
  dryRun?: boolean;
62
64
  apply?: boolean;
63
65
  yes?: boolean;
@@ -85,6 +87,23 @@ export interface FleetSyncOptions {
85
87
  expectedPlanFingerprint?: string;
86
88
  /** Take the manager lease from another manager before it expired. */
87
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[];
88
107
  }
89
108
  export function machineRoot(): string {
90
109
  const root = join(homedir(), '.local/share/personal/fleet/manager');
@@ -114,7 +133,6 @@ export async function fleetSync(
114
133
  });
115
134
  if (options.dryRun || options.schedule) return sync();
116
135
  const trigger = runTrigger();
117
- if (trigger === 'schedule') capScheduleLogs(join(machineRoot(), 'logs'));
118
136
  const startedAt = new Date();
119
137
  const outcome = await sync();
120
138
  await recordRun(
@@ -186,17 +204,8 @@ export async function managerSync(
186
204
  ): Promise<CommandOutcome> {
187
205
  await ensurePrivateRoot(root);
188
206
  const state = await loadEnrollment(legacyHome() ? root : null);
189
- const revision = await selectedRevision(
190
- options.revision,
191
- state?.approvedRevision,
192
- settings,
193
- run
194
- );
195
- if (!isRevision(revision))
196
- return actionRequired(
197
- 'FLEET_REVISION_REQUIRED',
198
- '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.'
199
- );
207
+ const revision = await managerRevision(options, state, settings, run);
208
+ if (typeof revision !== 'string') return revision;
200
209
  if (options.schedule)
201
210
  return scheduleRequest(options, revision, settings, run);
202
211
  const rehomed = await rehome(
@@ -238,6 +247,33 @@ export async function managerSync(
238
247
  });
239
248
  }
240
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
+
241
277
  /** The preview: the read-only report of every machine, and the plan of what
242
278
  * a sync would change on them.
243
279
  */
@@ -262,6 +298,11 @@ async function preview(
262
298
  .map((node) => node.node)
263
299
  .filter((node): node is string => typeof node === 'string'),
264
300
  sources: planSources(settings.workspace, context.root),
301
+ restartBusy: options.restartBusy === true,
302
+ definitions: {
303
+ applied: appliedDefinition(),
304
+ selected: canonicalDefinition,
305
+ },
265
306
  },
266
307
  context.run
267
308
  );
@@ -272,6 +313,16 @@ async function preview(
272
313
  };
273
314
  }
274
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
+
275
326
  interface ApplyContext {
276
327
  settings: Settings;
277
328
  run: Runner;
@@ -288,15 +339,28 @@ async function authorizeAndApply(
288
339
  context: ApplyContext
289
340
  ): Promise<CommandOutcome> {
290
341
  const { settings, run, revision } = context;
291
- const request = { ...options, revision, apply: true, yes: true };
292
- if (!interactive(options)) return applyLocked(root, state, request, context);
293
- 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);
294
358
  // Needed to hand each node its own Vault token; nodes report any gap.
295
359
  await authorizeManager({ interactive: true }, run);
296
360
  return applyLocked(
297
361
  root,
298
362
  state,
299
- { ...request, interactiveAuthorization: true },
363
+ { ...request, enrollment, interactiveAuthorization: true },
300
364
  context
301
365
  );
302
366
  }
@@ -317,6 +381,7 @@ function applyLocked(
317
381
  ...(definitionSource().kind === 'file'
318
382
  ? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
319
383
  : {}),
384
+ scoped: Boolean(request.node?.length),
320
385
  });
321
386
  return withMachineLock(root, async () => {
322
387
  record.approve();
@@ -358,6 +423,12 @@ async function offManager(
358
423
  forward: ForwardSpawn | undefined
359
424
  ): Promise<CommandOutcome> {
360
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
+ );
361
432
  const revision =
362
433
  options.revision ??
363
434
  packageRevision() ??
@@ -414,11 +485,42 @@ function validate(options: FleetSyncOptions): void {
414
485
  '--revision requires an exact lowercase 40-character commit SHA.'
415
486
  );
416
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);
417
494
  if (options.dryRun && (options.apply || options.yes))
418
495
  throw new FleetUsageError(
419
496
  '--dry-run cannot be combined with apply confirmation.'
420
497
  );
421
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
+
422
524
  function validateOnly(options: FleetSyncOptions): void {
423
525
  if (options.only !== undefined && options.only !== 'accounts')
424
526
  throw new FleetUsageError('--only takes one scope: accounts.');
@@ -450,6 +552,17 @@ async function onlyAccounts(
450
552
  return syncAccounts(options, settings, { revision, run });
451
553
  }
452
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
+
453
566
  function interactive(options: FleetSyncOptions): boolean {
454
567
  return (
455
568
  ![
@@ -516,6 +629,24 @@ function validateSchedule(schedule: unknown): void {
516
629
  throw new FleetUsageError('Unknown manager schedule action.');
517
630
  }
518
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
+
519
650
  async function selectedRevision(
520
651
  explicit: string | undefined,
521
652
  approved: string | undefined,
@@ -1,15 +1,29 @@
1
1
  import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
3
+ import { dirname, join } from 'node:path';
4
4
  import {
5
5
  DefinitionError,
6
6
  parseDefinition,
7
+ sshTarget,
7
8
  type DefinitionProblem,
8
9
  type FleetDefinition,
9
10
  } from '../definition';
10
11
  import { FleetUsageError } from '../errors';
11
12
  import { PACKAGE_ROOT } from '../package-info';
12
- import { ansibleCommand } from '../platform/ansible';
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';
13
27
  import { runCommand } from '../runner';
14
28
  import type { Check, Runner } from '../types';
15
29
  import type { CommandOutcome } from './contracts';
@@ -26,6 +40,8 @@ export interface FleetValidateServices {
26
40
  platform: NodeJS.Platform;
27
41
  /** The package's Ansible playbooks, relative to `root`. */
28
42
  root: string;
43
+ /** Runs a command `workerRecords` names. */
44
+ records?: RecordCommand;
29
45
  }
30
46
 
31
47
  const SERVICES: FleetValidateServices = {
@@ -42,20 +58,34 @@ const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
42
58
  * worker in the `workers` group in the order it is declared. Host names are
43
59
  * the fleet ids; a machine is reached as fleet-sync reaches it.
44
60
  */
45
- export function fleetInventory(definition: FleetDefinition): unknown {
61
+ export function fleetInventory(
62
+ definition: FleetDefinition,
63
+ knownHosts = knownHostsPath()
64
+ ): unknown {
46
65
  const workers = Object.fromEntries(
47
- definition.workers.map((worker) => [
48
- worker.id,
49
- {
50
- ansible_host: worker.ssh,
51
- ...(worker.network
52
- ? {
53
- ansible_user: worker.network.user,
54
- ansible_port: worker.network.sshPort,
55
- }
56
- : {}),
57
- },
58
- ])
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
+ })
59
89
  );
60
90
  return {
61
91
  all: {
@@ -89,7 +119,13 @@ export async function fleetValidate(
89
119
  );
90
120
  let definition: FleetDefinition;
91
121
  try {
92
- definition = parseDefinition(await readJson(path));
122
+ definition = parseDefinition(
123
+ expandRecords(
124
+ await readJson(path),
125
+ dirname(path),
126
+ services.records ?? runRecordCommand
127
+ )
128
+ );
93
129
  } catch (error) {
94
130
  if (error instanceof DefinitionError) return invalid(path, error.problem);
95
131
  throw error;
@@ -164,14 +200,16 @@ async function ansibleChecks(
164
200
  const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
165
201
  const inventory = join(directory, 'inventory.json');
166
202
  try {
167
- await writeFile(inventory, JSON.stringify(fleetInventory(definition)), {
168
- mode: 0o600,
169
- });
203
+ await writeFile(
204
+ inventory,
205
+ JSON.stringify(fleetInventory(definition, knownHostsPath(services.env))),
206
+ { mode: 0o600 }
207
+ );
170
208
  const playbook = ansibleCommand(services.platform, services.root);
171
209
  const tool = (name: string) => [...playbook.slice(0, -1), name];
172
210
  const listed = await services.run(
173
211
  [...tool('ansible-inventory'), '-i', inventory, '--list'],
174
- { timeoutMs: 120_000 }
212
+ { timeoutMs: 120_000, env: ansibleConfig(services.root) }
175
213
  );
176
214
  const checks: Check[] = [
177
215
  listed.ok
@@ -200,7 +238,7 @@ async function ansibleChecks(
200
238
  '--syntax-check',
201
239
  join(services.root, file),
202
240
  ],
203
- { timeoutMs: 120_000 }
241
+ { timeoutMs: 120_000, env: ansibleConfig(services.root) }
204
242
  );
205
243
  if (!result.ok) failed.push(file);
206
244
  }
@@ -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
  }
package/src/cli/fleet.ts CHANGED
@@ -16,13 +16,37 @@ import { probeSource } from './probe-source';
16
16
  import { executeRepairCommand } from './repair';
17
17
  import { scheduleCheck } from '../schedule-check';
18
18
  import { readHistory } from '../state/history';
19
+ import { withUsage } from '../worker-activity';
20
+ import { activeNodes, appendUsage } from './worker-usage-report';
21
+ import { fleet, secret } from '../fleet-config';
22
+ import { appendTailnetLeftovers } from '../tailnet-cleanup';
23
+ import { fleetTailnetControl, type TailnetControl } from '../tailnet-control';
24
+ import { packageInfo } from '../package-info';
25
+ import { managerRuns } from './fleet-sync-checkout';
26
+ import { existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
19
28
 
20
29
  export interface FleetServices {
21
30
  run: Runner;
22
31
  source: () => Promise<string>;
32
+ /** The tailnet control plane fleet.json declares, asked for devices
33
+ * tagged as workers that no declared worker accounts for. Absent, or null,
34
+ * fleet-status says nothing about them.
35
+ */
36
+ tailnet?: () => Promise<TailnetControl | null>;
37
+ /** The commit the running package names; absent or null in a checkout. */
38
+ revision?: string | null;
39
+ /** Where the manager keeps its fleet-owned checkout of each revision. */
40
+ runs?: string;
23
41
  }
24
42
 
25
- const SERVICES: FleetServices = { run: runCommand, source: probeSource };
43
+ const SERVICES: FleetServices = {
44
+ run: runCommand,
45
+ source: probeSource,
46
+ tailnet: () => fleetTailnetControl(fleet.tailnet, secret),
47
+ revision: packageInfo().revision,
48
+ runs: managerRuns(),
49
+ };
26
50
 
27
51
  export async function executeFleet(
28
52
  command: string,
@@ -114,11 +138,14 @@ async function diagnose(
114
138
  settings: Settings & { missingRoster: boolean },
115
139
  services: FleetServices
116
140
  ): Promise<CommandOutcome> {
117
- const source = nodes.length ? await services.source() : undefined;
141
+ // A parked worker is not contacted; it is listed as parked.
142
+ const active = activeNodes(nodes);
143
+ const source = active.length ? await services.source() : undefined;
144
+ const root = statusRoot(settings, services);
118
145
  const reports = await doctor(
119
- nodes,
146
+ active,
120
147
  {
121
- root: settings.workspace,
148
+ root,
122
149
  local: options.local === true,
123
150
  required: capabilities(options.require),
124
151
  timeoutMs: timeout(options),
@@ -126,11 +153,23 @@ async function diagnose(
126
153
  },
127
154
  services.run
128
155
  );
156
+ explainMissingCheckout(reports, root);
129
157
  if (!options.local && settings.missingRoster) reports.push(missingRoster());
130
158
  const schedule = scheduleCheck(readHistory(), new Date());
131
159
  if (schedule) reports[0]?.checks.push(schedule);
160
+ // What each worker is doing, unless only this manager is checked.
161
+ const usage = await usageOf(
162
+ reports,
163
+ active,
164
+ services.run,
165
+ skipsFleet(options, settings)
166
+ );
167
+ // Devices tagged as workers that are no declared worker (#123).
168
+ if (!skipsFleet(options, settings))
169
+ await appendTailnetLeftovers(reports, fleet, services.tailnet, nodes);
132
170
  const report = {
133
171
  ...makeReport('doctor', reports),
172
+ ...(usage ?? {}),
134
173
  coverage: {
135
174
  scope: options.local
136
175
  ? 'local'
@@ -146,10 +185,53 @@ async function diagnose(
146
185
  return {
147
186
  exitCode: reportExitCode(report, options.strict === true),
148
187
  data: report,
149
- text: renderReport(report),
188
+ text: withUsage(renderReport(report), usage),
189
+ };
190
+ }
191
+
192
+ /** What the manager and the workers are measured against. The published
193
+ * package holds no Git history and no bun.lock, and whatever repository
194
+ * surrounds it is the consumer's project: from a package it is the
195
+ * fleet-owned checkout fleet-sync made of the commit the package names. A
196
+ * checkout (development) is its own reference.
197
+ */
198
+ function statusRoot(settings: Settings, services: FleetServices): string {
199
+ const { revision, runs } = services;
200
+ return revision && runs ? join(runs, revision) : settings.workspace;
201
+ }
202
+
203
+ /** The manager has not run fleet-sync at this package's commit yet (a
204
+ * checkout is its own reference, so it always exists).
205
+ */
206
+ function explainMissingCheckout(reports: NodeReport[], root: string): void {
207
+ if (existsSync(root)) return;
208
+ const check = reports[0]?.checks.find((item) => item.id === 'workspace');
209
+ if (!check) return;
210
+ check.summary =
211
+ 'This manager has no fleet-owned checkout of the commit this package names; it is made by fleet-sync.';
212
+ check.remedy = {
213
+ action:
214
+ 'Run devfleet fleet-sync from the project that holds your fleet.json, then fleet-status again.',
150
215
  };
151
216
  }
152
217
 
218
+ /** Only this manager is checked: what is said of the fleet is skipped. */
219
+ function skipsFleet(
220
+ options: CliOptions,
221
+ settings: { missingRoster: boolean }
222
+ ): boolean {
223
+ return Boolean(options.local || settings.missingRoster);
224
+ }
225
+
226
+ function usageOf(
227
+ reports: NodeReport[],
228
+ nodes: FleetNode[],
229
+ run: Runner,
230
+ skip: boolean
231
+ ) {
232
+ return skip ? Promise.resolve(null) : appendUsage(reports, nodes, { run });
233
+ }
234
+
153
235
  function missingRoster(): NodeReport {
154
236
  return {
155
237
  node: 'fleet',
@@ -3,6 +3,7 @@ import { verifyT3Endpoint } from '../t3-endpoint';
3
3
  import type { Check, NodeReport, Runner } from '../types';
4
4
  import { fleet as definition } from '../fleet-config';
5
5
  import { enrolledRoster } from '../definition';
6
+ import { readParked } from '../state/workers';
6
7
  import type { Tailnet } from '../tailscale';
7
8
  import type { FleetNode } from '../types';
8
9
  import {
@@ -12,6 +13,7 @@ import {
12
13
  } from '../monitoring/beszel';
13
14
  import { hubAccess } from '../monitoring/hub-access';
14
15
  import { hubUrl } from '../topology';
16
+ import { leftoverSystems, listSystems } from '../monitoring/systems';
15
17
 
16
18
  export interface DashboardAccess {
17
19
  (hub: string): Promise<{ request: BeszelRequest }>;
@@ -76,6 +78,7 @@ async function nodeEvidence(
76
78
  ) ?? dashboard;
77
79
  report.checks.push(...evidence.checks);
78
80
  }
81
+ dashboard.checks.push(...(await leftovers(request)));
79
82
  } catch (error) {
80
83
  dashboard.checks.push({
81
84
  id: 'beszel:access',
@@ -92,6 +95,21 @@ async function nodeEvidence(
92
95
  }
93
96
  }
94
97
 
98
+ /** Systems the hub watches that no declared worker (parked ones included)
99
+ * and not the manager is named after. A hub whose list cannot be read says
100
+ * nothing here; its access is reported above.
101
+ */
102
+ async function leftovers(request: BeszelRequest): Promise<Check[]> {
103
+ try {
104
+ return leftoverSystems(await listSystems(request), [
105
+ definition.manager.id,
106
+ ...definition.workers.map((worker) => worker.id),
107
+ ]);
108
+ } catch {
109
+ return [];
110
+ }
111
+ }
112
+
95
113
  async function hubReachable(
96
114
  hub: string,
97
115
  fetcher: typeof fetch
@@ -155,7 +173,13 @@ export function managedHealthScope(
155
173
  roster: FleetNode[],
156
174
  includeManager = true
157
175
  ) {
158
- const canonical = definition;
176
+ // A parked worker is off on purpose: it is not audited, not missing and
177
+ // its device is not expected online (fleet-status lists it as parked).
178
+ const parked = readParked();
179
+ const canonical = {
180
+ ...definition,
181
+ workers: definition.workers.filter((entry) => !parked[entry.id]),
182
+ };
159
183
  const declared = roster.filter((node) =>
160
184
  canonical.workers.some((entry) => entry.id === node.id)
161
185
  );