@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
@@ -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
  );
@@ -21,6 +21,7 @@ export interface WorkerManifest {
21
21
  /** The policy a worker's manifest is measured against. */
22
22
  interface UbuntuPolicy {
23
23
  packages: Record<string, string>;
24
+ releases: { release: string; packages?: Record<string, string> }[];
24
25
  alwaysOnServices: string[];
25
26
  }
26
27
 
@@ -41,6 +42,8 @@ export function manifestProbe(services: string[]): string {
41
42
  "mise ls --current --json 2>/dev/null | tr -d '\\n'; echo",
42
43
  'echo @units',
43
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")',
44
47
  ].join('; ');
45
48
  }
46
49
 
@@ -95,6 +98,14 @@ export function parseManifest(stdout: string): WorkerManifest {
95
98
  };
96
99
  }
97
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
+
98
109
  /** The same manifest always has the same digest. */
99
110
  export function manifestDigest(manifest: WorkerManifest): string {
100
111
  return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
@@ -133,10 +144,17 @@ export function diffManifests(
133
144
  */
134
145
  export function declaredDifferences(
135
146
  manifest: WorkerManifest,
136
- policy: UbuntuPolicy
147
+ policy: UbuntuPolicy,
148
+ release: string | null = null
137
149
  ): ManifestDifference[] {
138
150
  const differences: ManifestDifference[] = [];
139
- for (const [name, version] of Object.entries(policy.packages)) {
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)) {
140
158
  const actual = manifest.packages[name] ?? null;
141
159
  if (actual !== version)
142
160
  differences.push({
@@ -191,11 +209,16 @@ export async function measureManifest(
191
209
  node: FleetNode,
192
210
  run: Runner,
193
211
  policy: UbuntuPolicy
194
- ): Promise<{ manifest: WorkerManifest } | { check: Check }> {
212
+ ): Promise<
213
+ { manifest: WorkerManifest; release: string | null } | { check: Check }
214
+ > {
195
215
  const result = await run(
196
216
  [...sshArgs(node), manifestProbe(policy.alwaysOnServices)],
197
217
  { timeoutMs: 60_000 }
198
218
  );
199
219
  if (!result.ok) return { check: sshFailure(result) };
200
- return { manifest: parseManifest(result.stdout) };
220
+ return {
221
+ manifest: parseManifest(result.stdout),
222
+ release: manifestRelease(result.stdout),
223
+ };
201
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',
@@ -47,6 +54,8 @@ const FIELDS: Record<string, (value: unknown) => boolean> = {
47
54
  neverSleep: (value) => value === true,
48
55
  toolProfiles: (value) => value === undefined || toolProfiles(value) !== null,
49
56
  tools: (value) => value === undefined || toolNames(value),
57
+ exposure: (value) => value === undefined || parseExposure(value) !== null,
58
+ features: (value) => value === undefined || featuresFault(value) === null,
50
59
  };
51
60
  /** The first field of a worker policy that breaks its rule, or null. */
52
61
  export function policyFault(value: Record<string, unknown>): string | null {
@@ -79,5 +88,12 @@ export function parseWorkerPolicy(value: unknown): WorkerPolicy {
79
88
  throw new FleetUsageError(
80
89
  'Invalid mandatory worker policy. Workers require the locked toolchain, common services, unattended sudo, persistent T3 and no sleep.'
81
90
  );
82
- 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
+ };
83
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
  ];
@@ -0,0 +1,129 @@
1
+ import { playbookConnection } from './ssh';
2
+ import type { PackagePlaybook } from './platform/ansible';
3
+ import { blocking, type Check, type FleetNode } from './types';
4
+
5
+ /** Why fleet-sync reboots a worker: its first converge, which nothing can be
6
+ * running on yet, or a maintenance reboot the operator asked for
7
+ * (`--maintenance-reboot`) to apply installed updates.
8
+ */
9
+ export type RebootReason = 'first-converge' | 'maintenance';
10
+
11
+ /** The boot-policy codes a reboot settles: the managed power files were
12
+ * written after this boot, or the running login manager has not loaded them.
13
+ */
14
+ const SETTLED_BY_REBOOT = [
15
+ 'UBUNTU_MAINTENANCE_REBOOT_PENDING',
16
+ 'UBUNTU_SESSION_POLICY_PENDING',
17
+ ];
18
+
19
+ /** The converge ran (its bootstrap passed) and says a reboot is needed. */
20
+ export function rebootNeeded(checks: Check[]): boolean {
21
+ if (
22
+ checks.some((check) => check.id.startsWith('bootstrap:') && blocking(check))
23
+ )
24
+ return false;
25
+ return checks.some(
26
+ (check) =>
27
+ (check.id === 'ubuntu:boot-policy' &&
28
+ SETTLED_BY_REBOOT.includes(check.summary)) ||
29
+ (check.id === 'worker:reboot-pending' && check.status !== 'pass')
30
+ );
31
+ }
32
+
33
+ /** A maintenance run goes on to the next worker only once this one is back
34
+ * and ready: a reboot that failed, or one after which the worker is not
35
+ * ready, stops the rest.
36
+ */
37
+ export function rebootStopsRollout(node: { checks: Check[] }): boolean {
38
+ const reboot = node.checks.find((check) => check.id === 'worker:reboot');
39
+ if (!reboot) return false;
40
+ if (reboot.summary === 'WORKER_REBOOT_FAILED') return true;
41
+ return reboot.status === 'pass' && node.checks.some(blocking);
42
+ }
43
+
44
+ /** Installed updates wait for a reboot: what --maintenance-reboot applies. */
45
+ export function updatesPending(checks: Check[]): boolean {
46
+ return checks.some(
47
+ (check) => check.id === 'worker:reboot-pending' && check.status !== 'pass'
48
+ );
49
+ }
50
+
51
+ /** The home of the account a roster entry converges, from its workspace. */
52
+ export function workerHome(node: FleetNode): string | null {
53
+ const suffix = '/.local/share/personal/fleet';
54
+ return node.workspace.endsWith(suffix)
55
+ ? node.workspace.slice(0, -suffix.length) || null
56
+ : null;
57
+ }
58
+
59
+ /** Reboots one worker through ansible/ubuntu/reboot.yml, run from the
60
+ * manager: only while its T3 runs no agent turn (unless `force`), waiting
61
+ * until it answers again. The play's output is read for its own codes only.
62
+ */
63
+ export async function rebootWorker(
64
+ node: FleetNode,
65
+ request: { revision: string; reason: RebootReason; force?: boolean },
66
+ playbook: PackagePlaybook
67
+ ): Promise<Check> {
68
+ const home = workerHome(node);
69
+ if (!home)
70
+ return {
71
+ id: 'worker:reboot',
72
+ status: 'fail',
73
+ summary: 'WORKER_REBOOT_FAILED',
74
+ remedy: {
75
+ action:
76
+ 'The roster names no workspace under the account home, so the worker was not rebooted. Rerun fleet-sync once it is enrolled.',
77
+ },
78
+ };
79
+ // `user@alias` only for a worker whose account this run created.
80
+ // A worker reached from declared data logs in as its declared account.
81
+ const connection = playbookConnection(node);
82
+ const [login, target] = node.ssh.includes('@')
83
+ ? node.ssh.split('@', 2)
84
+ : [connection.user ?? '', node.ssh];
85
+ const result = await playbook(
86
+ 'ubuntu/reboot.yml',
87
+ {
88
+ target,
89
+ login,
90
+ home,
91
+ revision: request.revision,
92
+ reason: request.reason,
93
+ force: Boolean(request.force),
94
+ ssh_options: connection.options,
95
+ },
96
+ // The play waits up to 900 seconds for the worker to answer again.
97
+ 1_200_000
98
+ );
99
+ if (result.ok)
100
+ return {
101
+ id: 'worker:reboot',
102
+ status: 'pass',
103
+ summary:
104
+ request.reason === 'first-converge'
105
+ ? 'Rebooted once after its first converge, which no agent turn ran on; it answered again and was converged once more.'
106
+ : 'Rebooted for maintenance while no agent turn ran; it answered again and was converged once more.',
107
+ };
108
+ if (result.stdout.includes('WORKER_REBOOT_BUSY'))
109
+ return {
110
+ id: 'worker:reboot',
111
+ // A busy worker is left alone, and the others still go: its boot
112
+ // policy or its pending updates still say a reboot is needed.
113
+ status: 'warn',
114
+ summary: 'WORKER_REBOOT_BUSY',
115
+ remedy: {
116
+ action:
117
+ 'An agent turn is running on the worker, so it was not rebooted. Rerun fleet-sync --maintenance-reboot once the turn has ended, or add --force to end it.',
118
+ },
119
+ };
120
+ return {
121
+ id: 'worker:reboot',
122
+ status: 'fail',
123
+ summary: 'WORKER_REBOOT_FAILED',
124
+ remedy: {
125
+ action:
126
+ 'The reboot did not complete, or the worker did not answer within 15 minutes. Check its console with the provider, then rerun fleet-sync.',
127
+ },
128
+ };
129
+ }