@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
@@ -25,6 +25,7 @@ export async function convergeGitIdentity(
25
25
  git_name: git.name,
26
26
  git_email: git.email,
27
27
  git_home: home,
28
+ git_signing: git.signing ?? '',
28
29
  }),
29
30
  }
30
31
  );
@@ -33,7 +34,9 @@ export async function convergeGitIdentity(
33
34
  id: 'git:configuration',
34
35
  status: result.ok ? 'pass' : 'fail',
35
36
  summary: result.ok
36
- ? 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
37
+ ? git.signing
38
+ ? "The declared Git identity, the GitHub credential helper and commit signing with this node's own SSH key are written in ~/.config/git/config."
39
+ : 'The declared Git identity and the GitHub credential helper are written in ~/.config/git/config.'
37
40
  : 'GIT_CONFIGURATION_INCOMPLETE',
38
41
  ...(result.ok
39
42
  ? {}
@@ -152,9 +155,79 @@ export async function gitPushAuthCheck(
152
155
  };
153
156
  }
154
157
 
158
+ /** The node's own commit signing key, made on the node by
159
+ * `ansible/github/git.yml`: an interface, like every path written to a
160
+ * machine.
161
+ */
162
+ export function signingKey(home: string = homedir()): string {
163
+ return resolve(home, '.ssh/dev-fleet-git-signing');
164
+ }
165
+
166
+ /** What the signing probe signs: a fixed text, so nothing of the node's is. */
167
+ const SIGNING_PROBE = 'dev-fleet signing probe\n';
168
+
169
+ /** With `git.signing` declared, every commit and tag is signed with the
170
+ * node's own key: Git is configured to, and the key really signs. Without it
171
+ * there is no check.
172
+ */
173
+ export async function gitSigningCheck(
174
+ git: GitIdentity | undefined,
175
+ run: Runner,
176
+ home: string = homedir()
177
+ ): Promise<Check | null> {
178
+ if (git?.signing !== 'ssh') return null;
179
+ const id = 'worker:git-signing';
180
+ const key = signingKey(home);
181
+ const format = await effective(run, 'gpg.format');
182
+ const signer = await effective(run, 'user.signingkey');
183
+ const always = await effective(run, 'commit.gpgsign');
184
+ if (format !== 'ssh' || signer !== `${key}.pub` || always !== 'true')
185
+ return {
186
+ id,
187
+ status: 'fail',
188
+ summary:
189
+ !format && !signer && !always
190
+ ? 'GIT_SIGNING_MISSING'
191
+ : 'GIT_SIGNING_OVERRIDDEN',
192
+ remedy: {
193
+ action:
194
+ 'Rerun fleet-sync: it makes the node its own signing key and writes the signing settings in ~/.config/git/config. A ~/.gitconfig that sets gpg.format, user.signingkey or commit.gpgsign wins over it; remove those lines.',
195
+ },
196
+ };
197
+ const signed = await run(
198
+ ['ssh-keygen', '-Y', 'sign', '-n', 'git', '-f', key],
199
+ {
200
+ input: SIGNING_PROBE,
201
+ timeoutMs: 10_000,
202
+ }
203
+ );
204
+ if (!signed.ok || !signed.stdout.includes('-----BEGIN SSH SIGNATURE-----'))
205
+ return {
206
+ id,
207
+ status: 'fail',
208
+ summary: 'GIT_SIGNING_FAILED',
209
+ remedy: {
210
+ action:
211
+ "The node's signing key (~/.ssh/dev-fleet-git-signing) is missing or cannot sign without a prompt. Remove both halves of it and rerun fleet-sync, which makes a new one; then add the new public key to the GitHub account as a signing key.",
212
+ },
213
+ };
214
+ return {
215
+ id,
216
+ status: 'pass',
217
+ summary:
218
+ 'Commits and tags on this node are signed with its own SSH key. GitHub shows them as verified once ~/.ssh/dev-fleet-git-signing.pub is a signing key of the account.',
219
+ };
220
+ }
221
+
155
222
  export async function gitChecks(
156
223
  git: GitIdentity | undefined,
157
- run: Runner
224
+ run: Runner,
225
+ home: string = homedir()
158
226
  ): Promise<Check[]> {
159
- return [await gitIdentityCheck(git, run), await gitPushAuthCheck(git, run)];
227
+ const signing = await gitSigningCheck(git, run, home);
228
+ return [
229
+ await gitIdentityCheck(git, run),
230
+ await gitPushAuthCheck(git, run),
231
+ ...(signing ? [signing] : []),
232
+ ];
160
233
  }
package/src/ide/ide.ts CHANGED
@@ -40,4 +40,9 @@ export interface WorkerContext {
40
40
  * time it is seen through a verified link.
41
41
  */
42
42
  remember?: (node: string, identity: string) => Promise<void>;
43
+ /** Pairs the manager's IDE with the worker's when the manager cannot
44
+ * reach it and a pairing was asked for (a replaced worker): the check
45
+ * that says how it went, or nothing when no pairing was asked for.
46
+ */
47
+ pair?: (node: FleetNode) => Promise<Check | undefined>;
43
48
  }
package/src/ide/t3.ts CHANGED
@@ -78,7 +78,7 @@ export function t3Ide(inputs: T3Inputs = {}): Ide {
78
78
  await managerVersion(home(), context.workspace),
79
79
  await updatesCheck(
80
80
  inputs.policyPath ?? MACOS_POLICY,
81
- `cd ${context.workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`
81
+ `cd ${context.workspace} && ${ansibleCommand().join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml`
82
82
  ),
83
83
  ];
84
84
  },
