@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,303 @@
1
+ import { record } from './config';
2
+ import type { FleetDefinition, MachineDefinition } from './definition';
3
+ import { sshArgs } from './ssh';
4
+ import type { Check, FleetNode, Runner } from './types';
5
+
6
+ /** How long a worker has been up and what its agents are doing, as T3
7
+ * records it: the number of agent turns under way and when the last one was
8
+ * requested or ended. Never the content of a session.
9
+ */
10
+ export interface WorkerActivity {
11
+ uptimeSeconds: number | null;
12
+ runningSessions: number | null;
13
+ lastActivityAt: string | null;
14
+ }
15
+
16
+ /** One worker in fleet-status: active or parked, what it does, and what it
17
+ * costs while it runs (only what fleet.json declares).
18
+ */
19
+ export interface WorkerUsage extends Partial<WorkerActivity> {
20
+ id: string;
21
+ state: 'active' | 'parked';
22
+ parkedAt?: string;
23
+ reachable?: boolean;
24
+ /** Seconds since the last agent activity, or since boot without any. */
25
+ idleSeconds?: number;
26
+ idle?: boolean;
27
+ hourlyRate?: number;
28
+ currency?: string;
29
+ billingNote?: string;
30
+ }
31
+
32
+ /** The installed copy fleet-sync keeps outside every revision
33
+ * (ansible/ubuntu/tasks/recovery.yml).
34
+ */
35
+ export const ACTIVITY_PROBE =
36
+ 'cut -d" " -f1 /proc/uptime; python3 -I -B /usr/local/lib/personal-fleet/t3-idle.py --state "${T3CODE_HOME:-$HOME/.t3}/userdata/statev2.sqlite" --activity';
37
+
38
+ /** Reads a worker's uptime and agent activity over SSH; null when the
39
+ * worker did not answer.
40
+ */
41
+ export async function workerActivity(
42
+ node: FleetNode,
43
+ run: Runner
44
+ ): Promise<WorkerActivity | null> {
45
+ const result = await run([...sshArgs(node), ACTIVITY_PROBE], {
46
+ timeoutMs: 20_000,
47
+ });
48
+ if (!result.ok && !result.stdout) return null;
49
+ return parseActivity(result.stdout);
50
+ }
51
+
52
+ /** The JSON line of t3-idle.py --activity, or nothing known. */
53
+ function activityLine(line: string | undefined): Record<string, unknown> {
54
+ let answer: unknown = null;
55
+ try {
56
+ answer = JSON.parse(line ?? '');
57
+ } catch {
58
+ answer = null;
59
+ }
60
+ return record(answer) && answer['version'] === 1 && !answer['unknown']
61
+ ? answer
62
+ : {};
63
+ }
64
+
65
+ /** What the probe printed: the uptime, then the one JSON line of
66
+ * t3-idle.py --activity. Anything unreadable is unknown, never zero.
67
+ */
68
+ export function parseActivity(stdout: string): WorkerActivity {
69
+ const [uptime, line] = stdout.split('\n');
70
+ const seconds = Number(uptime);
71
+ const known = activityLine(line);
72
+ const running = known['running'];
73
+ const last = known['lastActivity'];
74
+ return {
75
+ uptimeSeconds:
76
+ uptime && Number.isFinite(seconds) && seconds >= 0
77
+ ? Math.floor(seconds)
78
+ : null,
79
+ runningSessions:
80
+ Number.isInteger(running) && Number(running) >= 0
81
+ ? (running as number)
82
+ : null,
83
+ lastActivityAt:
84
+ typeof last === 'string' && !Number.isNaN(Date.parse(last))
85
+ ? new Date(last).toISOString()
86
+ : null,
87
+ };
88
+ }
89
+
90
+ /** `2d 3h`, `5h 12m`, `40m`: a duration for a person. */
91
+ export function duration(seconds: number): string {
92
+ const minutes = Math.floor(seconds / 60);
93
+ const days = Math.floor(minutes / 1440);
94
+ const hours = Math.floor((minutes % 1440) / 60);
95
+ if (days) return `${days}d ${hours}h`;
96
+ if (hours) return `${hours}h ${minutes % 60}m`;
97
+ return `${minutes}m`;
98
+ }
99
+
100
+ /** Seconds since the last agent activity, or since boot when there was
101
+ * none; null when it cannot be told.
102
+ */
103
+ function idleFor(activity: WorkerActivity, now: Date): number | null {
104
+ if (activity.lastActivityAt)
105
+ return Math.max(
106
+ 0,
107
+ Math.floor((now.getTime() - Date.parse(activity.lastActivityAt)) / 1000)
108
+ );
109
+ return activity.uptimeSeconds;
110
+ }
111
+
112
+ /** `worker:idle`: a warning once a worker has run no agent turn for longer
113
+ * than fleet.json's parking.idleAfterHours. A worker with a turn under way
114
+ * is never idle, and one whose T3 cannot be read is not judged.
115
+ */
116
+ export function idleCheck(
117
+ id: string,
118
+ activity: WorkerActivity,
119
+ idleAfterHours: number | undefined,
120
+ now: Date
121
+ ): { check: Check; idleSeconds?: number; idle: boolean } {
122
+ if (activity.runningSessions === null)
123
+ return {
124
+ idle: false,
125
+ check: {
126
+ id: 'worker:idle',
127
+ status: 'skip',
128
+ summary: `${id}'s agent activity could not be read from its T3.`,
129
+ },
130
+ };
131
+ if (activity.runningSessions > 0)
132
+ return {
133
+ idle: false,
134
+ idleSeconds: 0,
135
+ check: {
136
+ id: 'worker:idle',
137
+ status: 'pass',
138
+ summary: `${id} runs ${activity.runningSessions} agent turn(s).`,
139
+ },
140
+ };
141
+ const seconds = idleFor(activity, now);
142
+ const limit = idleAfterHours === undefined ? null : idleAfterHours * 3600;
143
+ const idle = seconds !== null && limit !== null && seconds > limit;
144
+ return {
145
+ idle,
146
+ ...(seconds === null ? {} : { idleSeconds: seconds }),
147
+ check: idle
148
+ ? {
149
+ id: 'worker:idle',
150
+ status: 'warn',
151
+ summary: `${id} has run no agent turn for ${duration(seconds!)}, longer than the declared ${idleAfterHours}h.`,
152
+ remedy: {
153
+ action: `Park it while nobody needs it: devfleet fleet-node-park --id ${id}. devfleet fleet-node-resume --id ${id} brings it back.`,
154
+ },
155
+ }
156
+ : {
157
+ id: 'worker:idle',
158
+ status: 'pass',
159
+ summary:
160
+ seconds === null
161
+ ? `${id} runs no agent turn.`
162
+ : `${id} runs no agent turn; the last agent activity was ${duration(seconds)} ago.`,
163
+ },
164
+ };
165
+ }
166
+
167
+ function billing(worker: MachineDefinition | undefined) {
168
+ return worker?.billing
169
+ ? {
170
+ hourlyRate: worker.billing.hourlyRate,
171
+ ...(worker.billing.currency
172
+ ? { currency: worker.billing.currency }
173
+ : {}),
174
+ ...(worker.billing.note ? { billingNote: worker.billing.note } : {}),
175
+ }
176
+ : {};
177
+ }
178
+
179
+ /** Measures every active enrolled worker and lists the parked ones: the
180
+ * `workers` of fleet-status, and the checks it adds to each worker's report.
181
+ * Parked workers are not contacted.
182
+ */
183
+ export async function fleetUsage(
184
+ definition: FleetDefinition,
185
+ {
186
+ nodes,
187
+ parked,
188
+ run,
189
+ now,
190
+ }: {
191
+ nodes: FleetNode[];
192
+ parked: Record<string, string>;
193
+ run: Runner;
194
+ now: Date;
195
+ }
196
+ ): Promise<{ workers: WorkerUsage[]; checks: Map<string, Check> }> {
197
+ const checks = new Map<string, Check>();
198
+ const workers = await Promise.all(
199
+ definition.workers.map(async (worker): Promise<WorkerUsage> => {
200
+ if (parked[worker.id])
201
+ return {
202
+ id: worker.id,
203
+ state: 'parked',
204
+ parkedAt: parked[worker.id]!,
205
+ ...billing(worker),
206
+ };
207
+ const node = nodes.find((entry) => entry.id === worker.id);
208
+ const activity = node ? await workerActivity(node, run) : null;
209
+ if (!activity)
210
+ return {
211
+ id: worker.id,
212
+ state: 'active',
213
+ reachable: false,
214
+ ...billing(worker),
215
+ };
216
+ const judged = idleCheck(
217
+ worker.id,
218
+ activity,
219
+ definition.parking?.idleAfterHours,
220
+ now
221
+ );
222
+ checks.set(worker.id, judged.check);
223
+ return {
224
+ id: worker.id,
225
+ state: 'active',
226
+ reachable: true,
227
+ ...activity,
228
+ ...(judged.idleSeconds === undefined
229
+ ? {}
230
+ : { idleSeconds: judged.idleSeconds }),
231
+ idle: judged.idle,
232
+ ...billing(worker),
233
+ };
234
+ })
235
+ );
236
+ return { workers, checks };
237
+ }
238
+
239
+ /** Workers running, workers idle, and what the idle ones cost per day, by
240
+ * the rates fleet.json declares.
241
+ */
242
+ export function usageSummary(workers: WorkerUsage[]) {
243
+ const running = workers.filter(
244
+ (worker) => worker.state === 'active' && worker.reachable
245
+ );
246
+ const idle = running.filter((worker) => worker.idle);
247
+ const currencies = [
248
+ ...new Set(idle.flatMap((worker) => worker.currency ?? [])),
249
+ ];
250
+ return {
251
+ running: running.length,
252
+ idle: idle.length,
253
+ parked: workers.filter((worker) => worker.state === 'parked').length,
254
+ idleCostPerDay:
255
+ Math.round(
256
+ idle.reduce((sum, worker) => sum + (worker.hourlyRate ?? 0) * 24, 0) *
257
+ 100
258
+ ) / 100,
259
+ ...(currencies.length === 1 ? { currency: currencies[0] } : {}),
260
+ };
261
+ }
262
+
263
+ /** The lines fleet-status prints about each worker and the fleet. */
264
+ export function usageText(workers: WorkerUsage[], now: Date): string {
265
+ const summary = usageSummary(workers);
266
+ const lines = workers.map((worker) => {
267
+ if (worker.state === 'parked')
268
+ return ` ${worker.id}: parked for ${duration(Math.max(0, (now.getTime() - Date.parse(worker.parkedAt!)) / 1000))}`;
269
+ if (!worker.reachable) return ` ${worker.id}: not reached`;
270
+ const parts = [
271
+ worker.uptimeSeconds == null
272
+ ? 'uptime unknown'
273
+ : `up ${duration(worker.uptimeSeconds)}`,
274
+ worker.runningSessions == null
275
+ ? 'agent turns unknown'
276
+ : `${worker.runningSessions} agent turn(s) running`,
277
+ worker.lastActivityAt
278
+ ? `last agent activity ${worker.lastActivityAt}`
279
+ : 'no agent activity recorded',
280
+ ];
281
+ if (worker.idle) parts.push('idle');
282
+ if (worker.hourlyRate !== undefined)
283
+ parts.push(
284
+ `${worker.hourlyRate}${worker.currency ? ` ${worker.currency}` : ''}/h`
285
+ );
286
+ return ` ${worker.id}: ${parts.join(', ')}`;
287
+ });
288
+ return [
289
+ 'Workers:',
290
+ ...lines,
291
+ `${summary.running} running, ${summary.idle} idle, ${summary.parked} parked; idle workers cost ${summary.idleCostPerDay}${summary.currency ? ` ${summary.currency}` : ''} per day as declared.`,
292
+ ].join('\n');
293
+ }
294
+
295
+ /** A report's text, followed by the worker lines when there are any. */
296
+ export function withUsage(
297
+ text: string,
298
+ usage: { workers: WorkerUsage[] } | null
299
+ ): string {
300
+ return usage?.workers.length
301
+ ? `${text}\n${usageText(usage.workers, new Date())}`
302
+ : text;
303
+ }
@@ -2,6 +2,8 @@ import { join } from 'node:path';
2
2
  import { clockCheck } from './clock';
