@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
@@ -4,14 +4,28 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
4
4
  import { verifyResourceSafety } from './platform/resource-verify';
5
5
  import policy from '../ansible/ubuntu/policy.json';
6
6
  import { userInfo } from 'node:os';
7
- import { T3_HEALTH_SCRIPT, runningVersionCheck } from './platform/t3-service';
7
+ import {
8
+ T3_HEALTH_SCRIPT,
9
+ runningVersionCheck,
10
+ t3Unit,
11
+ } from './platform/t3-service';
12
+ import {
13
+ DEFAULT_FEATURES,
14
+ featuresCheck,
15
+ notApplicable,
16
+ offReason,
17
+ partsOff,
18
+ presentParts,
19
+ type WorkerFeatures,
20
+ } from './worker-features';
21
+ import { detectMachineClass, type MachineClass } from './machine/machine-class';
8
22
  import { compareDebianVersions } from './platform/debian-version';
9
23
  import { packagePins, ubuntuRelease } from './platform/ubuntu-release';
10
24
  import type { Check, Runner } from './types';
11
25
 
12
26
  function check(id: string, ok: boolean, summary: string): Check {
13
27
  return {
14
- id: `worker:${id}`,
28
+ id,
15
29
  status: ok ? 'pass' : 'fail',
16
30
  summary,
17
31
  ...(!ok
@@ -35,7 +49,7 @@ export function pinnedPackage(
35
49
  installed === undefined ? -1 : compareDebianVersions(installed, pinned);
36
50
  if (order <= 0)
37
51
  return check(
38
- `package:${name}`,
52
+ `worker:package:${name}`,
39
53
  order === 0,
40
54
  order === 0
41
55
  ? 'Installed at the version the policy pins.'
@@ -69,25 +83,34 @@ export function serviceState(name: string, show: string): string {
69
83
  return `${code} ${name}.service (${state}, unit file ${file || 'missing'}, restart ${field('Restart') || 'unknown'})`;
70
84
  }
71
85
 
72
- /** Measured runtime evidence, independent of installer receipts. */
86
+ /** Measured runtime evidence, independent of installer receipts. What a
87
+ * worker's declared features leave out, and what its class of machine does
88
+ * not have, is reported as not applicable, with the reason. */
73
89
  export async function workerHostChecks(
74
90
  user: string,
75
- run: Runner
91
+ run: Runner,
92
+ features: WorkerFeatures = DEFAULT_FEATURES,
93
+ powerRecoveryNote?: string
76
94
  ): Promise<Check[]> {
77
95
  const audit = await auditUbuntu(run);
96
+ const off = partsOff(features);
97
+ const { machine, virtualization } = await detectMachineClass(run);
78
98
  const checks = [
79
99
  check(
80
- 'ubuntu',
100
+ 'worker:ubuntu',
81
101
  audit.supported,
82
102
  'Required Ubuntu release and architecture.'
83
103
  ),
84
104
  check(
85
- 'sudo',
105
+ 'worker:sudo',
86
106
  audit.passwordlessSudo,
87
107
  'Designated worker user has noninteractive sudo.'
88
108
  ),
89
109
  ...Object.entries(packagePins(ubuntuRelease(audit.release))).map(
90
- ([name, version]) => pinnedPackage(name, version, audit.packages[name])
110
+ ([name, version]) =>
111
+ off.packages.includes(name)
112
+ ? notApplicable(`worker:package:${name}`, offReason(features, name))
113
+ : pinnedPackage(name, version, audit.packages[name])
91
114
  ),
92
115
  pinnedPackage(
93
116
  'tailscale',
@@ -96,6 +119,12 @@ export async function workerHostChecks(
96
119
  ),
97
120
  ];
98
121
  for (const name of policy.alwaysOnServices) {
122
+ if (off.services.includes(name)) {
123
+ checks.push(
124
+ notApplicable(`worker:service:${name}`, offReason(features, name))
125
+ );
126
+ continue;
127
+ }
99
128
  const unit = await run([
100
129
  'systemctl',
101
130
  'show',
@@ -113,7 +142,7 @@ export async function workerHostChecks(
113
142
  /^Restart=always$/m.test(unit.stdout);
114
143
  checks.push(
115
144
  check(
116
- `service:${name}`,
145
+ `worker:service:${name}`,
117
146
  ok,
118
147
  ok
119
148
  ? 'Required system service is active, enabled for boot and restarts unconditionally.'
@@ -122,33 +151,48 @@ export async function workerHostChecks(
122
151
  );
123
152
  }
124
153
  checks.push(await rebootPendingCheck(audit.rebootPending, run));
125
- checks.push(...(await recoveryChecks(run)));
126
- checks.push(...(await verifyUbuntuBoot(user, run)));
154
+ checks.push(featuresCheck(features, await presentParts(run, user, features)));
155
+ checks.push(
156
+ ...(await recoveryChecks(run, features, {
157
+ machine,
158
+ virtualization,
159
+ ...(powerRecoveryNote ? { note: powerRecoveryNote } : {}),
160
+ }))
161
+ );
162
+ checks.push(...(await verifyUbuntuBoot(user, run, machine)));
127
163
  checks.push(...(await verifyResourceSafety(userInfo().homedir, run)));
128
164
  return checks;
129
165
  }
130
166
 
131
- async function recoveryChecks(run: Runner): Promise<Check[]> {
167
+ async function recoveryChecks(
168
+ run: Runner,
169
+ features: WorkerFeatures,
170
+ host: { machine: MachineClass; virtualization: string; note?: string }
171
+ ): Promise<Check[]> {
132
172
  const checks: Check[] = [];
133
173
  const t3 = await run([
134
174
  'systemctl',
135
175
  '--user',
136
176
  'show',
137
- 't3code-desktop.service',
177
+ t3Unit(features.session),
138
178
  '--property=ActiveState,UnitFileState,Restart',
139
179
  ]);
140
180
  checks.push(
141
181
  check(
142
- 't3-recovery',
182
+ 'worker:t3-recovery',
143
183
  t3.ok &&
144
184
  /^ActiveState=active$/m.test(t3.stdout) &&
145
185
  /^UnitFileState=enabled$/m.test(t3.stdout) &&
146
186
  /^Restart=(always|on-failure)$/m.test(t3.stdout),
147
- 'The T3 desktop app runs as an enabled session service with automatic crash recovery.'
187
+ features.session === 'headless'
188
+ ? 'The T3 server runs as an enabled lingering user service with automatic crash recovery.'
189
+ : 'The T3 desktop app runs as an enabled session service with automatic crash recovery.'
148
190
  )
149
191
  );
150
192
  checks.push(await t3HealthCheck(run));
151
- checks.push(await runningVersionCheck(run, userInfo().uid));
193
+ checks.push(
194
+ await runningVersionCheck(run, userInfo().uid, Date.now(), features.session)
195
+ );
152
196
  const renewal = await run([
153
197
  'systemctl',
154
198
  '--user',
@@ -157,13 +201,20 @@ async function recoveryChecks(run: Runner): Promise<Check[]> {
157
201
  ]);
158
202
  checks.push(
159
203
  check(
160
- 'credential-renewal',
204
+ 'worker:credential-renewal',
161
205
  renewal.ok,
162
206
  'Worker credential renewal timer is active.'
163
207
  )
164
208
  );
165
209
  checks.push(await serviceRecoveryCheck(run));
166
- checks.push(await powerRecoveryCheck(run));
210
+ checks.push(
211
+ host.machine === 'virtual'
212
+ ? notApplicable(
213
+ 'worker:power-loss-recovery',
214
+ `a virtual machine (${host.virtualization}) has no firmware its guest can read, and its provider restarts it after a power loss${host.note ? ` (${host.note})` : ''}`
215
+ )
216
+ : await powerRecoveryCheck(run)
217
+ );
167
218
  return checks;
168
219
  }
169
220
 
@@ -194,7 +245,7 @@ export async function serviceRecoveryCheck(run: Runner): Promise<Check> {
194
245
  ? `WORKER_RECOVERY_LAST_RUN_FAILED (${/^[\w-]+$/.test(result) ? result : 'unknown'})`
195
246
  : 'Worker service recovery timer is active and its last run succeeded.';
196
247
  return check(
197
- 'service-recovery',
248
+ 'worker:service-recovery',
198
249
  timer.ok && script.ok && unit.ok && result === 'success',
199
250
  summary
200
251
  );
@@ -224,7 +275,7 @@ async function t3HealthCheck(run: Runner): Promise<Check> {
224
275
  /* Raw responses are never reported. */
225
276
  }
226
277
  return check(
227
- 't3-health',
278
+ 'worker:t3-health',
228
279
  probe.ok && identified,
229
280
  'T3 local API answers with its environment identity within 10 seconds.'
230
281
  );
@@ -0,0 +1,224 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { PACKAGE_ROOT } from './package-info';
5
+ import { shellQuote, sshArgs, sshFailure } from './ssh';
6
+ import type { Check, FleetNode, Runner } from './types';
7
+
8
+ /** What a worker is made of, measured: every installed package and its
9
+ * version, the tools mise has active, and whether each always-on service is
10
+ * enabled and running. Two workers built from the same revision have the same
11
+ * manifest, so its digest proves a rebuild reproduces a machine, and its
12
+ * difference from a fresh build shows what a long-lived worker gained.
13
+ */
14
+ export interface WorkerManifest {
15
+ version: 1;
16
+ packages: Record<string, string>;
17
+ tools: Record<string, string>;
18
+ units: Record<string, string>;
19
+ }
20
+
21
+ /** The policy a worker's manifest is measured against. */
22
+ interface UbuntuPolicy {
23
+ packages: Record<string, string>;
24
+ releases: { release: string; packages?: Record<string, string> }[];
25
+ alwaysOnServices: string[];
26
+ }
27
+
28
+ export async function ubuntuPolicy(root = PACKAGE_ROOT): Promise<UbuntuPolicy> {
29
+ return JSON.parse(
30
+ await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
31
+ ) as UbuntuPolicy;
32
+ }
33
+
34
+ /** A read-only POSIX probe: it lists, and changes nothing. */
35
+ export function manifestProbe(services: string[]): string {
36
+ const units = services.map(shellQuote).join(' ');
37
+ return [
38
+ 'export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"',
39
+ 'echo @packages',
40
+ "dpkg-query -W -f='${binary:Package}=${Version}\\n' 2>/dev/null",
41
+ 'echo @tools',
42
+ "mise ls --current --json 2>/dev/null | tr -d '\\n'; echo",
43
+ 'echo @units',
44
+ `for unit in ${units}; do printf '%s=%s/%s\\n' "$unit" "$(systemctl is-enabled "$unit" 2>/dev/null)" "$(systemctl is-active "$unit" 2>/dev/null)"; done`,
45
+ 'echo @release',
46
+ '(. /etc/os-release 2>/dev/null && echo "$VERSION_ID")',
47
+ ].join('; ');
48
+ }
49
+
50
+ const sorted = (entries: [string, string][]): Record<string, string> =>
51
+ Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
52
+
53
+ function pairs(lines: string[]): [string, string][] {
54
+ return lines
55
+ .map((line) => line.trim())
56
+ .filter((line) => line.includes('='))
57
+ .map((line) => {
58
+ const at = line.indexOf('=');
59
+ return [line.slice(0, at), line.slice(at + 1)] as [string, string];
60
+ });
61
+ }
62
+
63
+ function tools(line: string | undefined): Record<string, string> {
64
+ try {
65
+ const listed = JSON.parse(line ?? '{}') as Record<
66
+ string,
67
+ { version?: string }[]
68
+ >;
69
+ return sorted(
70
+ Object.entries(listed).map(([tool, versions]) => [
71
+ tool,
72
+ versions
73
+ .map((entry) => entry.version ?? '')
74
+ .sort()
75
+ .join(','),
76
+ ])
77
+ );
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ /** The manifest the probe printed. Order never matters: everything is sorted. */
84
+ export function parseManifest(stdout: string): WorkerManifest {
85
+ const sections: Record<string, string[]> = {};
86
+ let current = '';
87
+ for (const line of stdout.split('\n')) {
88
+ if (/^@[a-z]+$/.test(line.trim())) {
89
+ current = line.trim().slice(1);
90
+ sections[current] = [];
91
+ } else if (current) sections[current]!.push(line);
92
+ }
93
+ return {
94
+ version: 1,
95
+ packages: sorted(pairs(sections['packages'] ?? [])),
96
+ tools: tools(sections['tools']?.find((line) => line.trim())),
97
+ units: sorted(pairs(sections['units'] ?? [])),
98
+ };
99
+ }
100
+
101
+ /** The release the probe reported. It is kept apart from the manifest, so
102
+ * the manifest and its digest are what they were before releases differed. */
103
+ export function manifestRelease(stdout: string): string | null {
104
+ const lines = stdout.split('\n').map((line) => line.trim());
105
+ const at = lines.indexOf('@release');
106
+ return (at >= 0 && lines.slice(at + 1).find(Boolean)) || null;
107
+ }
108
+
109
+ /** The same manifest always has the same digest. */
110
+ export function manifestDigest(manifest: WorkerManifest): string {
111
+ return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
112
+ }
113
+
114
+ export interface ManifestDifference {
115
+ section: 'packages' | 'tools' | 'units';
116
+ name: string;
117
+ expected: string | null;
118
+ actual: string | null;
119
+ }
120
+
121
+ /** What `live` has that `reference` does not, lacks, or has otherwise. */
122
+ export function diffManifests(
123
+ reference: WorkerManifest,
124
+ live: WorkerManifest
125
+ ): ManifestDifference[] {
126
+ const differences: ManifestDifference[] = [];
127
+ for (const section of ['packages', 'tools', 'units'] as const) {
128
+ const names = new Set([
129
+ ...Object.keys(reference[section]),
130
+ ...Object.keys(live[section]),
131
+ ]);
132
+ for (const name of [...names].sort()) {
133
+ const expected = reference[section][name] ?? null;
134
+ const actual = live[section][name] ?? null;
135
+ if (expected !== actual)
136
+ differences.push({ section, name, expected, actual });
137
+ }
138
+ }
139
+ return differences;
140
+ }
141
+
142
+ /** Where a manifest departs from what the policy declares: a pinned package
143
+ * at another version or missing, an always-on service not enabled and active.
144
+ */
145
+ export function declaredDifferences(
146
+ manifest: WorkerManifest,
147
+ policy: UbuntuPolicy,
148
+ release: string | null = null
149
+ ): ManifestDifference[] {
150
+ const differences: ManifestDifference[] = [];
151
+ // The release's own pins over the common ones, as tasks/release.yml
152
+ // combines them.
153
+ const pins = {
154
+ ...policy.packages,
155
+ ...policy.releases.find((entry) => entry.release === release)?.packages,
156
+ };
157
+ for (const [name, version] of Object.entries(pins)) {
158
+ const actual = manifest.packages[name] ?? null;
159
+ if (actual !== version)
160
+ differences.push({
161
+ section: 'packages',
162
+ name,
163
+ expected: version,
164
+ actual,
165
+ });
166
+ }
167
+ for (const unit of policy.alwaysOnServices) {
168
+ const actual = manifest.units[unit] ?? null;
169
+ if (actual !== 'enabled/active')
170
+ differences.push({
171
+ section: 'units',
172
+ name: unit,
173
+ expected: 'enabled/active',
174
+ actual,
175
+ });
176
+ }
177
+ return differences;
178
+ }
179
+
180
+ /** `worker:manifest`: the worker is what a fresh build of the same revision
181
+ * is (when a reference is given) and what the policy declares.
182
+ */
183
+ export function manifestCheck(differences: ManifestDifference[]): Check {
184
+ if (!differences.length)
185
+ return {
186
+ id: 'worker:manifest',
187
+ status: 'pass',
188
+ summary: 'The worker matches the declared pins and the fresh build.',
189
+ };
190
+ const shown = differences
191
+ .slice(0, 20)
192
+ .map(
193
+ (difference) =>
194
+ `${difference.section}:${difference.name} ${difference.expected ?? 'absent'} -> ${difference.actual ?? 'absent'}`
195
+ );
196
+ return {
197
+ id: 'worker:manifest',
198
+ status: 'fail',
199
+ summary: `WORKER_MANIFEST_DRIFT: ${differences.length} difference(s): ${shown.join('; ')}`,
200
+ remedy: {
201
+ action:
202
+ 'Something was installed, removed or changed outside fleet-sync. Rebuild the worker (fleet-node-replace) or declare the change in dev-fleet.',
203
+ },
204
+ };
205
+ }
206
+
207
+ /** Measures one worker's manifest over the verified SSH connection. */
208
+ export async function measureManifest(
209
+ node: FleetNode,
210
+ run: Runner,
211
+ policy: UbuntuPolicy
212
+ ): Promise<
213
+ { manifest: WorkerManifest; release: string | null } | { check: Check }
214
+ > {
215
+ const result = await run(
216
+ [...sshArgs(node), manifestProbe(policy.alwaysOnServices)],
217
+ { timeoutMs: 60_000 }
218
+ );
219
+ if (!result.ok) return { check: sshFailure(result) };
220
+ return {
221
+ manifest: parseManifest(result.stdout),
222
+ release: manifestRelease(result.stdout),
223
+ };
224
+ }
@@ -0,0 +1,70 @@
1
+ import type { TailnetComputer } from './tailscale';
2
+ import type { Check } from './types';
3
+ import { duration } from './worker-activity';
4
+
5
+ /** A parked worker is stopped or destroyed on purpose: it is listed with
6
+ * the time since it was parked, never contacted, never the canary, and it
7
+ * does not change the exit code. The fleet state says which ones are parked
8
+ * (src/state/workers.ts).
9
+ */
10
+ export function parkedCheck(id: string, parkedAt: string, now: Date): Check {
11
+ const since = Math.max(0, (now.getTime() - Date.parse(parkedAt)) / 1000);
12
+ return {
13
+ id: 'worker:parked',
14
+ status: 'not-applicable',
15
+ summary: `${id} is parked since ${parkedAt} (${duration(since)}): it is not contacted. devfleet fleet-node-resume --id ${id} brings it back.`,
16
+ };
17
+ }
18
+
19
+ /** What fleet-sync converges once parked workers are set aside: the fleet
20
+ * without them (so none is contacted, expected online or made the canary)
21
+ * and a not-applicable check for each, which never changes the exit code.
22
+ */
23
+ export function withoutParked<
24
+ D extends { workers: { id: string }[] },
25
+ N extends { id: string },
26
+ >(
27
+ definition: D,
28
+ nodes: N[],
29
+ parked: Record<string, string>,
30
+ now: Date
31
+ ): { definition: D; nodes: N[]; checks: Check[] } {
32
+ return {
33
+ definition: {
34
+ ...definition,
35
+ workers: definition.workers.filter((worker) => !parked[worker.id]),
36
+ },
37
+ nodes: nodes.filter((node) => !parked[node.id]),
38
+ checks: definition.workers
39
+ .filter((worker) => parked[worker.id])
40
+ .map((worker) => parkedCheck(worker.id, parked[worker.id]!, now)),
41
+ };
42
+ }
43
+
44
+ /** A parked worker that is online on the tailnet anyway: it costs money
45
+ * while nobody uses it, or it was resumed by hand.
46
+ */
47
+ export function parkedOnline(
48
+ id: string,
49
+ tailscaleName: string | undefined,
50
+ computers: TailnetComputer[]
51
+ ): Check | null {
52
+ if (!tailscaleName) return null;
53
+ const name = tailscaleName.toLowerCase();
54
+ const online = computers.some(
55
+ (computer) =>
56
+ !computer.self &&
57
+ computer.online &&
58
+ computer.dns.toLowerCase().replace(/\.$/, '') === name
59
+ );
60
+ return online
61
+ ? {
62
+ id: 'worker:parked-online',
63
+ status: 'warn',
64
+ summary: `${id} is parked, and online on the tailnet.`,
65
+ remedy: {
66
+ action: `Stop the machine if nobody uses it (it costs money while it runs), or resume it: devfleet fleet-node-resume --id ${id}.`,
67
+ },
68
+ }
69
+ : null;
70
+ }
@@ -2,6 +2,9 @@ import { CAPABILITIES, record } from './config';
2
2
  import { FleetUsageError } from './errors';
3
3
  import type { Capability } from './types';
4
4
  import { TOOL_PROFILES, type ToolProfile } from '../bootstrap/worker-tools';
5
+ import { parseExposure, type ExposureDeclaration } from './exposure';
6
+ import { featuresFault } from '../bootstrap/worker-features';
7
+ import type { FeaturesDefinition } from './definition';
5
8
 
6
9
  export interface WorkerPolicy {
7
10
  requiredCapabilities: Capability[];
@@ -17,6 +20,10 @@ export interface WorkerPolicy {
17
20
  toolProfiles?: ToolProfile[];
18
21
  /** Single tools pinned in dev-fleet's mise.toml every worker installs. */
19
22
  tools?: string[];
23
+ /** What every worker opens beyond loopback and the tailnet (src/exposure.ts). */
24
+ exposure?: ExposureDeclaration;
25
+ /** Features every worker has unless its own `features` say otherwise. */
26
+ features?: FeaturesDefinition;
20
27
  }
21
28
  const REQUIRED = [
22
29
  'github',
@@ -34,33 +41,29 @@ function validCapabilities(value: unknown): boolean {
34
41
  REQUIRED.every((item) => value.includes(item))
35
42
  );
36
43
  }
37
- function validPolicy(value: Record<string, unknown>): boolean {
44
+ const FIELDS: Record<string, (value: unknown) => boolean> = {
45
+ requiredCapabilities: validCapabilities,
46
+ githubLogin: (value) =>
47
+ typeof value === 'string' && /^[a-zA-Z0-9-]{1,39}$/.test(value),
48
+ vaultUser: (value) =>
49
+ typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,64}$/.test(value),
50
+ toolchain: (value) => value === 'mise.lock',
51
+ agentProfile: (value) => value === 'proxy',
52
+ unattendedSudo: (value) => value === true,
53
+ persistentT3: (value) => value === true,
54
+ neverSleep: (value) => value === true,
55
+ toolProfiles: (value) => value === undefined || toolProfiles(value) !== null,
56
+ tools: (value) => value === undefined || toolNames(value),
57
+ exposure: (value) => value === undefined || parseExposure(value) !== null,
58
+ features: (value) => value === undefined || featuresFault(value) === null,
59
+ };
60
+ /** The first field of a worker policy that breaks its rule, or null. */
61
+ export function policyFault(value: Record<string, unknown>): string | null {
62
+ const unknown = Object.keys(value).find((key) => !(key in FIELDS));
63
+ if (unknown !== undefined) return unknown;
38
64
  return (
39
- Object.keys(value).every((key) =>
40
- [
41
- 'requiredCapabilities',
42
- 'githubLogin',
43
- 'vaultUser',
44
- 'toolchain',
45
- 'agentProfile',
46
- 'unattendedSudo',
47
- 'persistentT3',
48
- 'neverSleep',
49
- 'toolProfiles',
50
- 'tools',
51
- ].includes(key)
52
- ) &&
53
- validTools(value) &&
54
- validCapabilities(value['requiredCapabilities']) &&
55
- typeof value['githubLogin'] === 'string' &&
56
- /^[a-zA-Z0-9-]{1,39}$/.test(value['githubLogin']) &&
57
- typeof value['vaultUser'] === 'string' &&
58
- /^[a-zA-Z0-9_.-]{1,64}$/.test(value['vaultUser']) &&
59
- value['toolchain'] === 'mise.lock' &&
60
- value['agentProfile'] === 'proxy' &&
61
- ['unattendedSudo', 'persistentT3', 'neverSleep'].every(
62
- (key) => value[key] === true
63
- )
65
+ Object.entries(FIELDS).find(([key, valid]) => !valid(value[key]))?.[0] ??
66
+ null
64
67
  );
65
68
  }
66
69
  /** A list of known tool profiles, each once; null when it is not one. */
@@ -71,14 +74,6 @@ export function toolProfiles(value: unknown): ToolProfile[] | null {
71
74
  ? (value as ToolProfile[])
72
75
  : null;
73
76
  }
74
- /** The optional tool profiles and single tools every worker adds. */
75
- function validTools(value: Record<string, unknown>): boolean {
76
- return (
77
- (value['toolProfiles'] === undefined ||
78
- toolProfiles(value['toolProfiles']) !== null) &&
79
- (value['tools'] === undefined || toolNames(value['tools']))
80
- );
81
- }
82
77
  function toolNames(value: unknown): boolean {
83
78
  return (
84
79
  Array.isArray(value) &&
@@ -89,9 +84,16 @@ function toolNames(value: unknown): boolean {
89
84
  );
90
85
  }
91
86
  export function parseWorkerPolicy(value: unknown): WorkerPolicy {
92
- if (!record(value) || !validPolicy(value))
87
+ if (!record(value) || policyFault(value) !== null)
93
88
  throw new FleetUsageError(
94
89
  'Invalid mandatory worker policy. Workers require the locked toolchain, common services, unattended sudo, persistent T3 and no sleep.'
95
90
  );
96
- return value as unknown as WorkerPolicy;
91
+ const exposure =
92
+ value['exposure'] === undefined
93
+ ? undefined
94
+ : parseExposure(value['exposure']);
95
+ return {
96
+ ...(value as unknown as WorkerPolicy),
97
+ ...(exposure ? { exposure } : {}),
98
+ };
97
99
  }
@@ -6,16 +6,25 @@ import { workerSelection } from '../bootstrap/worker-tools';
6
6
  import { browserCheck, dockerRunCheck } from './worker-capability-checks';
7
7
  import { gitChecks } from './git-identity';
8
8
  import type { Check, Runner } from './types';
9
- import { workerHostChecks } from './worker-host-checks';
9
+ import { UBUNTU_WORKER } from './platform/ubuntu-worker';
10
10
  import { workerAccessChecks } from './worker-access-checks';
11
11
  import { record } from './config';
12
+ import { workerExposure } from './exposure';
13
+ import { exposureCheck } from './worker-exposure';
14
+ import { notApplicable, offReason, workerFeatures } from './worker-features';
12
15
 
13
16
  export async function workerReadiness(
14
17
  run: Runner,
15
18
  nodeId = hostId(hostname()),
16
19
  root = process.cwd()
17
20
  ): Promise<Check[]> {
18
- const host = await workerHostChecks(userInfo().username, run);
21
+ const features = workerFeatures(fleet, nodeId);
22
+ const host = await UBUNTU_WORKER.hostChecks(
23
+ userInfo().username,
24
+ run,
25
+ features,
26
+ fleet.workers.find((entry) => entry.id === nodeId)?.powerRecoveryNote
27
+ );
19
28
  const access = await workerAccessChecks(run, nodeId);
20
29
  const revision = await run(['git', 'rev-parse', 'HEAD']);
21
30
  const singleton = await run(
@@ -62,11 +71,14 @@ export async function workerReadiness(
62
71
  ...host,
63
72
  ...access,
64
73
  proxy,
65
- await dockerRunCheck(run),
74
+ features.containers
75
+ ? await dockerRunCheck(run)
76
+ : notApplicable('worker:docker-run', offReason(features, 'docker')),
66
77
  await browserCheck(
67
78
  run,
68
79
  workerSelection(fleet, nodeId).profiles.includes('web')
69
80
  ),
81
+ await exposureCheck(workerExposure(fleet, nodeId), run),
70
82
  ...(await gitChecks(fleet.git, run)),
71
83
  ...(await workerCapacity(root, nodeId, run)),
72
84
  ];