@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
@@ -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
@@ -29,6 +29,10 @@ export const OPTIONAL_SECRET_NAMES = [
29
29
  // A short-lived, single-use, tagged Tailscale auth key a new worker joins
30
30
  // the tailnet with (fleet-node-seed).
31
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',
32
36
  ] as const;
33
37
  export type SecretName =
34
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
  });
@@ -0,0 +1,141 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ writeFileSync,
7
+ } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { dirname, join } from 'node:path';
10
+ import type { FleetDefinition, MachineDefinition } from './definition';
11
+ import { stateRoot } from './state/paths';
12
+
13
+ /** How the manager reaches a worker, written once: every direct SSH session
14
+ * (src/ssh.ts) and every Ansible run over SSH (fleet-validate's inventory,
15
+ * with ansible/ansible.cfg) takes its options from here, so the two cannot
16
+ * drift. Host keys are checked strictly and never learned on first use. A
17
+ * few attempts and keepalives let a short network blip pass without ending
18
+ * a long operation.
19
+ */
20
+ export const SSH_TRANSPORT: readonly (readonly [string, string])[] = [
21
+ ['BatchMode', 'yes'],
22
+ ['StrictHostKeyChecking', 'yes'],
23
+ ['UpdateHostKeys', 'no'],
24
+ ['ConnectTimeout', '10'],
25
+ ['ConnectionAttempts', '3'],
26
+ ['ServerAliveInterval', '15'],
27
+ ['ServerAliveCountMax', '4'],
28
+ ];
29
+
30
+ /** A direct session also forwards nothing and shares no connection. Ansible
31
+ * multiplexes its own (ControlPersist in ansible/ansible.cfg).
32
+ */
33
+ export const SSH_SESSION: readonly (readonly [string, string])[] = [
34
+ ['ClearAllForwardings', 'yes'],
35
+ ['PermitLocalCommand', 'no'],
36
+ ['ControlMaster', 'no'],
37
+ ['ControlPath', 'none'],
38
+ ];
39
+
40
+ /** The name a worker's host key is pinned under, whatever its address. */
41
+ export const hostKeyAlias = (id: string): string => `devfleet-${id}`;
42
+
43
+ /** The fleet-owned known_hosts file: a cache rendered from the pinned keys
44
+ * (declared in a worker record, or in the fleet state). The operator's own
45
+ * ~/.ssh files are neither read for it nor changed.
46
+ */
47
+ export function knownHostsPath(env = process.env, home = homedir()): string {
48
+ return join(dirname(stateRoot(env, home)), 'ssh', 'known_hosts');
49
+ }
50
+
51
+ /** A worker without an SSH alias is reached from its declared network
52
+ * block: its tailnet name, user and port.
53
+ */
54
+ export function declaredConnection(
55
+ worker: MachineDefinition | undefined
56
+ ):
57
+ | (MachineDefinition & { network: NonNullable<MachineDefinition['network']> })
58
+ | null {
59
+ return worker && worker.ssh === undefined && worker.network
60
+ ? (worker as MachineDefinition & {
61
+ network: NonNullable<MachineDefinition['network']>;
62
+ })
63
+ : null;
64
+ }
65
+
66
+ /** Host keys pinned in the fleet state, by worker ID. Only the devfleet CLI
67
+ * reads the state (it registers the reader); launchers and the worker probe
68
+ * run under Node, which cannot open the state database.
69
+ */
70
+ type PinReader = () => Record<string, string>;
71
+ let pinReader: PinReader | null = null;
72
+ export function readPinsWith(reader: PinReader | null): void {
73
+ pinReader = reader;
74
+ }
75
+
76
+ /** The key each worker reached from declared data is pinned to: the one its
77
+ * record or fleet.json declares, else the one the fleet state pins.
78
+ */
79
+ export function pinnedKeys(
80
+ definition: FleetDefinition,
81
+ pins: Record<string, string> = pinReader?.() ?? {}
82
+ ): Record<string, string> {
83
+ return Object.fromEntries(
84
+ definition.workers.flatMap((worker) => {
85
+ const declared = declaredConnection(worker);
86
+ const key = declared?.network.hostKey ?? pins[worker.id];
87
+ return declared && key ? [[worker.id, key]] : [];
88
+ })
89
+ );
90
+ }
91
+
92
+ export function knownHostsText(keys: Record<string, string>): string {
93
+ return [
94
+ '# Managed by dev-fleet: rendered from the fleet state and worker records. Do not edit.',
95
+ ...Object.entries(keys)
96
+ .sort(([a], [b]) => a.localeCompare(b))
97
+ .map(([id, key]) => `${hostKeyAlias(id)} ${key}`),
98
+ '',
99
+ ].join('\n');
100
+ }
101
+
102
+ /** Writes the fleet-owned known_hosts when it differs; returns its path. */
103
+ export function renderKnownHosts(
104
+ definition: FleetDefinition,
105
+ env = process.env,
106
+ pins?: Record<string, string>
107
+ ): string {
108
+ const path = knownHostsPath(env);
109
+ const text = knownHostsText(pinnedKeys(definition, pins));
110
+ if (existsSync(path) && readFileSync(path, 'utf8') === text) return path;
111
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
112
+ writeFileSync(`${path}.new`, text, { mode: 0o600 });
113
+ renameSync(`${path}.new`, path);
114
+ return path;
115
+ }
116
+
117
+ const option = ([name, value]: readonly [string, string]): string =>
118
+ `${name}=${value}`;
119
+
120
+ /** The options that reach one worker from declared data. */
121
+ export function connectionOptions(
122
+ worker: MachineDefinition & {
123
+ network: NonNullable<MachineDefinition['network']>;
124
+ },
125
+ knownHosts: string
126
+ ): [string, string][] {
127
+ return [
128
+ ['HostKeyAlias', hostKeyAlias(worker.id)],
129
+ ['UserKnownHostsFile', knownHosts],
130
+ ['GlobalKnownHostsFile', '/dev/null'],
131
+ ['User', worker.network.user],
132
+ ['Port', String(worker.network.sshPort)],
133
+ ];
134
+ }
135
+
136
+ /** `-o NAME=VALUE` for each option, as ssh takes them. */
137
+ export function sshOptionArgs(
138
+ options: readonly (readonly [string, string])[]
139
+ ): string[] {
140
+ return options.flatMap((entry) => ['-o', option(entry)]);
141
+ }
package/src/ssh.ts CHANGED
@@ -1,40 +1,79 @@
1
1
  import type { CommandResult, FleetNode, Check } from './types';
