@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
@@ -0,0 +1,186 @@
1
+ import { createConnection, isIP } from 'node:net';
2
+ import { record } from './config';
3
+ import { fleet } from './fleet-config';
4
+ import { workerExposure, type WorkerExposure } from './exposure';
5
+ import { MONITORING_AGENT_PORT } from './worker-exposure';
6
+ import type { Check, NodeReport, Runner } from './types';
7
+
8
+ /** Opens one TCP connection and closes it at once: whether it was accepted. */
9
+ export type Connect = (
10
+ host: string,
11
+ port: number,
12
+ timeoutMs: number
13
+ ) => Promise<boolean>;
14
+
15
+ export const tcpConnect: Connect = (host, port, timeoutMs) =>
16
+ new Promise((resolve) => {
17
+ const socket = createConnection({ host, port });
18
+ const done = (accepted: boolean) => {
19
+ socket.destroy();
20
+ resolve(accepted);
21
+ };
22
+ socket.setTimeout(timeoutMs, () => done(false));
23
+ socket.once('connect', () => done(true));
24
+ socket.once('error', () => done(false));
25
+ });
26
+
27
+ const UNRESTRICTED = 'WORKER_PUBLIC_PORT_UNRESTRICTED';
28
+
29
+ const PRIVATE_V4: [number, number][] = [
30
+ [0x00000000, 8], // this network
31
+ [0x0a000000, 8],
32
+ [0x64400000, 10], // carrier-grade NAT and the tailnet
33
+ [0x7f000000, 8],
34
+ [0xa9fe0000, 16], // link-local
35
+ [0xac100000, 12],
36
+ [0xc0a80000, 16],
37
+ [0xe0000000, 3], // multicast and reserved
38
+ ];
39
+
40
+ /** An address the internet routes to: not private, loopback, link-local,
41
+ * shared (which includes the tailnet), unique-local or multicast. */
42
+ export function isPublicAddress(address: string): boolean {
43
+ if (isIP(address) === 4) {
44
+ const value =
45
+ address
46
+ .split('.')
47
+ .reduce((total, part) => total * 256 + Number(part), 0) >>> 0;
48
+ return !PRIVATE_V4.some(
49
+ ([network, bits]) =>
50
+ value >>> (32 - bits) === (network >>> (32 - bits)) >>> 0
51
+ );
52
+ }
53
+ if (isIP(address) === 6) {
54
+ const lower = address.toLowerCase();
55
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
56
+ if (mapped) return isPublicAddress(mapped[1]!);
57
+ return !(
58
+ lower === '::' ||
59
+ lower === '::1' ||
60
+ /^fe[89ab]/.test(lower) ||
61
+ /^f[cd]/.test(lower) ||
62
+ /^ff/.test(lower)
63
+ );
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /** The public addresses Tailscale reaches a worker at (`Addrs` of its peer in
69
+ * `tailscale status --json`), each once. */
70
+ export function publicEndpoints(
71
+ status: unknown,
72
+ tailscaleName: string
73
+ ): string[] {
74
+ if (!record(status) || !record(status['Peer'])) return [];
75
+ const name = tailscaleName.toLowerCase();
76
+ const peer = Object.values(status['Peer']).find(
77
+ (entry) =>
78
+ record(entry) &&
79
+ typeof entry['DNSName'] === 'string' &&
80
+ entry['DNSName'].replace(/\.$/, '').toLowerCase() === name
81
+ );
82
+ if (!record(peer) || !Array.isArray(peer['Addrs'])) return [];
83
+ const found = new Set<string>();
84
+ for (const entry of peer['Addrs'].slice(0, 32)) {
85
+ if (typeof entry !== 'string') continue;
86
+ const host = entry.replace(/:\d+$/, '').replace(/^\[|\]$/g, '');
87
+ if (isPublicAddress(host)) found.add(host);
88
+ }
89
+ return [...found];
90
+ }
91
+
92
+ /** worker:public-reachability, from the manager: none of the fleet's ports
93
+ * accepts a connection on the worker's public address. Optional: the manager
94
+ * may share the worker's network, so a failure here never blocks readiness;
95
+ * worker:exposure is measured on the worker itself. Names the service and
96
+ * port, never the address.
97
+ */
98
+ export async function publicReachabilityCheck(
99
+ rules: WorkerExposure,
100
+ addresses: string[],
101
+ connect: Connect = tcpConnect
102
+ ): Promise<Check> {
103
+ if (addresses.length === 0)
104
+ return {
105
+ id: 'worker:public-reachability',
106
+ status: 'not-applicable',
107
+ required: false,
108
+ summary:
109
+ 'Tailscale knows no public address for this worker: there is nothing outside the tailnet to try.',
110
+ };
111
+ const ports = [
112
+ ...(rules.policy.publicSsh ? [] : [{ name: 'SSH', port: rules.sshPort }]),
113
+ { name: 'T3', port: rules.t3Port },
114
+ { name: 'the monitoring agent', port: MONITORING_AGENT_PORT },
115
+ ].filter(
116
+ (entry) =>
117
+ !rules.policy.publicPorts.some(
118
+ (open) => open.protocol === 'tcp' && open.port === entry.port
119
+ )
120
+ );
121
+ const accepted: string[] = [];
122
+ for (const entry of ports)
123
+ for (const address of addresses)
124
+ if (await connect(address, entry.port, 3_000)) {
125
+ accepted.push(`${entry.name} (tcp ${entry.port})`);
126
+ break;
127
+ }
128
+ const tried = ports.map((entry) => `${entry.name} (tcp ${entry.port})`);
129
+ if (accepted.length)
130
+ return {
131
+ id: 'worker:public-reachability',
132
+ status: 'fail',
133
+ required: false,
134
+ summary: `${UNRESTRICTED} ${accepted.join(', ')} accepted a connection on the worker's public address.`,
135
+ remedy: {
136
+ action:
137
+ 'Run devfleet fleet-sync, which loads the fleet firewall on the worker, then read worker:exposure for the unit that listens. When the manager shares the worker’s network, confirm from a machine outside it before changing anything.',
138
+ },
139
+ };
140
+ return {
141
+ id: 'worker:public-reachability',
142
+ status: 'pass',
143
+ required: false,
144
+ summary: `No connection was accepted on the worker's public address: ${tried.join(', ')}.`,
145
+ };
146
+ }
147
+
148
+ /** Adds worker:public-reachability to the report of every declared worker
149
+ * the tailnet knows. */
150
+ export async function appendPublicReachability(
151
+ reports: NodeReport[],
152
+ run: Runner,
153
+ connect: Connect = tcpConnect
154
+ ): Promise<void> {
155
+ const status = await run(['tailscale', 'status', '--json'], {
156
+ timeoutMs: 15_000,
157
+ });
158
+ let parsed: unknown = null;
159
+ try {
160
+ parsed = status.ok ? JSON.parse(status.stdout) : null;
161
+ } catch {
162
+ /* Raw status is never reported. */
163
+ }
164
+ for (const machine of fleet.workers) {
165
+ const name = machine.network?.tailscaleName;
166
+ const report = reports.find(
167
+ (entry) => entry.node === name || entry.node === machine.id
168
+ );
169
+ if (!report || !name) continue;
170
+ report.checks.push(
171
+ parsed === null
172
+ ? {
173
+ id: 'worker:public-reachability',
174
+ status: 'skip',
175
+ required: false,
176
+ summary:
177
+ 'Tailscale status is unavailable on the manager, so the public address of this worker is unknown.',
178
+ }
179
+ : await publicReachabilityCheck(
180
+ workerExposure(fleet, machine.id),
181
+ publicEndpoints(parsed, name),
182
+ connect
183
+ )
184
+ );
185
+ }
186
+ }
@@ -24,6 +24,7 @@ import { record } from './config';
24
24
  import { installedDefinition, type FleetDefinition } from './definition';
25
25
  import { cleanCheck, validCheck } from './protocol';
26
26
  import { UBUNTU_RELEASES } from './platform/ubuntu-release';
27
+ import { T3_RESTART_MODES } from './platform/t3-service';
27
28
 
28
29
  export interface BootstrapPayload {
29
30
  revision: string;
@@ -33,7 +34,12 @@ export interface BootstrapPayload {
33
34
  authorizeSudo?: string;
34
35
  /** The fleet configuration every node keeps at ~/.config/dev-fleet/fleet.json. */
35
36
  config?: string;
37
+ /** `fleet-sync --restart-busy`: a worker's T3 restarts for a new release or
38
+ * policy even while agent turns run. By default it waits for idle.
39
+ */
40
+ restartBusy?: boolean;
36
41
  }
42
+
37
43
  /** No manager tool installation; Git/source preparation is part of apply approval. */
38
44
  export async function prepareBootstrap(
39
45
  root: string,
@@ -107,16 +113,21 @@ async function bundleRevision(
107
113
  await unlink(path).catch(() => undefined);
108
114
  }
109
115
  }
116
+ /** `tailnetKey` is a single-use enrolment key for a worker not on the tailnet
117
+ * yet. It travels on the session's stdin, after the configuration, and only
118
+ * to a bootstrap script that reads it: never as an argument.
119
+ */
110
120
  export async function bootstrapRemote(
111
121
  node: FleetNode,
112
122
  payload: BootstrapPayload,
113
123
  run: Runner,
114
- follow: Follow = {}
124
+ options: Follow & { tailnetKey?: string } = {}
115
125
  ): Promise<NodeReport> {
126
+ const { tailnetKey, ...follow } = options;
116
127
  const clock = follow.clock ?? realClock;
117
- const cached = await cachedRevision(node, payload.revision, run, clock);
118
- if (typeof cached !== 'boolean') return { node: node.id, checks: [cached] };
119
- const transfer = cached
128
+ const held = await cachedRevision(node, payload.revision, run, clock);
129
+ if ('id' in held) return { node: node.id, checks: [held] };
130
+ const transfer = held.cached
120
131
  ? { mode: 'cached', bundle: '' }
121
132
  : { mode: 'transfer', bundle: payload.bundle };
122
133
  const command = [
@@ -129,14 +140,22 @@ export async function bootstrapRemote(
129
140
  node.id,
130
141
  transfer.mode,
131
142
  UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
143
+ T3_RESTART_MODES[payload.restartBusy ? 1 : 0],
132
144
  ]
133
145
  .map(shellQuote)
134
146
  .join(' ');
135
147
  const checks = await bootstrapSessions(
136
148
  node,
137
149
  payload,
138
- { command, input: bootstrapInput(payload.config, transfer.bundle) },
139
- { run, follow: { ...follow, clock } }
150
+ {
151
+ command,
152
+ input: bootstrapInput(
153
+ payload.config,
154
+ transfer.bundle,
155
+ readsTailnetKey(payload.script) ? (tailnetKey ?? '') : null
156
+ ),
157
+ },
158
+ { run, follow: { ...follow, clock, earlier: held.earlier } }
140
159
  );
141
160
  return { node: node.id, checks };
142
161
  }
@@ -198,23 +217,37 @@ function locked(checks: Check[]): boolean {
198
217
 
199
218
  /** What the bootstrap reads on stdin: one line with the fleet configuration
200
219
  * (installed under the remote lock, so overlapping runs never both write it),
201
- * then the bundle when the worker lacks the revision.
220
+ * one line with the tailnet enrolment key (empty when there is none; absent
221
+ * for a script of an earlier revision, which reads no such line), then the
222
+ * bundle when the worker lacks the revision.
202
223
  */
203
224
  export function bootstrapInput(
204
225
  config: string | undefined,
205
- bundle: string
226
+ bundle: string,
227
+ tailnetKey: string | null = null
206
228
  ): string {
207
229
  const line = config ? Buffer.from(config).toString('base64') : '';
208
- return `${line}\n${bundle}`;
230
+ return tailnetKey === null
231
+ ? `${line}\n${bundle}`
232
+ : `${line}\n${tailnetKey}\n${bundle}`;
209
233
  }
210
234
 
235
+ /** A bootstrap script reads the key line since the revision that added it. */
236
+ export function readsTailnetKey(script: string): boolean {
237
+ return script.includes('read -r tailnet_key');
238
+ }
239
+
240
+ /** Whether the worker holds a clean checkout of the revision, and when the
241
+ * operation its record says had finished there started: that one ended
242
+ * before this run, so its result is never taken for this run's.
243
+ */
211
244
  async function cachedRevision(
212
245
  node: FleetNode,
213
246
  revision: string,
214
247
  run: Runner,
215
248
  clock: Clock
216
- ): Promise<boolean | Check> {
217
- const command = `d="$HOME/.local/share/personal/fleet/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
249
+ ): Promise<{ cached: boolean; earlier: number | null } | Check> {
250
+ const command = `r="$HOME/.local/share/personal/fleet"; sed -n 's/^{.*"started":\\([0-9][0-9]*\\),.*"finished":[0-9].*$/finished=\\1/p' "$r/remote-operation.json" 2>/dev/null | head -n 1; d="$r/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
218
251
  const result = await retrying(
219
252
  () => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
220
253
  clock
@@ -223,7 +256,12 @@ async function cachedRevision(
223
256
  const failure = sshFailure(result);
224
257
  if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
225
258
  }
226
- return result.ok && result.stdout === 'ready';
259
+ const lines = result.stdout.split('\n');
260
+ const finished = /^finished=(\d{1,12})$/.exec(lines[0] ?? '');
261
+ return {
262
+ cached: result.ok && lines.at(-1) === 'ready',
263
+ earlier: finished ? Number(finished[1]) : null,
264
+ };
227
265
  }
228
266
 
229
267
  function parseEvidence(result: {
@@ -148,16 +148,30 @@ function followable(operation: Operation, revision: string): boolean {
148
148
  );
149
149
  }
150
150
 
151
- function finishedWith(operation: Operation, revision: string): Check[] | null {
151
+ /** The result of a finished operation of this revision, unless it is the
152
+ * operation that had already finished before this run opened a session: a
153
+ * re-sync of the same revision finds the record of the last run still there,
154
+ * and its result is not this run's.
155
+ */
156
+ function finishedWith(
157
+ operation: Operation,
158
+ revision: string,
159
+ earlier: number | null | undefined
160
+ ): Check[] | null {
152
161
  return !operation.held &&
153
162
  operation.finished &&
154
- operation.revision === revision
163
+ operation.revision === revision &&
164
+ !(typeof earlier === 'number' && operation.started === earlier)
155
165
  ? operation.result
156
166
  : null;
157
167
  }
158
168
 
159
169
  export interface Follow {
160
170
  clock?: Clock;
171
+ /** When the operation that had finished on the worker before this run
172
+ * began had started there (the worker's clock, in seconds), when known.
173
+ */
174
+ earlier?: number | null;
161
175
  /** Told the phase and how long the operation has run, at every look. */
162
176
  progress?: (phase: string) => void;
163
177
  }
@@ -176,7 +190,7 @@ export async function followOperation(
176
190
  const deadline = clock.now() + FOLLOW_AT_MOST_MS;
177
191
  let operation = await inspectOperation(node, run, clock);
178
192
  if (!operation) return null;
179
- const done = finishedWith(operation, revision);
193
+ const done = finishedWith(operation, revision, options.earlier);
180
194
  if (done) return [attached(operation, 'finished'), ...done];
181
195
  if (!followable(operation, revision)) return null;
182
196
  let last: Operation = operation;
@@ -186,7 +200,7 @@ export async function followOperation(
186
200
  operation = await inspectOperation(node, run, clock);
187
201
  if (!operation) continue;
188
202
  last = operation;
189
- const result = finishedWith(operation, revision);
203
+ const result = finishedWith(operation, revision, options.earlier);
190
204
  if (result) return [attached(operation, 'followed'), ...result];
191
205
  if (!followable(operation, revision)) return null;
192
206
  }
@@ -1,5 +1,5 @@
1
1
  import { checkOwner } from './check-owners';
2
- import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
2
+ import { AUTOMATED_REPAIRS, REPAIR_RECIPES, recipe } from './repair-catalog';
3
3
  import type { RepairRecipe } from './repair-catalog';
4
4
  import type { Check, FleetNode, NodeReport, Fingerprint } from './types';
5
5
 
@@ -18,22 +18,33 @@ export interface RepairStep extends RepairRecipe {
18
18
  manual?: { check: string; reason: string }[];
19
19
  }
20
20
 
21
+ const UNOWNED =
22
+ 'No repair recipe is registered for this check: follow its remedy.';
23
+
24
+ /** What repairs a check: the recipe its owner names, or why only a person
25
+ * can. An `auth:` check is repaired by the login recipe of its name; one
26
+ * with no such recipe is a person's, for the reason `login:*` gives. */
27
+ export function checkRepair(
28
+ id: string
29
+ ): { recipe: string } | { manual: string } {
30
+ const owner = checkOwner(id);
31
+ if (!owner?.repair) return { manual: owner?.manual ?? UNOWNED };
32
+ if (owner.repair !== 'login:*') return { recipe: owner.repair };
33
+ const login = `login:${id.slice('auth:'.length)}`;
34
+ return REPAIR_RECIPES.some((entry) => entry.id === login)
35
+ ? { recipe: login }
36
+ : { manual: checkOwner(login)?.manual ?? UNOWNED };
37
+ }
38
+
21
39
  export function repairId(check: Check): string {
22
- const owner = checkOwner(check.id);
23
- if (!owner?.repair) return 'operator';
24
- return owner.repair === 'login:*'
25
- ? `login:${check.id.slice('auth:'.length)}`
26
- : owner.repair;
40
+ const repair = checkRepair(check.id);
41
+ return 'recipe' in repair ? repair.recipe : 'operator';
27
42
  }
28
43
 
29
44
  /** Why a check is left to a person, or null when a recipe repairs it. */
30
45
  export function manualReason(check: Check): string | null {
31
- const owner = checkOwner(check.id);
32
- if (owner?.repair) return null;
33
- return (
34
- owner?.manual ??
35
- 'No repair recipe is registered for this check: follow its remedy.'
36
- );
46
+ const repair = checkRepair(check.id);
47
+ return 'manual' in repair ? repair.manual : null;
37
48
  }
38
49
 
39
50
  /** Every nonpassing check is accounted for; unknown checks remain operator work. */
package/src/report.ts CHANGED
@@ -6,6 +6,7 @@ export function makeReport(command: string, nodes: NodeReport[]): Report {
6
6
  warn: 0,
7
7
  fail: 0,
8
8
  skip: 0,
9
+ 'not-applicable': 0,
9
10
  };
10
11
  for (const node of nodes)
11
12
  for (const check of node.checks) summary[check.status] += 1;
@@ -46,7 +47,7 @@ export function renderReport(report: Report): string {
46
47
  }
47
48
  }
48
49
  lines.push(
49
- `\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip`
50
+ `\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip${report.summary['not-applicable'] ? `, ${report.summary['not-applicable']} not applicable` : ''}`
50
51
  );
51
52
  return lines.join('\n');
52
53
  }
@@ -1,8 +1,27 @@
1
+ import macos from '../ansible/macos/policy.json';
2
+ import type { ManagerDefinition } from './definition';
1
3
  import type { History } from './state/history';
2
4
  import type { Check } from './types';
3
5
 
4
- /** How often the manager's schedule runs fleet-sync. */
5
- export const SCHEDULE_INTERVAL_SECONDS = 1800;
6
+ /** How often the manager's schedule runs fleet-sync by default. */
7
+ export const SCHEDULE_INTERVAL_SECONDS = macos.fleetSync.intervalMinutes * 60;
8
+
9
+ /** How often the manager's scheduled jobs run: `manager.schedule` in
10
+ * fleet.json, or the defaults of `ansible/macos/policy.json`.
11
+ */
12
+ export function scheduleIntervals(
13
+ manager: Pick<ManagerDefinition, 'schedule'>
14
+ ): {
15
+ syncSeconds: number;
16
+ watchMinutes: number;
17
+ } {
18
+ return {
19
+ syncSeconds:
20
+ (manager.schedule?.syncMinutes ?? macos.fleetSync.intervalMinutes) * 60,
21
+ watchMinutes:
22
+ manager.schedule?.watchMinutes ?? macos.fleetWatch.intervalMinutes,
23
+ };
24
+ }
6
25
 
7
26
  /** `manager:schedule`: a schedule that is on must have succeeded within two
8
27
  * of its intervals. A schedule that was never turned on is no check at all.
package/src/secrets.ts CHANGED
@@ -26,6 +26,13 @@ export const OPTIONAL_SECRET_NAMES = [
26
26
  // Where fleet-watch notifies (notify.targets).
27
27
  'notify-webhook-url',
28
28
  'notify-ntfy-url',
29
+ // A short-lived, single-use, tagged Tailscale auth key a new worker joins
30
+ // the tailnet with (fleet-node-seed).
31
+ 'tailnet-auth-key',
32
+ // An API credential of the tailnet's control plane (tailnet in fleet.json)
33
+ // that creates one single-use key per new worker. It stays on the manager:
34
+ // no worker ever receives it.
35
+ 'tailnet-admin',
29
36
  ] as const;
30
37
  export type SecretName =
31
38
  (typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
@@ -43,68 +43,75 @@ export async function codingAuth(
43
43
  ];
44
44
  }
45
45
 
46
- /** CLIs with no sign-in of their own to check, and why. Each says where
47
- * its access is proven instead; none is a silent skip.
46
+ /** Registered agent CLIs with no sign-in of their own to check, and why.
47
+ * Each is `not-applicable` on the proxy route and says where its access is
48
+ * proven instead; none is a silent skip.
48
49
  */
49
50
  export const PROXY_ROUTED: Record<string, string> = {
50
51
  gemini:
51
52
  'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
52
53
  opencode:
53
54
  'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
55
+ grok: 'grok reads the proxy key from its environment and has no grok login; agent:grok:session proves it from this node.',
56
+ antigravity:
57
+ 'antigravity signs in through the CLI proxy; agent:antigravity:session proves it from this node.',
54
58
  };
55
59
 
60
+ /** The services a computer signs in to, each checked as `auth:<id>`. */
61
+ const SERVICES: Service[] = [
62
+ {
63
+ id: 'railway',
64
+ command: ['railway', 'whoami', '--json'],
65
+ action: 'Run railway login on this computer.',
66
+ valid: (result) => typeof json(result)?.['id'] === 'string',
67
+ },
68
+ {
69
+ id: 'vercel',
70
+ command: ['vercel', 'whoami'],
71
+ action: 'Run vercel login on this computer.',
72
+ valid: (result) =>
73
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
74
+ },
75
+ {
76
+ id: 'cloudflare',
77
+ command: ['wrangler', 'whoami', '--json'],
78
+ action: 'Run wrangler login on this computer.',
79
+ valid: (result) => json(result)?.['loggedIn'] === true,
80
+ },
81
+ {
82
+ id: 'aws',
83
+ command: [
84
+ 'aws',
85
+ 'sts',
86
+ 'get-caller-identity',
87
+ '--output',
88
+ 'json',
89
+ '--no-cli-pager',
90
+ ],
91
+ action:
92
+ 'Select and authenticate the intended AWS profile on this computer.',
93
+ valid: (result) =>
94
+ typeof json(result)?.['Account'] === 'string' &&
95
+ typeof json(result)?.['Arn'] === 'string',
96
+ },
97
+ {
98
+ id: 'npm',
99
+ command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
100
+ action: 'Run npm login only if this computer should publish packages.',
101
+ valid: (result) =>
102
+ /^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
103
+ },
104
+ ];
105
+ export const SERVICE_IDS = SERVICES.map((service) => service.id);
106
+
56
107
  export async function serviceAuth(run: Runner): Promise<Check[]> {
57
- const services: Service[] = [
58
- {
59
- id: 'railway',
60
- command: ['railway', 'whoami', '--json'],
61
- action: 'Run railway login on this computer.',
62
- valid: (result) => typeof json(result)?.['id'] === 'string',
63
- },
64
- {
65
- id: 'vercel',
66
- command: ['vercel', 'whoami'],
67
- action: 'Run vercel login on this computer.',
68
- valid: (result) =>
69
- /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
70
- },
71
- {
72
- id: 'cloudflare',
73
- command: ['wrangler', 'whoami', '--json'],
74
- action: 'Run wrangler login on this computer.',
75
- valid: (result) => json(result)?.['loggedIn'] === true,
76
- },
77
- {
78
- id: 'aws',
79
- command: [
80
- 'aws',
81
- 'sts',
82
- 'get-caller-identity',
83
- '--output',
84
- 'json',
85
- '--no-cli-pager',
86
- ],
87
- action:
88
- 'Select and authenticate the intended AWS profile on this computer.',
89
- valid: (result) =>
90
- typeof json(result)?.['Account'] === 'string' &&
91
- typeof json(result)?.['Arn'] === 'string',
92
- },
93
- {
94
- id: 'npm',
95
- command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
96
- action: 'Run npm login only if this computer should publish packages.',
97
- valid: (result) =>
98
- /^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
99
- },
100
- ];
101
108
  const checks: Check[] = [];
102
- for (const service of services) checks.push(await serviceCheck(service, run));
109
+ for (const service of SERVICES) checks.push(await serviceCheck(service, run));
103
110
  checks.push(await copilotCheck(run));
104
111
  for (const [id, reason] of Object.entries(PROXY_ROUTED))
105
112
  checks.push({
106
113
  id: `auth:${id}`,
107
- status: 'skip',
114
+ status: 'not-applicable',
108
115
  required: false,
109
116
  summary: `AUTH_NOT_SUPPORTED_ON_ROUTE: ${reason}`,
110
117
  });