3
3
  import { healthChecks, parseProbe } from './machine-health';
4
4
  import type { Check, Runner } from './types';
5
+ import policy from '../ansible/ubuntu/policy.json';
6
+ import { supportedSessions } from './worker-sizing';
5
7
 
6
8
  export async function workerCapacity(
7
9
  root: string,
@@ -35,6 +37,9 @@ export async function workerCapacity(
35
37
  summary: check.detail,
36
38
  ...(check.fix ? { remedy: { action: check.fix } } : {}),
37
39
  })),
40
+ ...(sample.memTotalMb === undefined
41
+ ? []
42
+ : [sessionsCheck(sample.memTotalMb, sample.agentSessions)]),
38
43
  clockCheck(
39
44
  'worker:clock',
40
45
  sample,
@@ -44,3 +49,29 @@ export async function workerCapacity(
44
49
  ),
45
50
  ];
46
51
  }
52
+
53
+ const SESSIONS_EXHAUSTED = 'AGENT_SESSION_CAPACITY_EXHAUSTED';
54
+
55
+ /** How many agent sessions this worker's memory policy supports, and how
56
+ * many run. The size of a session is an estimate (`sizing.agentSessionMiB`),
57
+ * so more sessions than that only warns.
58
+ */
59
+ export function sessionsCheck(memoryMiB: number, running?: number): Check {
60
+ const supported = supportedSessions(memoryMiB);
61
+ const each = policy.sizing.agentSessionMiB;
62
+ const now = running === undefined ? 'an unknown number' : String(running);
63
+ const over = running !== undefined && running > supported;
64
+ return {
65
+ id: 'worker:agent-sessions',
66
+ status: over ? 'warn' : 'pass',
67
+ summary: `${over ? `${SESSIONS_EXHAUSTED}: ` : ''}${now} agent sessions running; the memory policy supports ${supported} at once on ${memoryMiB} MiB (an estimated ${each} MiB each).`,
68
+ ...(over
69
+ ? {
70
+ remedy: {
71
+ action:
72
+ 'More agent sessions run than the memory policy supports: each is still capped, so the largest may be stopped under pressure. Finish some, or move work to a worker with more memory.',
73
+ },
74
+ }
75
+ : {}),
76
+ };
77
+ }
@@ -0,0 +1,201 @@
1
+ import { fleet } from './fleet-config';
2
+ import { resolveVaultConfig, type VaultConfig } from './vault-config';
3
+ import type { Check, Runner } from './types';
4
+
5
+ /** Each worker's own Vault token is kept in a document of its own, beside
6
+ * the fleet's shared one: `<project>/workers/<id>`, field `token`, with the
7
+ * token's `accessor` so the manager can revoke it without holding it. A
8
+ * policy that grants one worker its document grants it nothing of another's.
9
+ */
10
+ export interface WorkerTokenLocation {
11
+ mount: string;
12
+ path: string;
13
+ field: 'token';
14
+ }
15
+
16
+ export function workerTokenLocation(
17
+ node: string,
18
+ vault: VaultConfig = resolveVaultConfig()
19
+ ): WorkerTokenLocation {
20
+ return {
21
+ mount: vault.mount,
22
+ path: `${vault.project}/workers/${node}`,
23
+ field: 'token',
24
+ };
25
+ }
26
+
27
+ /** The Vault policy each worker token is issued with. */
28
+ export const workerPolicyName = (node: string): string =>
29
+ `fleet-worker-${node}`;
30
+
31
+ /** The least a worker needs: the fleet's shared document (the proxy key and
32
+ * what the providers read) and its own token document. Nothing grants a
33
+ * worker the documents of the others, so one worker's token cannot read
34
+ * another's. Token renewal and lookup come from Vault's default policy.
35
+ */
36
+ export function workerVaultPolicy(
37
+ node: string,
38
+ vault: VaultConfig = resolveVaultConfig(),
39
+ shared: string = sharedDocument()
40
+ ): string {
41
+ const own = workerTokenLocation(node, vault);
42
+ return [
43
+ `# dev-fleet: the policy of the worker ${node}. Written by dev-fleet; do not edit.`,
44
+ `path "${vault.mount}/data/${shared}" {`,
45
+ ' capabilities = ["read"]',
46
+ '}',
47
+ `path "${own.mount}/data/${own.path}" {`,
48
+ ' capabilities = ["read"]',
49
+ '}',
50
+ '',
51
+ ].join('\n');
52
+ }
53
+
54
+ function sharedDocument(): string {
55
+ const secrets = fleet.secrets;
56
+ return secrets.provider === 'vault'
57
+ ? secrets.locations['proxy-client-key'].path
58
+ : '';
59
+ }
60
+
61
+ const TIMEOUT = { timeoutMs: 20_000 };
62
+
63
+ /** Issues a worker its own periodic, orphan token under its own policy and
64
+ * keeps it in its own document. The token reaches `vault kv put` on standard
65
+ * input, never as an argument. Run on the manager, logged in to Vault.
66
+ */
67
+ export async function issueWorkerToken(
68
+ node: string,
69
+ run: Runner,
70
+ vault: VaultConfig = resolveVaultConfig()
71
+ ): Promise<Check> {
72
+ const address = `-address=${vault.addr}`;
73
+ const policy = await run(
74
+ ['vault', 'policy', 'write', address, workerPolicyName(node), '-'],
75
+ { ...TIMEOUT, input: workerVaultPolicy(node, vault) }
76
+ );
77
+ if (!policy.ok) return credentialFailure('VAULT_WORKER_POLICY_FAILED');
78
+ const created = await run(
79
+ [
80
+ 'vault',
81
+ 'token',
82
+ 'create',
83
+ address,
84
+ `-policy=${workerPolicyName(node)}`,
85
+ '-orphan',
86
+ '-period=768h',
87
+ `-display-name=${workerPolicyName(node)}`,
88
+ '-format=json',
89
+ ],
90
+ TIMEOUT
91
+ );
92
+ const issued = created.ok ? parseIssued(created.stdout) : null;
93
+ if (!issued) return credentialFailure('VAULT_WORKER_TOKEN_NOT_ISSUED');
94
+ const location = workerTokenLocation(node, vault);
95
+ const stored = await run(
96
+ [
97
+ 'vault',
98
+ 'kv',
99
+ 'put',
100
+ address,
101
+ `-mount=${location.mount}`,
102
+ location.path,
103
+ 'token=-',
104
+ `accessor=${issued.accessor}`,
105
+ ],
106
+ { ...TIMEOUT, input: issued.token }
107
+ );
108
+ if (!stored.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_STORED');
109
+ return {
110
+ id: 'auth:worker-token',
111
+ status: 'pass',
112
+ summary: `${node} has a token of its own, readable by nothing but itself and the manager.`,
113
+ };
114
+ }
115
+
116
+ function parseIssued(
117
+ stdout: string
118
+ ): { token: string; accessor: string } | null {
119
+ try {
120
+ const auth = (JSON.parse(stdout) as { auth?: Record<string, unknown> })
121
+ .auth;
122
+ const token = auth?.['client_token'];
123
+ const accessor = auth?.['accessor'];
124
+ return typeof token === 'string' &&
125
+ typeof accessor === 'string' &&
126
+ /^[\x21-\x7e]{16,4096}$/.test(token) &&
127
+ /^[A-Za-z0-9._-]{8,256}$/.test(accessor)
128
+ ? { token, accessor }
129
+ : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ /** Revokes a worker's token by its accessor (the manager never holds the
136
+ * token to do so), then removes its document and its policy. A worker that
137
+ * has none already is revoked: running it twice changes nothing.
138
+ */
139
+ export async function revokeWorkerToken(
140
+ node: string,
141
+ run: Runner,
142
+ vault: VaultConfig = resolveVaultConfig()
143
+ ): Promise<Check> {
144
+ const address = `-address=${vault.addr}`;
145
+ const location = workerTokenLocation(node, vault);
146
+ const read = await run(
147
+ [
148
+ 'vault',
149
+ 'kv',
150
+ 'get',
151
+ address,
152
+ `-mount=${location.mount}`,
153
+ '-field=accessor',
154
+ location.path,
155
+ ],
156
+ TIMEOUT
157
+ );
158
+ const accessor = read.ok ? read.stdout.trim() : '';
159
+ if (accessor) {
160
+ if (!/^[A-Za-z0-9._-]{8,256}$/.test(accessor))
161
+ return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
162
+ const revoked = await run(
163
+ ['vault', 'token', 'revoke', address, '-accessor', accessor],
164
+ TIMEOUT
165
+ );
166
+ if (!revoked.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
167
+ }
168
+ await run(
169
+ [
170
+ 'vault',
171
+ 'kv',
172
+ 'metadata',
173
+ 'delete',
174
+ address,
175
+ `-mount=${location.mount}`,
176
+ location.path,
177
+ ],
178
+ TIMEOUT
179
+ );
180
+ await run(
181
+ ['vault', 'policy', 'delete', address, workerPolicyName(node)],
182
+ TIMEOUT
183
+ );
184
+ return {
185
+ id: 'auth:worker-token',
186
+ status: 'pass',
187
+ summary: `${node}'s token is revoked and its document and policy removed.`,
188
+ };
189
+ }
190
+
191
+ function credentialFailure(summary: string): Check {
192
+ return {
193
+ id: 'auth:worker-token',
194
+ status: 'fail',
195
+ summary,
196
+ remedy: {
197
+ action:
198
+ 'Log the manager in to Vault with a token that may write policies, issue tokens and write the fleet mount, then run the command again.',
199
+ },
200
+ };
201
+ }