@@ -89,9 +89,19 @@ export function t3Ide(inputs: T3Inputs = {}): Ide {
89
89
  const restarted = context.earlier.some(
90
90
  (check) => check.summary === T3_RESTARTED_SUMMARY
91
91
  );
92
- const linked = await verifyT3Linked(node, context.run, {
92
+ let linked = await verifyT3Linked(node, context.run, {
93
93
  reconnectMs: restarted ? 180_000 : 0,
94
94
  });
95
+ // A replaced worker: the manager pairs with its new environment.
96
+ const paired =
97
+ linked.status === 'fail' && context.pair
98
+ ? await context.pair(node)
99
+ : undefined;
100
+ if (paired) {
101
+ checks.push(paired);
102
+ if (paired.status === 'pass')
103
+ linked = await verifyT3Linked(node, context.run);
104
+ }
95
105
  checks.push(linked);
96
106
  if (
97
107
  linked.status !== 'fail' &&
@@ -22,10 +22,16 @@ export async function fileHash(path: string): Promise<string | null> {
22
22
  }
23
23
  }
24
24
 
25
+ /** A repository without a packages directory (dev-fleet itself) has an
26
+ * empty set of workspace manifests, which is known, not missing.
27
+ */
25
28
  export async function manifestHash(root: string): Promise<string | null> {
26
29
  try {
27
30
  const entries = await readdir(join(root, 'packages'), {
28
31
  withFileTypes: true,
32
+ }).catch((error: NodeJS.ErrnoException) => {
33
+ if (error.code === 'ENOENT') return [];
34
+ throw error;
29
35
  });
30
36
  const hashes: string[] = [];
31
37
  for (const entry of entries
@@ -0,0 +1,59 @@
1
+ import type { Runner } from '../types';
2
+
3
+ /** What kind of machine a worker is. A virtual machine has no lid, no
4
+ * battery and no firmware its guest can read, and restarting it after a
5
+ * power loss is its provider's job: checks of those are not applicable to
6
+ * it. Containers and WSL2 are not workers at all (docs/support-matrix.md).
7
+ */
8
+ export type MachineClass = 'physical' | 'virtual' | 'unsupported';
9
+
10
+ /** What systemd-detect-virt prints for a container or WSL rather than a
11
+ * hypervisor. */
12
+ const CONTAINERS = [
13
+ 'container-other',
14
+ 'docker',
15
+ 'lxc',
16
+ 'lxc-libvirt',
17
+ 'openvz',
18
+ 'podman',
19
+ 'pouch',
20
+ 'proot',
21
+ 'rkt',
22
+ 'systemd-nspawn',
23
+ 'wsl',
24
+ ];
25
+
26
+ /** The class of a machine from the probe's environment word (`machine`,
27
+ * `container` or `wsl`) and what systemd-detect-virt printed: `none` is bare
28
+ * metal, a hypervisor (`kvm`, `qemu`, `amazon`, `microsoft`, ...) a virtual
29
+ * machine. When it cannot tell (`unknown`, or nothing), the machine is
30
+ * taken as physical, so no check that applies to one is left out.
31
+ */
32
+ export function machineClass(
33
+ environment: string,
34
+ virtualization: string
35
+ ): MachineClass {
36
+ if (environment !== 'machine' || CONTAINERS.includes(virtualization))
37
+ return 'unsupported';
38
+ return ['', 'none', 'unknown'].includes(virtualization)
39
+ ? 'physical'
40
+ : 'virtual';
41
+ }
42
+
43
+ /** The class of the machine this runs on, and the hypervisor, measured by
44
+ * systemd-detect-virt. The worker's own checks use it; the manager's probe
45
+ * records it for each worker before anything is installed. */
46
+ export async function detectMachineClass(
47
+ run: Runner
48
+ ): Promise<{ machine: MachineClass; virtualization: string }> {
49
+ const detected = await run(['systemd-detect-virt']);
50
+ const word = detected.stdout.trim();
51
+ const virtualization = /^[a-z0-9-]{1,32}$/.test(word) ? word : 'unknown';
52
+ return {
53
+ machine: machineClass(
54
+ CONTAINERS.includes(virtualization) ? 'container' : 'machine',
55
+ virtualization
56
+ ),
57
+ virtualization,
58
+ };
59
+ }
@@ -1,10 +1,16 @@
1
1
  import platforms from '../../ansible/platforms.json';
2
- import { kernelArchitectures, ubuntuRelease } from '../platform/ubuntu-release';
2
+ import { UBUNTU_WORKER, workerPlatform } from '../platform/ubuntu-worker';
3
+ import { belowMinimum, MINIMUM_WORKER } from '../worker-sizing';
4
+ import { machineClass } from './machine-class';
3
5
 
4
- /** One POSIX probe for every worker, four lines: family, release, CPU and
5
- * the environment it runs in (`wsl`, `container` or `machine`, a physical
6
- * or virtual machine of its own). Read-only, and it needs nothing installed.
7
- * `root` moves the files it reads, for a stand-in; workers use `/`.
6
+ /** One POSIX probe for every worker, nine lines: family, release, CPU, the
7
+ * environment it runs in (`wsl`, `container` or `machine`, a physical or
8
+ * virtual machine of its own), the hypervisor systemd-detect-virt names
9
+ * (`none` on bare metal, `unknown` when it cannot tell), the CPU count, all
10
+ * memory in MiB, the size of the root filesystem in MiB, and its clock in
11
+ * seconds since the epoch, which the manager compares with its own.
12
+ * Read-only, and it needs nothing installed. `root` moves the files it
13
+ * reads, for a stand-in; workers use `/`.
8
14
  */
9
15
  export function platformProbe(root = ''): string {
10
16
  return `sh -c '${[
@@ -13,31 +19,54 @@ export function platformProbe(root = ''): string {
13
19
  `if grep -qiE "microsoft|wsl" ${root}/proc/sys/kernel/osrelease 2>/dev/null; then echo wsl`,
14
20
  `elif [ -e ${root}/.dockerenv ] || [ -e ${root}/run/.containerenv ] || systemd-detect-virt --container --quiet 2>/dev/null; then echo container`,
15
21
  'else echo machine; fi',
22
+ 'v=$(systemd-detect-virt --vm 2>/dev/null)',
23
+ 'echo "${v:-unknown}"',
24
+ 'nproc 2>/dev/null || echo',
25
+ // Each size prints its own line, empty when unread, so the clock after
26
+ // them stays on the ninth.
27
+ `m=$(awk "/^MemTotal:/{print int(\\$2/1024)}" ${root}/proc/meminfo 2>/dev/null)`,
28
+ 'echo "$m"',
29
+ `d=$(df -Pk ${root}/ 2>/dev/null | awk "NR==2{print int(\\$2/1024)}")`,
30
+ 'echo "$d"',
31
+ 'date -u +%s.%N',
16
32
  ].join('; ')}'`;
17
33
  }
18
34
  export const PLATFORM_PROBE = platformProbe();
19
35
 
20
36
  export interface PlatformBaseline {
21
- id: 'ubuntu:release' | 'platform:environment';
37
+ id: 'ubuntu:release' | 'platform:environment' | 'platform:worker-size';
22
38
  supported: boolean;
23
39
  summary: string;
24
40
  }
25
41
 
26
- /** A worker is an accepted Ubuntu release on one of that release's CPUs. */
42
+ /** A worker is an accepted release of a worker platform on one of that
43
+ * release's CPUs. The check keeps its ID and its refusal: Ubuntu is the only
44
+ * worker platform, and anything else is refused as not that baseline. */
27
45
  export function evaluatePlatform(probe: string): PlatformBaseline {
28
46
  const [family, version, cpu] = probe.split('\n').map((line) => line.trim());
29
- const release = family === 'ubuntu' ? ubuntuRelease(version) : undefined;
47
+ const platform = workerPlatform(family);
48
+ const release = platform?.release(version);
30
49
  const supported =
31
- release !== undefined && kernelArchitectures(release).includes(cpu ?? '');
50
+ platform !== undefined &&
51
+ release !== undefined &&
52
+ platform.kernelArchitectures(release).includes(cpu ?? '');
32
53
  return {
33
54
  id: 'ubuntu:release',
34
55
  supported,
35
56
  summary: supported
36
- ? `Ubuntu ${release.release} and supported CPU architecture observed.`
37
- : 'UBUNTU_BASELINE_MISMATCH',
57
+ ? `${platform.label} ${release.release} and supported CPU architecture observed.`
58
+ : (platform ?? UBUNTU_WORKER).mismatch,
38
59
  };
39
60
  }
40
61
 
62
+ /** The clock the probe read, in seconds since the epoch, or undefined. */
63
+ export function probedClock(probe: string): number | undefined {
64
+ const line = probe.split('\n')[8]?.trim() ?? '';
65
+ // GNU date prints nanoseconds; another date prints a literal N.
66
+ const match = /^(\d{9,})(\.\d+)?/.exec(line);
67
+ return match ? Number(match[1]! + (match[2] ?? '')) : undefined;
68
+ }
69
+
41
70
  /** Environments the support matrix (ansible/platforms.json) refuses, by the
42
71
  * word the probe prints for them. */
43
72
  const REFUSED = new Map(
@@ -62,3 +91,53 @@ export function evaluateEnvironment(probe: string): PlatformBaseline {
62
91
  : `WORKER_ENVIRONMENT_UNSUPPORTED: ${refused ?? 'unknown environment'}`,
63
92
  };
64
93
  }
94
+
95
+ const line = (probe: string, index: number): string =>
96
+ probe.split('\n')[index]?.trim() ?? '';
97
+
98
+ /** The class of machine the probe found: physical, virtual or unsupported
99
+ * (a container or WSL2), recorded for every worker. A worker that cannot
100
+ * say which hypervisor it runs on is taken as physical, so nothing that
101
+ * applies to a physical machine is left out. */
102
+ export function evaluateMachineClass(probe: string): {
103
+ id: 'platform:machine-class';
104
+ summary: string;
105
+ } {
106
+ const environment = line(probe, 3);
107
+ const virtualization = line(probe, 4) || 'unknown';
108
+ const found = machineClass(environment, virtualization);
109
+ return {
110
+ id: 'platform:machine-class',
111
+ summary:
112
+ found === 'virtual'
113
+ ? `Virtual machine (${virtualization}): checks of firmware power recovery and of a lid do not apply.`
114
+ : found === 'physical'
115
+ ? `Physical machine (systemd-detect-virt reported ${virtualization}).`
116
+ : `Unsupported: ${environment || 'unknown environment'}.`,
117
+ };
118
+ }
119
+
120
+ const BELOW_MINIMUM = 'WORKER_SIZE_BELOW_MINIMUM';
121
+
122
+ /** The machine against the stated minimum worker size (`sizing` in the
123
+ * Ubuntu policy), before anything is installed; null when the probe did not
124
+ * report a size. */
125
+ export function evaluateSize(probe: string): PlatformBaseline | null {
126
+ const [cpus, memoryMiB, diskMiB] = [5, 6, 7].map((index) =>
127
+ Number(line(probe, index) || NaN)
128
+ );
129
+ if (![cpus, memoryMiB, diskMiB].every((value) => Number.isInteger(value)))
130
+ return null;
131
+ const lacks = belowMinimum({
132
+ cpus: cpus!,
133
+ memoryMiB: memoryMiB!,
134
+ diskMiB: diskMiB!,
135
+ });
136
+ return {
137
+ id: 'platform:worker-size',
138
+ supported: lacks.length === 0,
139
+ summary: lacks.length
140
+ ? `${BELOW_MINIMUM}: ${lacks.join(', ')}`
141
+ : `${cpus} CPUs, ${memoryMiB} MiB of memory and a ${diskMiB} MiB root filesystem: at least the minimum worker (${MINIMUM_WORKER.cpus} CPUs, ${MINIMUM_WORKER.memoryMiB} MiB, ${MINIMUM_WORKER.diskMiB} MiB).`,
142
+ };
143
+ }
@@ -86,20 +86,39 @@ async function lockAbandoned(lock: string): Promise<boolean> {
86
86
  }
87
87
  }
88
88
 
89
- /** Removes an abandoned lock. The rename is atomic, so when several managers
90
- * race to recover it exactly one wins; the others simply retry the lock. */
91
- async function reclaimAbandonedLock(lock: string): Promise<boolean> {
89
+ /** Removes an abandoned lock. Judging a lock abandoned and renaming it aside
90
+ * are two steps: between them another reclaimer can have replaced it with a
91
+ * live lock of its own. So the lock is judged again where it was put aside,
92
+ * where nobody else can change it, and put back when it turns out to be held.
93
+ * `judged` runs between the two steps; only the features pause there. */
94
+ async function reclaimAbandonedLock(
95
+ lock: string,
96
+ judged: () => Promise<void>
97
+ ): Promise<boolean> {
92
98
  if (!(await lockAbandoned(lock))) return false;
99
+ await judged();
93
100
  const aside = `${lock}.abandoned-${randomUUID()}`;
94
101
  try {
95
102
  await rename(lock, aside);
96
103
  } catch {
97
104
  return true; // Someone else already recovered it; retry.
98
105
  }
106
+ if (!(await lockAbandoned(aside))) {
107
+ await rename(aside, lock).catch(() => undefined);
108
+ return false;
109
+ }
99
110
  await rm(aside, { recursive: true, force: true });
100
111
  return true;
101
112
  }
102
113
 
114
+ /** Removes the lock only while it is still the one this run wrote. */
115
+ async function releaseLock(lock: string, owner: string): Promise<void> {
116
+ const current = await readFile(join(lock, 'owner.json'), 'utf8').catch(
117
+ () => null
118
+ );
119
+ if (current === owner) await rm(lock, { recursive: true, force: true });
120
+ }
121
+
103
122
  /** Bundles are written next to the lock and removed in a `finally`, which a
104
123
  * killed run never reaches. Holding the lock means none is in use. */
105
124
  async function sweepTransferBundles(root: string): Promise<void> {
@@ -113,7 +132,8 @@ async function sweepTransferBundles(root: string): Promise<void> {
113
132
  * cleanup. Never steals a lock from a still-running installer. */
114
133
  export async function withMachineLock<T>(
115
134
  root: string,
116
- operation: () => Promise<T>
135
+ operation: () => Promise<T>,
136
+ judged: () => Promise<void> = async () => {}
117
137
  ): Promise<T> {
118
138
  await mkdir(root, { recursive: true, mode: 0o700 });
119
139
  const lock = join(root, 'sync.lock');
@@ -126,26 +146,28 @@ export async function withMachineLock<T>(
126
146
  throw new FleetUsageError(
127
147
  'Cannot create manager lock; inspect private state-directory permissions and free disk space.'
128
148
  );
129
- if (attempt < 3 && (await reclaimAbandonedLock(lock))) continue;
149
+ if (attempt < 3 && (await reclaimAbandonedLock(lock, judged))) continue;
130
150
  throw new FleetLockError(
131
151
  'Fleet sync is locked. Inspect running installers before removing an abandoned sync.lock directory, then rerun.'
132
152
  );
133
153
  }
134
154
  }
155
+ const owner =
156
+ JSON.stringify({
157
+ version: 1,
158
+ pid: process.pid,
159
+ startedAt: new Date().toISOString(),
160
+ run: randomUUID(),
161
+ }) + '\n';
135
162
  try {
136
- await writeFile(
137
- join(lock, 'owner.json'),
138
- JSON.stringify({
139
- version: 1,
140
- pid: process.pid,
141
- startedAt: new Date().toISOString(),
142
- }) + '\n',
143
- { flag: 'wx', mode: 0o600 }
144
- );
163
+ await writeFile(join(lock, 'owner.json'), owner, {
164
+ flag: 'wx',
165
+ mode: 0o600,
166
+ });
145
167
  await sweepTransferBundles(root);
146
168
  return await operation();
147
169
  } finally {
148
- await rm(lock, { recursive: true });
170
+ await releaseLock(lock, owner);
149
171
  }
150
172
  }
151
173
 
@@ -1,6 +1,6 @@
1
1
  import macos from '../../ansible/macos/policy.json';
2
2
  import platforms from '../../ansible/platforms.json';
3
- import { UBUNTU_RELEASES } from '../platform/ubuntu-release';
3
+ import { WORKER_PLATFORMS } from '../platform/ubuntu-worker';
4
4
 
5
5
  export type SupportStatus = 'supported' | 'best effort' | 'refused';
6
6
  export interface SupportRow {
@@ -25,14 +25,16 @@ export function supportMatrix(): SupportRow[] {
25
25
  status: 'supported',
26
26
  note: 'ansible/macos/policy.json',
27
27
  },
28
- ...UBUNTU_RELEASES.map((entry): SupportRow => ({
29
- role: 'worker',
30
- platform: 'Ubuntu',
31
- versions: `${entry.release} (${entry.codename})`,
32
- architectures: entry.architectures.join(', '),
33
- status: 'supported',
34
- note: 'ansible/ubuntu/policy.json; a machine of its own, physical or virtual',
35
- })),
28
+ ...WORKER_PLATFORMS.flatMap((platform) =>
29
+ platform.releases.map((entry): SupportRow => ({
30
+ role: 'worker',
31
+ platform: platform.label,
32
+ versions: `${entry.release} (${entry.codename})`,
33
+ architectures: entry.architectures.join(', '),
34
+ status: 'supported',
35
+ note: `${platform.policy}; a machine of its own, physical or virtual`,
36
+ }))
37
+ ),
36
38
  ...platforms.rows.map((row): SupportRow => ({
37
39
  role: row.role,
38
40
  platform: row.platform,
@@ -1,5 +1,6 @@
1
1
  import policy from '../ansible/ubuntu/policy.json';
2
2
  import type { DoctorCheck } from './doctor-check';
3
+ import { threshold } from './worker-sizing';
3
4
 
4
5
  /** Machine health sample parsed from `net/remote-probe.sh` output. */
5
6
  export type HealthSample = {
@@ -8,6 +9,12 @@ export type HealthSample = {
8
9
  readonly memFreePct?: number;
9
10
  readonly swapPct?: number;
10
11
  readonly diskMb?: number;
12
+ /** The size of the home filesystem, in MiB. */
13
+ readonly diskTotalMb?: number;
14
+ /** All memory, in MiB. */
15
+ readonly memTotalMb?: number;
16
+ /** Agent sessions running now: T3's agent scopes. */
17
+ readonly agentSessions?: number;
11
18
  /** Free inodes on the home filesystem, as a percentage of all of them. */
12
19
  readonly inodesFreePct?: number;
13
20
  /** 1 when the clock is synchronised to a time source, 0 when not. */
@@ -24,6 +31,9 @@ const PROBE_KEYS = {
24
31
  memfree: 'memFreePct',
25
32
  swap: 'swapPct',
26
33
  diskmb: 'diskMb',
34
+ disktotalmb: 'diskTotalMb',
35
+ memtotalmb: 'memTotalMb',
36
+ agentsessions: 'agentSessions',
27
37
  inodefree: 'inodesFreePct',
28
38
  ntpsync: 'clockSynced',
29
39
  ntp: 'timeService',
@@ -52,6 +62,8 @@ const LABELS = {
52
62
  disk: 'Disk space',
53
63
  inodes: 'Free inodes',
54
64
  };
65
+ /** The capacity checks of a machine, each reported as `worker:capacity:<id>`. */
66
+ export const HEALTH_CHECK_IDS = Object.keys(LABELS);
55
67
 
56
68
  function check(
57
69
  id: keyof typeof LABELS,
@@ -92,17 +104,28 @@ export function memoryCheck(s: HealthSample): DoctorCheck | null {
92
104
  return check('memory', 'pass', what);
93
105
  }
94
106
 
95
- /** Free disk: <2 GB fails (VS Code server, git, worktrees break), <10 GB warns. */
96
- export function diskCheck(diskMb: number | undefined): DoctorCheck | null {
107
+ /** Free disk, against lines that scale with the disk
108
+ * (`sizing.thresholds.capacityFail` and `capacityWarn` in policy.json): under
109
+ * the first fails (VS Code server, git, worktrees break), under the second
110
+ * warns. A disk of unknown size gets the upper bounds.
111
+ */
112
+ export function diskCheck(
113
+ diskMb: number | undefined,
114
+ diskTotalMb?: number
115
+ ): DoctorCheck | null {
97
116
  if (diskMb === undefined) return null;
98
- if (diskMb < 2048) {
117
+ const total = diskTotalMb ?? Number.MAX_SAFE_INTEGER;
118
+ const fail = threshold('capacityFail', total);
119
+ const warn = threshold('capacityWarn', total);
120
+ if (diskMb < fail) {
99
121
  return check(
100
122
  'disk',
101
123
  'fail',
102
124
  `almost full, ${diskMb} MB free. VS Code server, git and T3 worktrees will break.`
103
125
  );
104
126
  }
105
- if (diskMb < 10_240) return check('disk', 'warn', `low, ${diskMb} MB free`);
127
+ if (diskMb < warn)
128
+ return check('disk', 'warn', `low, ${diskMb} MB free (under ${warn} MB)`);
106
129
  return check('disk', 'pass', `${Math.floor(diskMb / 1024)} GB free`);
107
130
  }
108
131
 
@@ -136,7 +159,7 @@ export function healthChecks(s: HealthSample, alias: string): DoctorCheck[] {
136
159
  return [
137
160
  loadCheck(s, alias),
138
161
  memoryCheck(s),
139
- diskCheck(s.diskMb),
162
+ diskCheck(s.diskMb, s.diskTotalMb),
140
163
  inodesCheck(s.inodesFreePct),
141
164
  ].filter((c): c is DoctorCheck => c !== null);
142
165
  }