2
+ import { fleet } from './fleet-config';
3
+ import {
4
+ connectionOptions,
5
+ declaredConnection,
6
+ renderKnownHosts,
7
+ SSH_SESSION,
8
+ SSH_TRANSPORT,
9
+ sshOptionArgs,
10
+ } from './ssh-connection';
2
11
 
3
12
  export function shellQuote(value: string): string {
4
13
  return `'${value.replaceAll("'", "'\\''")}'`;
5
14
  }
6
15
 
7
- export function sshArgs(node: FleetNode): string[] {
16
+ /** The worker fleet.json declares for a roster entry that is reached from
17
+ * declared data, if it is one. No configuration (a fresh machine) is none.
18
+ */
19
+ function declaredWorker(node: Pick<FleetNode, 'id' | 'ssh'>) {
20
+ try {
21
+ const worker = declaredConnection(
22
+ fleet.workers.find((entry) => entry.id === node.id)
23
+ );
24
+ return worker && worker.network.tailscaleName === node.ssh ? worker : null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** One SSH session to a node: unattended, strict about the host key,
31
+ * forwarding nothing. A worker without an alias is reached from its declared
32
+ * user, port and tailnet name, against the fleet-owned known_hosts.
33
+ */
34
+ export function sshArgs(node: Pick<FleetNode, 'id' | 'ssh'>): string[] {
35
+ const declared = declaredWorker(node);
8
36
  return [
9
37
  'ssh',
10
38
  '-T',
11
39
  '-a',
12
- '-o',
13
- 'BatchMode=yes',
14
- '-o',
15
- 'StrictHostKeyChecking=yes',
16
- '-o',
17
- 'UpdateHostKeys=no',
18
- '-o',
19
- 'ConnectTimeout=8',
20
- '-o',
21
- 'ConnectionAttempts=1',
22
- '-o',
23
- 'ServerAliveInterval=15',
24
- '-o',
25
- 'ServerAliveCountMax=4',
26
- '-o',
27
- 'ClearAllForwardings=yes',
28
- '-o',
29
- 'PermitLocalCommand=no',
30
- '-o',
31
- 'ControlMaster=no',
32
- '-o',
33
- 'ControlPath=none',
40
+ ...sshOptionArgs(SSH_TRANSPORT),
41
+ ...(declared
42
+ ? sshOptionArgs(connectionOptions(declared, renderKnownHosts(fleet)))
43
+ : []),
44
+ ...sshOptionArgs(SSH_SESSION),
34
45
  node.ssh,
35
46
  ];
36
47
  }
37
48
 
49
+ /** How a package playbook that addresses a worker over SSH itself
50
+ * (ansible/ubuntu/worker-account.yml, ansible/ubuntu/reboot.yml) reaches it:
51
+ * the options every direct session takes, as one `ansible_ssh_common_args`
52
+ * value, and the account a worker reached from declared data logs in as. The
53
+ * play names the login itself (root, or the declared account), so the
54
+ * declared user is not among the options.
55
+ */
56
+ export function playbookConnection(node: Pick<FleetNode, 'id' | 'ssh'>): {
57
+ options: string;
58
+ user: string | null;
59
+ } {
60
+ const declared = declaredWorker(node);
61
+ const options = [
62
+ ...SSH_TRANSPORT,
63
+ ...(declared
64
+ ? connectionOptions(declared, renderKnownHosts(fleet)).filter(
65
+ ([name]) => name !== 'User'
66
+ )
67
+ : []),
68
+ ];
69
+ return {
70
+ options: sshOptionArgs(options)
71
+ .map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
72
+ .join(' '),
73
+ user: declared?.network.user ?? null,
74
+ };
75
+ }
76
+
38
77
  export function remoteCommand(node: FleetNode, request: object): string[] {
39
78
  const encoded = Buffer.from(JSON.stringify(request)).toString('base64');
40
79
  const command = `cd -- ${shellQuote(node.workspace)} && export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" && exec ${shellQuote(node.runtime ?? 'node')} --input-type=module - ${shellQuote(encoded)}`;
@@ -48,7 +87,7 @@ const FAILURES = [
48
87
  id: 'ssh:host-key',
49
88
  summary: 'SSH host key is unknown or changed.',
50
89
  action:
51
- 'Verify the host fingerprint out of band, then update known_hosts manually. Never disable host-key checking.',
90
+ 'The worker presented a host key other than the pinned one. Verify its fingerprint out of band, then pin the new key: devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply --yes, or change hostKey in its worker record. A worker reached through your own SSH alias is checked against your known_hosts instead. Never disable host-key checking.',
52
91
  },
53
92
  {
54
93
  pattern: