@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,21 +1,58 @@
1
1
  import { mkdir, rm } from 'node:fs/promises';
2
2
  import { loadRoster } from './roster';
3
- import { enrolledRoster } from '../definition';
3
+ import { enrolledRoster, sshTarget, type FleetDefinition } from '../definition';
4
4
  import { FleetUsageError } from '../errors';
5
5
  import { dirname } from 'node:path';
6
- import { fleet as definition } from '../fleet-config';
6
+ import { fleet } from '../fleet-config';
7
7
  import { discoverTailnet } from '../tailscale';
8
8
  import { matchComputers } from '../tailnet-matching';
9
9
  import { sshArgs, sshFailure } from '../ssh';
10
10
  import { ensurePrivateRoot } from '../machine/state';
11
- import type { FleetNode, Runner } from '../types';
11
+ import { createWorkerAccount } from '../worker-account';
12
+ import { packagePlaybook, type PackagePlaybook } from '../platform/ansible';
13
+ import type { Check, FleetNode, Runner } from '../types';
12
14
  import { STATE_ROSTER, readRosterText, writeRosterText } from '../state/roster';
15
+ import { readParked } from '../state/workers';
13
16
 
14
- /** Interactive enrollment/resume only. Existing paths and explicit identities
15
- * are preserved. Tailnet candidates never enter the declared membership.
17
+ /** What an enrollment did: the workers whose roster entry it added or
18
+ * completed, and the checks that say why another one was not enrolled (or
19
+ * what was done to reach it).
16
20
  */
17
- export async function enrollManager(path: string, run: Runner): Promise<void> {
18
- const canonical = definition;
21
+ export interface EnrollmentResult {
22
+ enrolled: string[];
23
+ checks: { node: string; check: Check }[];
24
+ }
25
+
26
+ export interface EnrollmentOptions {
27
+ /** A person is at the terminal and sees the list of what is enrolled. */
28
+ interactive: boolean;
29
+ /** A worker not on the tailnet yet joins it during this run: the fleet
30
+ * declares a tailnet control plane that issues single-use keys.
31
+ */
32
+ joinable?: boolean;
33
+ definition?: FleetDefinition;
34
+ /** Applies a playbook of the package from the manager. */
35
+ playbook?: PackagePlaybook;
36
+ /** When each parked worker was parked; default the fleet state's. */
37
+ parked?: Record<string, string>;
38
+ }
39
+
40
+ /** Enrolls declared workers, and resumes earlier enrollments. Existing paths
41
+ * and explicit identities are preserved. Tailnet candidates never enter the
42
+ * declared membership. An interactive run enrolls what a fresh
43
+ * host-key-verified SSH session reaches. A run without a person enrolls a
44
+ * worker only when every fact is verified: its pinned host key matched (the
45
+ * session is strict), `sudo -n` works, and exactly one tailnet device matches
46
+ * it, or none when this run joins it to the tailnet itself. Anything
47
+ * ambiguous stays out, with the code that says why; nothing prompts.
48
+ */
49
+ export async function enrollManager(
50
+ path: string,
51
+ run: Runner,
52
+ options: EnrollmentOptions = { interactive: true }
53
+ ): Promise<EnrollmentResult> {
54
+ const canonical = options.definition ?? fleet;
55
+ const result: EnrollmentResult = { enrolled: [], checks: [] };
19
56
  const original = await existingEnrollment(path);
20
57
  const saved =
21
58
  original === null
@@ -23,54 +60,178 @@ export async function enrollManager(path: string, run: Runner): Promise<void> {
23
60
  : enrolledRoster(canonical, await loadRoster(path)).nodes;
24
61
  const nodes: FleetNode[] = canonical.workers.map((node) => ({
25
62
  id: node.id,
26
- ssh: node.ssh,
63
+ ssh: sshTarget(node),
27
64
  workspace: '/',
28
65
  required: ['t3', 'toolchain', 'workstation'],
29
66
  }));
67
+ // A parked worker is off on purpose: it is enrolled once it is resumed.
68
+ const parked = options.parked ?? readParked();
30
69
  const pending = nodes.filter(
31
- (node) => !saved.find((entry) => entry.id === node.id)?.tailscaleId
70
+ (node) =>
71
+ !parked[node.id] &&
72
+ !saved.find((entry) => entry.id === node.id)?.tailscaleId
32
73
  );
33
- if (!pending.length) return;
74
+ if (!pending.length) return result;
34
75
  const tailnet = await discoverTailnet(run);
35
76
  const matches = await matchComputers(nodes, tailnet.computers, run);
36
77
  const enrolled: FleetNode[] = [];
37
78
  for (const node of pending) {
38
- const peers = tailnet.computers.filter((peer) =>
39
- matches.get(peer.id)?.some((candidate) => candidate.id === node.id)
40
- );
41
- if (
42
- ambiguous(
43
- peers.map((peer) => peer.id),
44
- matches
45
- )
46
- )
47
- continue;
48
- const connection = await inspectConnection(node, peers[0]?.id, run);
79
+ // A worker already in the roster reports its own state in the run.
80
+ const known = saved.some((entry) => entry.id === node.id);
81
+ const connection = await enrollOne(node, {
82
+ run,
83
+ options,
84
+ peers: tailnet.computers
85
+ .filter((peer) =>
86
+ matches.get(peer.id)?.some((candidate) => candidate.id === node.id)
87
+ )
88
+ .map((peer) => peer.id),
89
+ matches,
90
+ user: canonical.workers.find((worker) => worker.id === node.id)?.network
91
+ ?.user,
92
+ note: (check) => {
93
+ if (!known) result.checks.push({ node: node.id, check });
94
+ },
95
+ });
49
96
  if (connection) enrolled.push(connection);
50
97
  }
51
98
  const merged = mergeEnrollment(saved, enrolled);
52
- if (JSON.stringify(merged) === JSON.stringify(saved)) return;
99
+ if (JSON.stringify(merged) === JSON.stringify(saved)) return result;
53
100
  // Only canonical nodes reached over host-key-verified SSH get here, so
54
101
  // enrollment needs no extra confirmation; the list is still shown.
55
- process.stderr.write(
56
- `Enrolling verified SSH connections for canonical targets:\n${enrolled.map((node) => ` ${node.id} → ${node.ssh}; device ${enrollmentIdentity(node)}`).join('\n')}\nMissing targets remain visible and are never replaced by discovered peers.\n`
57
- );
102
+ if (options.interactive)
103
+ process.stderr.write(
104
+ `Enrolling verified SSH connections for canonical targets:\n${enrolled.map((node) => ` ${node.id} → ${node.ssh}; device ${enrollmentIdentity(node)}`).join('\n')}\nMissing targets remain visible and are never replaced by discovered peers.\n`
105
+ );
58
106
  await persistEnrollment(path, original, merged);
107
+ result.enrolled = merged
108
+ .filter(
109
+ (node) =>
110
+ JSON.stringify(node) !==
111
+ JSON.stringify(saved.find((entry) => entry.id === node.id))
112
+ )
113
+ .map((node) => node.id);
114
+ return result;
59
115
  }
60
116
 
117
+ /** One worker's connection, when every fact this run needs is verified. */
118
+ async function enrollOne(
119
+ node: FleetNode,
120
+ context: {
121
+ run: Runner;
122
+ options: EnrollmentOptions;
123
+ /** The tailnet devices that match this worker. */
124
+ peers: string[];
125
+ matches: Map<string, FleetNode[]>;
126
+ user: string | undefined;
127
+ note: (check: Check) => void;
128
+ }
129
+ ): Promise<FleetNode | null> {
130
+ const { run, options, peers, note } = context;
131
+ if (ambiguous(peers, context.matches)) {
132
+ if (!options.interactive) note(IDENTITY_CONFLICT);
133
+ return null;
134
+ }
135
+ if (!options.interactive && !peers.length && !options.joinable) {
136
+ note(NOT_ON_TAILNET);
137
+ return null;
138
+ }
139
+ const connection = await inspectConnection(node, peers[0], {
140
+ run,
141
+ options,
142
+ user: context.user,
143
+ note,
144
+ quiet: !options.interactive,
145
+ });
146
+ if (!connection || options.interactive) return connection;
147
+ const privilege = await passwordlessSudo(connection, run);
148
+ if (privilege) note(privilege);
149
+ return privilege ? null : connection;
150
+ }
151
+
152
+ const IDENTITY_CONFLICT: Check = {
153
+ id: 'tailscale:identity',
154
+ status: 'fail',
155
+ summary: 'TAILSCALE_IDENTITY_CONFLICT',
156
+ remedy: {
157
+ action:
158
+ 'More than one tailnet device matches this worker, so it was not enrolled. Remove the stale device from the tailnet, then rerun fleet-sync.',
159
+ },
160
+ };
161
+
162
+ const NOT_ON_TAILNET: Check = {
163
+ id: 'tailscale:enrolled-identity',
164
+ status: 'fail',
165
+ summary: 'TAILSCALE_ENROLLMENT_REQUIRED',
166
+ remedy: {
167
+ action:
168
+ 'No tailnet device matches this worker and fleet.json declares no tailnet control plane to join it with, so a run without a person did not enroll it. Declare tailnet with the secret tailnet-admin, join the machine yourself, or run fleet-sync once in a terminal.',
169
+ },
170
+ };
171
+
172
+ /** A run without a person never prompts for sudo: the account has it, or the
173
+ * worker is not enrolled.
174
+ */
175
+ async function passwordlessSudo(
176
+ node: FleetNode,
177
+ run: Runner
178
+ ): Promise<Check | null> {
179
+ const result = await run([...sshArgs(node), 'sudo -n true'], {
180
+ timeoutMs: 15_000,
181
+ });
182
+ if (result.ok) return null;
183
+ if (result.reason !== 'exit' || !/sudo:/i.test(result.stderr))
184
+ return sshFailure(result);
185
+ return {
186
+ id: 'bootstrap:privilege',
187
+ status: 'fail',
188
+ summary: 'SUDO_AUTHORIZATION_REQUIRED',
189
+ remedy: {
190
+ action:
191
+ 'The worker account has no passwordless sudo, so a run without a person did not enroll it. Grant it (a fleet-node-seed seed does), or run fleet-sync once in a terminal to authorize it.',
192
+ },
193
+ };
194
+ }
195
+
196
+ /** The account's home over a fresh strict session. A machine whose image
197
+ * lets only root in refuses the declared account: it is created once over
198
+ * the root login, and the session is opened again.
199
+ */
61
200
  async function inspectConnection(
62
201
  node: FleetNode,
63
202
  peerId: string | undefined,
64
- run: Runner
203
+ context: {
204
+ run: Runner;
205
+ options: EnrollmentOptions;
206
+ user: string | undefined;
207
+ note: (check: Check) => void;
208
+ quiet: boolean;
209
+ }
65
210
  ): Promise<FleetNode | null> {
66
- const home = await run([...sshArgs(node), 'printf "%s" "$HOME"'], {
67
- timeoutMs: 20_000,
68
- });
211
+ const { run } = context;
212
+ const probe = () =>
213
+ run([...sshArgs(node), 'printf "%s" "$HOME"'], { timeoutMs: 20_000 });
214
+ let home = await probe();
215
+ if (!home.ok && sshFailure(home).id === 'ssh:auth' && context.user) {
216
+ const account = await createWorkerAccount(
217
+ { id: node.id, ssh: node.ssh, login: 'root' },
218
+ context.user,
219
+ context.options.playbook ?? packagePlaybook(run)
220
+ );
221
+ // Only an account that was created is reported here: a root login that
222
+ // is refused as well stays the SSH failure below.
223
+ if (account.status === 'pass') {
224
+ context.note(account);
225
+ home = await probe();
226
+ }
227
+ }
69
228
  if (!home.ok) {
70
229
  const check = sshFailure(home);
71
- process.stderr.write(
72
- `[${node.id}] ${check.id}: ${check.summary} ${check.remedy?.action ?? ''}\n`
73
- );
230
+ if (context.quiet) context.note(check);
231
+ else
232
+ process.stderr.write(
233
+ `[${node.id}] ${check.id}: ${check.summary} ${check.remedy?.action ?? ''}\n`
234
+ );
74
235
  return null;
75
236
  }
76
237
  if (!/^\/[^\x00-\x1f\x7f]{1,4000}$/.test(home.stdout)) return null;
@@ -104,14 +104,20 @@ export function forwardedFlags(
104
104
  ): string[] {
105
105
  const flags = revision ? ['--revision', revision] : [];
106
106
  for (const node of options.node ?? []) flags.push('--node', node);
107
+ if (options.canary) flags.push('--canary', options.canary);
107
108
  if (options.schedule) flags.push('--schedule', options.schedule);
108
109
  const switches: [boolean | undefined, string][] = [
109
110
  [options.all, '--all'],
111
+ [options.vaultPasswordLogin, '--vault-password-login'],
112
+ [options.restartBusy, '--restart-busy'],
110
113
  [options.dryRun, '--dry-run'],
111
114
  [options.apply, '--apply'],
112
115
  [options.yes, '--yes'],
113
116
  [options.json, '--json'],
114
117
  [options.nonInteractive, '--non-interactive'],
118
+ [options.maintenanceReboot, '--maintenance-reboot'],
119
+ [options.force, '--force'],
120
+ [options.takeOver, '--take-over'],
115
121
  ];
116
122
  for (const [on, flag] of switches) if (on) flags.push(flag);
117
123
  return flags;
@@ -14,11 +14,15 @@ import { enabled, managerChecks } from '../ide/orchestrate';
14
14
  import { idesOf } from '../ide/definition';
15
15
  import { convergeGitIdentity, gitChecks } from '../git-identity';
16
16
  import { ansibleCommand } from '../platform/ansible';
17
+ import { runAnsibleWithReceipt } from '../platform/ansible-receipt';
18
+ import macos from '../../ansible/macos/policy.json';
19
+ import { scheduleIntervals } from '../schedule-check';
17
20
  import { convergeGithub } from '../github/converge';
18
21
  import { PACKAGE_ROOT } from '../package-info';
19
22
  import { managerHealth } from '../manager-health';
23
+ import { holdManagerVault } from './fleet-sync-vault';
20
24
  import { readFileSync } from 'node:fs';
21
- import { homedir } from 'node:os';
25
+ import { homedir, userInfo } from 'node:os';
22
26
  import {
23
27
  MACOS_MANAGER,
24
28
  managerHost,
@@ -47,9 +51,22 @@ export async function convergeManager(
47
51
  if (identity.status === 'pass') {
48
52
  // Launchers and scheduled jobs on the manager read the installed copy.
49
53
  if (input.apply) installConfig();
50
- checks.push(await managerPower(run, input.workspace, host));
54
+ checks.push(
55
+ input.apply
56
+ ? await convergeManagerPolicy(run, definition, host)
57
+ : await managerPower(run, input.workspace, host)
58
+ );
59
+ if (input.apply)
60
+ checks.push(await managerPower(run, input.workspace, host));
51
61
  checks.push(await managerBootRecovery(run, input.workspace, host));
52
62
  checks.push(await managerDocker(run));
63
+ // The renewal job is a launch agent: macOS managers only.
64
+ if (host.platform === 'macOS')
65
+ checks.push(
66
+ ...(await attempt('manager:vault-renewal', () =>
67
+ holdManagerVault(run, { apply: input.apply, root: PACKAGE_ROOT })
68
+ ))
69
+ );
53
70
  // Each enabled IDE: its settings, pinned version and policy.
54
71
  checks.push(
55
72
  ...(await managerChecks(enabled(idesOf(definition), IDES), {
@@ -202,7 +219,8 @@ export async function managerAgentProfiles(
202
219
  export async function managerIdentity(
203
220
  id: string,
204
221
  run: Runner,
205
- host: ManagerHost = MACOS_MANAGER
222
+ host: ManagerHost = MACOS_MANAGER,
223
+ platform: NodeJS.Platform = process.platform
206
224
  ): Promise<Check> {
207
225
  const local = (await host.localHostName(run)) ?? '';
208
226
  return local === id
@@ -216,26 +234,34 @@ export async function managerIdentity(
216
234
  status: 'fail',
217
235
  summary: 'FLEET_NOT_MANAGER',
218
236
  remedy: {
219
- action: `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`,
237
+ action:
238
+ platform === 'darwin'
239
+ ? `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`
240
+ : `This machine runs ${platform}, and the manager is always a macOS machine. Run fleet-sync on the manager (${id}), or from a worker that can SSH to it.`,
220
241
  },
221
242
  };
222
243
  }
223
244
 
224
245
  /** Workers depend on the manager's services, so it never sleeps on AC power.
225
246
  * Measures every setting `ansible/macos/manager.yml` declares, from the same
226
- * policy file the playbook applies.
247
+ * policy file the playbook applies. A setting pmset does not list on AC power
248
+ * is one this Mac does not have (a virtual Mac has no womp): the playbook
249
+ * skips it, so it is not applicable here either.
227
250
  */
228
251
  export async function managerPower(
229
252
  run: Runner,
230
253
  workspace = '.',
231
254
  host: ManagerHost = MACOS_MANAGER
232
255
  ): Promise<Check> {
233
- const drifted = await host.powerDrift(run);
256
+ const { drifted, notApplicable } = await host.powerDrift(run);
234
257
  return drifted.length === 0
235
258
  ? {
236
259
  id: 'manager:power',
237
260
  status: 'pass',
238
- summary: 'The manager never sleeps on AC power.',
261
+ summary:
262
+ notApplicable.length === 0
263
+ ? 'The manager never sleeps on AC power.'
264
+ : `The manager never sleeps on AC power; not applicable on this Mac: ${notApplicable.join(' ')}.`,
239
265
  }
240
266
  : {
241
267
  id: 'manager:power',
@@ -244,7 +270,7 @@ export async function managerPower(
244
270
  ? 'MANAGER_SLEEPS_ON_POWER'
245
271
  : `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
246
272
  remedy: {
247
- action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Apply the manager power policy once, as an administrator: cd ${workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`,
273
+ action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Grant the manager its power settings once, as an administrator; every fleet-sync then applies the policy with no prompt: cd ${workspace} && ${ansibleCommand().join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml`,
248
274
  },
249
275
  };
250
276
  }
@@ -283,6 +309,95 @@ export async function managerBootRecovery(
283
309
  };
284
310
  }
285
311
 
312
+ /** The receipt of the manager's own playbook, beside the workers' ones. */
313
+ export const MANAGER_RECEIPT = 'macos-manager';
314
+
315
+ /** On every applying sync the manager applies `ansible/macos/manager.yml`
316
+ * like a worker applies its baseline: in check mode first, then for real,
317
+ * with a receipt that skips an unchanged playbook while its postconditions
318
+ * hold, and a full re-apply at least once a day. It never asks for a
319
+ * password: what needs root goes through the grant manager-privilege.yml
320
+ * installs once, and the playbook skips it without the grant.
321
+ */
322
+ export async function convergeManagerPolicy(
323
+ run: Runner,
324
+ definition: Pick<FleetDefinition, 'manager'>,
325
+ host: ManagerHost = MACOS_MANAGER,
326
+ {
327
+ root = PACKAGE_ROOT,
328
+ scratch,
329
+ receipts,
330
+ uid = userInfo().uid,
331
+ now,
332
+ }: {
333
+ root?: string;
334
+ scratch?: string;
335
+ receipts?: string;
336
+ uid?: number;
337
+ now?: number;
338
+ } = {}
339
+ ): Promise<Check> {
340
+ if (host.platform !== 'macOS')
341
+ return {
342
+ id: 'manager:playbook',
343
+ status: 'not-applicable',
344
+ summary: 'The manager playbook is for a macOS manager.',
345
+ };
346
+ const result = await runAnsibleWithReceipt(
347
+ run,
348
+ join(root, 'ansible/macos/manager.yml'),
349
+ {
350
+ cwd: join(root, 'ansible/macos'),
351
+ timeoutMs: 300_000,
352
+ input: JSON.stringify({
353
+ fleet_watch_minutes: scheduleIntervals(definition.manager).watchMinutes,
354
+ }),
355
+ },
356
+ {
357
+ name: MANAGER_RECEIPT,
358
+ sources: [join(root, 'ansible/macos')],
359
+ checkFirst: true,
360
+ verify: async () =>
361
+ (await host.powerDrift(run)).drifted.length === 0 &&
362
+ (await watchLoaded(run, uid)),
363
+ ...(receipts ? { directory: receipts } : {}),
364
+ ...(scratch ? { scratch } : {}),
365
+ ...(now === undefined ? {} : { now }),
366
+ }
367
+ );
368
+ if (result.ok)
369
+ return {
370
+ id: 'manager:playbook',
371
+ status: 'pass',
372
+ summary: result.cached
373
+ ? 'The manager playbook is unchanged since it was last applied, and its postconditions hold.'
374
+ : 'The manager playbook was checked, then applied.',
375
+ };
376
+ return {
377
+ id: 'manager:playbook',
378
+ status: 'fail',
379
+ summary:
380
+ result.stage === 'check'
381
+ ? 'MANAGER_PLAYBOOK_CHECK_FAILED'
382
+ : 'MANAGER_PLAYBOOK_FAILED',
383
+ remedy: {
384
+ action: `Run the playbook in check mode on the manager and read the failing task: ${ansibleCommand().join(' ')} --check --diff ${join(root, 'ansible/macos/manager.yml')}. Nothing in it needs a password; what needs root comes from the one grant (manager-privilege.yml).`,
385
+ },
386
+ };
387
+ }
388
+
389
+ /** Whether launchd holds the fleet-watch job manager.yml installs. Reads only. */
390
+ export async function watchLoaded(run: Runner, uid: number): Promise<boolean> {
391
+ return (
392
+ await run(
393
+ ['/bin/launchctl', 'print', `gui/${uid}/${macos.fleetWatch.label}`],
394
+ {
395
+ timeoutMs: 10_000,
396
+ }
397
+ )
398
+ ).ok;
399
+ }
400
+
286
401
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
287
402
  export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
288
403
  const { vars } = Bun.TOML.parse(
@@ -51,17 +51,19 @@ const COLUMNS: { title: string; matches: (id: string) => boolean }[] = [
51
51
  },
52
52
  ];
53
53
 
54
- const ORDER: Status[] = ['fail', 'warn', 'skip', 'pass'];
54
+ const ORDER: Status[] = ['fail', 'warn', 'skip', 'not-applicable', 'pass'];
55
55
  const WORD: Record<Status, string> = {
56
56
  fail: 'FAIL',
57
57
  warn: 'WARN',
58
58
  skip: 'SKIP',
59
+ 'not-applicable': 'N/A',
59
60
  pass: 'PASS',
60
61
  };
61
62
  const SYMBOL: Record<Status, string> = {
62
63
  fail: '✖',
63
64
  warn: '▲',
64
65
  skip: '○',
66
+ 'not-applicable': '–',
65
67
  pass: '✔',
66
68
  };
67
69
 
@@ -104,7 +106,7 @@ export function managerText(
104
106
  const lines: string[] = [header(c, report, failing, options)];
105
107
  lines.push('', ...grid(c, report.nodes, mark, options.color === true));
106
108
  lines.push('', totals(c, counts, options.color === true));
107
- for (const status of ['fail', 'warn', 'skip'] as const) {
109
+ for (const status of ['fail', 'warn', 'skip', 'not-applicable'] as const) {
108
110
  const problems = groupProblems(
109
111
  all.filter((entry) => entry.check.status === status)
110
112
  );
@@ -120,6 +122,8 @@ export function managerText(
120
122
  lines.push(
121
123
  `${c.dim('report ')} ${options.reportPath} ${c.dim('(full JSON; or rerun with --json)')}`
122
124
  );
125
+ if (report.enrolled?.length)
126
+ lines.push(`${c.dim('enrolled ')} ${report.enrolled.join(', ')}`);
123
127
  if (report.candidates)
124
128
  lines.push(
125
129
  `${c.dim('tailnet ')} ${report.candidates} unmanaged device(s) seen; discovery never enrolls them`
@@ -138,10 +142,11 @@ export function stableUrls(text: string): string {
138
142
  return text.replace(TAILNET_URL, 'its private tailnet address');
139
143
  }
140
144
 
141
- const SECTION: Record<'fail' | 'warn' | 'skip', string> = {
145
+ const SECTION: Record<'fail' | 'warn' | 'skip' | 'not-applicable', string> = {
142
146
  fail: 'Failing — fix these',
143
147
  warn: 'Warnings — outside the fleet; sync still succeeds',
144
148
  skip: 'Not observable on this hardware',
149
+ 'not-applicable': 'Not applicable to this machine — with the reason',
145
150
  };
146
151
 
147
152
  function header(
@@ -268,22 +273,32 @@ function totals(
268
273
  counts: Record<Status, number>,
269
274
  color: boolean
270
275
  ): string {
271
- return ORDER.map((status) => {
272
- const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
273
- // A zero count is not a signal: never show red/yellow for "0 failing".
274
- return counts[status] ? paint(c, status, text) : c.dim(text);
275
- }).join(c.dim(' '));
276
+ // Not-applicable checks are listed only on machines that have some.
277
+ return ORDER.filter((status) => status !== 'not-applicable' || counts[status])
278
+ .map((status) => {
279
+ const text = `${color ? `${SYMBOL[status]} ` : ''}${counts[status]} ${LABEL[status]}`;
280
+ // A zero count is not a signal: never show red/yellow for "0 failing".
281
+ return counts[status] ? paint(c, status, text) : c.dim(text);
282
+ })
283
+ .join(c.dim(' '));
276
284
  }
277
285
 
278
286
  const LABEL: Record<Status, string> = {
279
287
  fail: 'failing',
280
288
  warn: 'warnings',
281
289
  skip: 'skipped',
290
+ 'not-applicable': 'not applicable',
282
291
  pass: 'passing',
283
292
  };
284
293
 
285
294
  function count(checks: Check[]): Record<Status, number> {
286
- const counts: Record<Status, number> = { pass: 0, warn: 0, fail: 0, skip: 0 };
295
+ const counts: Record<Status, number> = {
296
+ pass: 0,
297
+ warn: 0,
298
+ fail: 0,
299
+ skip: 0,
300
+ 'not-applicable': 0,
301
+ };
287
302
  for (const check of checks)
288
303
  counts[
289
304
  blocking(check) || check.status !== 'fail' ? check.status : 'warn'
@@ -295,7 +310,12 @@ function worst(checks: Check[]): Status | null {
295
310
  if (!checks.length) return null;
296
311
  if (checks.some(blocking)) return 'fail';
297
312
  if (
298
- checks.some((check) => check.status !== 'pass' && check.status !== 'skip')
313
+ checks.some(
314
+ (check) =>
315
+ check.status !== 'pass' &&
316
+ check.status !== 'skip' &&
317
+ check.status !== 'not-applicable'
318
+ )
299
319
  )
300
320
  return 'warn';
301
321
  return checks.every((check) => check.status === 'skip') ? 'skip' : 'pass';
@@ -306,7 +326,7 @@ function paint(c: ChalkInstance, status: Status, text: string): string {
306
326
  ? c.red(text)
307
327
  : status === 'warn'
308
328
  ? c.yellow(text)
309
- : status === 'skip'
329
+ : status === 'skip' || status === 'not-applicable'
310
330
  ? c.dim(text)
311
331
  : c.green(text);
312
332
  }