@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
@@ -15,6 +15,14 @@ export interface BootItem {
15
15
  detail: string;
16
16
  }
17
17
 
18
+ /** `notApplicable` names settings the machine lacks (a virtual Mac has no
19
+ * womp): the playbook skips them, so they are not drift.
20
+ */
21
+ export interface PowerDrift {
22
+ drifted: string[];
23
+ notApplicable: string[];
24
+ }
25
+
18
26
  /** Where the manager runs, behind one interface: how it is named, how it is
19
27
  * kept awake, how its services are supervised and where they keep data. The
20
28
  * macOS host is the default and the reference.
@@ -29,8 +37,9 @@ export interface ManagerHost {
29
37
  dataDirectory(home: string): string;
30
38
  /** The machine's local host name, as `manager.id` must name it. */
31
39
  localHostName(run: Runner): Promise<string | null>;
32
- /** The power settings that differ from the manager's policy. */
33
- powerDrift(run: Runner): Promise<string[]>;
40
+ /** The power settings that differ from the manager's policy, and those
41
+ * this machine does not have at all. */
42
+ powerDrift(run: Runner): Promise<PowerDrift>;
34
43
  /** What brings the manager's services back after a power cut, unattended. */
35
44
  bootRecovery(run: Runner, home: string): Promise<BootItem[]>;
36
45
  /** How a person applies what bootRecovery measures. */
@@ -78,10 +87,18 @@ export const MACOS_MANAGER: ManagerHost = {
78
87
  return result.ok ? hostId(result.stdout.trim()) : null;
79
88
  },
80
89
  async powerDrift(run) {
81
- const observed = await acPower(run);
82
- return Object.entries(macos.pmset)
83
- .filter(([key, value]) => observed(key) !== value)
84
- .map(([key]) => key);
90
+ const result = await run(['pmset', '-g', 'custom']);
91
+ const ac = result.ok ? result.stdout.split('AC Power:')[1] : undefined;
92
+ const observed = (key: string) =>
93
+ new RegExp(`^\\s*${key}\\s+(\\S+)`, 'm').exec(ac ?? '')?.[1];
94
+ const absent = (key: string) =>
95
+ ac !== undefined && observed(key) === undefined;
96
+ return {
97
+ drifted: Object.entries(macos.pmset)
98
+ .filter(([key, value]) => !absent(key) && observed(key) !== value)
99
+ .map(([key]) => key),
100
+ notApplicable: Object.keys(macos.pmset).filter(absent),
101
+ };
85
102
  },
86
103
  async bootRecovery(run, home) {
87
104
  const observed = await acPower(run);
@@ -126,7 +143,7 @@ export const MACOS_MANAGER: ManagerHost = {
126
143
  ];
127
144
  },
128
145
  bootRemedy: (workspace) =>
129
- `Apply the manager policy, as an administrator: cd ${workspace} && ${ansibleCommand('darwin').join(' ')} -K ansible/macos/manager.yml (autorestart, Docker Desktop at login), then fleet-sync (LaunchAgents). FileVault and automatic login are a person’s choice: docs/manager-recovery.md.`,
146
+ `fleet-sync applies ansible/macos/manager.yml on every run (autorestart, Docker Desktop at login, LaunchAgents); autorestart needs the one grant an administrator gives: cd ${workspace} && ${ansibleCommand('darwin').join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml. FileVault and automatic login are a person’s choice: docs/manager-recovery.md.`,
130
147
  };
131
148
 
132
149
  /** The sleep states a Linux manager must never enter. */
@@ -153,7 +170,7 @@ export const LINUX_MANAGER: ManagerHost = {
153
170
  const result = await run(['systemctl', 'is-enabled', target]);
154
171
  if (result.stdout.trim() !== 'masked') drifted.push(target);
155
172
  }
156
- return drifted;
173
+ return { drifted, notApplicable: [] };
157
174
  },
158
175
  async bootRecovery(run) {
159
176
  const user = userInfo().username;
@@ -1,4 +1,4 @@
1
- import policy from '../../ansible/ubuntu/policy.json';
1
+ import { threshold } from '../worker-sizing';
2
2
  import type { Check, Runner } from '../types';
3
3
 
4
4
  const AGENT_DROP_IN =
@@ -9,7 +9,6 @@ const GUARDS = [
9
9
  'personal-fleet-disk-gc.timer',
10
10
  ];
11
11
  const MIB = 1024 * 1024;
12
- const FLOOR_MIB = policy.resourceSafety.diskGuardFloorMiB;
13
12
 
14
13
  /** A failure code and remedy, or undefined when the probe passes. */
15
14
  type Probe = (run: Runner) => Promise<[string, string] | undefined>;
@@ -61,17 +60,29 @@ const swapActive: Probe = async (run) => {
61
60
  ];
62
61
  };
63
62
 
63
+ /** The disk guard's floor scales with the filesystem
64
+ * (`sizing.thresholds.diskGuardFloor`); one of unknown size gets the upper
65
+ * bound. */
64
66
  const diskAboveFloor =
65
67
  (path: string): Probe =>
66
68
  async (run) => {
67
- const df = await run(['df', '--output=avail', '-B1', path]);
68
- const free = Number(df.stdout.trim().split('\n').at(-1));
69
- if (!df.ok || !Number.isSafeInteger(free))
69
+ const df = await run(['df', '--output=avail,size', '-B1', path]);
70
+ const [free, size] = (df.stdout.trim().split('\n').at(-1) ?? '')
71
+ .trim()
72
+ .split(/\s+/)
73
+ .map(Number);
74
+ if (!df.ok || free === undefined || !Number.isSafeInteger(free))
70
75
  return ['DISK_FREE_UNREADABLE', `Free space on ${path} is unreadable.`];
71
- if (free >= FLOOR_MIB * MIB) return undefined;
76
+ const floor = threshold(
77
+ 'diskGuardFloor',
78
+ size !== undefined && Number.isSafeInteger(size)
79
+ ? Math.floor(size / MIB)
80
+ : Number.MAX_SAFE_INTEGER
81
+ );
82
+ if (free >= floor * MIB) return undefined;
72
83
  return [
73
84
  'DISK_FREE_BELOW_FLOOR',
74
- `${path} has ${Math.floor(free / MIB)} MiB free, under the ${FLOOR_MIB} MiB floor. The disk GC already removed idle build output and caches; remove stale worktrees or data, or run "sudo systemctl start personal-fleet-disk-gc.service" and read its journal.`,
85
+ `${path} has ${Math.floor(free / MIB)} MiB free, under the ${floor} MiB floor. The disk GC already removed idle build output and caches; remove stale worktrees or data, or run "sudo systemctl start personal-fleet-disk-gc.service" and read its journal.`,
75
86
  ];
76
87
  };
77
88
 
@@ -8,27 +8,40 @@ import { rename, rm, symlink } from 'node:fs/promises';
8
8
  import { userInfo } from 'node:os';
9
9
  import type { Runner, Check } from '../types';
10
10
 
11
+ /** The administrator policy a worker's T3 runs: the fleet's
12
+ * (`ansible/ubuntu/t3/policy.json`, which keeps linked environments on so
13
+ * the manager pairs with a replaced worker without a person) with the
14
+ * worker's provider instances locked.
15
+ */
16
+ export function workerPolicy(instances: Record<string, unknown>) {
17
+ return {
18
+ ...policy,
19
+ settings: {
20
+ ...policy.settings,
21
+ ...Object.fromEntries(
22
+ Object.entries(instances).map(([id, value]) => [
23
+ `providerInstances.${id}`,
24
+ { value, locked: true },
25
+ ])
26
+ ),
27
+ },
28
+ };
29
+ }
30
+
11
31
  export async function stageT3Policy(
12
32
  root: string,
13
33
  executable: string,
14
34
  run: Runner,
15
- node: string
35
+ target: { node: string; restartBusy?: boolean }
16
36
  ): Promise<Check> {
37
+ const { node } = target;
17
38
  const instances = await desired(
18
39
  resolve(root, 'agents/t3.json'),
19
40
  await stableWorkspace(root),
20
41
  node
21
42
  );
22
43
  const user = userInfo();
23
- const document = {
24
- ...policy,
25
- settings: Object.fromEntries(
26
- Object.entries(instances).map(([id, value]) => [
27
- `providerInstances.${id}`,
28
- { value, locked: true },
29
- ])
30
- ),
31
- };
44
+ const document = workerPolicy(instances);
32
45
  const result = await runAnsible(run, 't3-policy.yml', {
33
46
  cwd: resolve(root, 'ansible/ubuntu'),
34
47
  input: JSON.stringify({
@@ -37,6 +50,7 @@ export async function stageT3Policy(
37
50
  development_user: user.username,
38
51
  development_uid: user.uid,
39
52
  development_home: user.homedir,
53
+ t3_restart_busy: target.restartBusy === true,
40
54
  }),
41
55
  timeoutMs: 120_000,
42
56
  });
@@ -1,20 +1,41 @@
1
1
  import policy from '../../ansible/ubuntu/policy.json';
2
2
  import type { Runner, Check } from '../types';
3
+ import type { Session } from '../../bootstrap/worker-features';
3
4
 
4
5
  /** The user unit that runs the pinned desktop app (UI and embedded server). */
5
6
  export const T3_DESKTOP_UNIT = 't3code-desktop.service';
7
+ /** The lingering user unit that runs the pinned native server on a headless
8
+ * worker (`t3-headless.yml`). */
9
+ export const T3_HEADLESS_UNIT = 't3code-headless.service';
6
10
 
7
- /** Workers run one T3 process: the desktop app, started with the auto-login
8
- * graphical session. `t3-desktop.yml` installs it, retires the former headless
9
- * server and starts the app; this only refuses a foreign launcher beforehand
11
+ /** The unit that runs T3 for a session type. */
12
+ export function t3Unit(session: Session = 'desktop'): string {
13
+ return session === 'headless' ? T3_HEADLESS_UNIT : T3_DESKTOP_UNIT;
14
+ }
15
+
16
+ /** Workers run one T3 process: on a desktop worker the desktop app, started
17
+ * with the auto-login graphical session (`t3-desktop.yml`); on a headless
18
+ * worker the pinned native server as a lingering user service
19
+ * (`t3-headless.yml`). The play installs it, retires the other session
20
+ * type's unit and starts it; this only refuses a foreign launcher beforehand
10
21
  * and measures the result afterwards.
11
22
  */
12
23
  export async function convergeT3Service(
13
24
  artifact: { path: string; version: string },
14
25
  run: Runner,
15
- prepare: () => Promise<Check[]> = async () => [],
16
- install: () => Promise<boolean> = async () => true
26
+ steps: {
27
+ /** Stages the policy; any check it returns stops here. */
28
+ prepare?: () => Promise<Check[]>;
29
+ /** Applies the session type's playbook. */
30
+ install?: () => Promise<boolean>;
31
+ session?: Session;
32
+ } = {}
17
33
  ): Promise<Check[]> {
34
+ const {
35
+ prepare = async () => [],
36
+ install = async () => true,
37
+ session = 'desktop',
38
+ } = steps;
18
39
  const existing = await run(['t3', '--version']);
19
40
  if (conflictingVersion(existing, artifact.version))
20
41
  return pending(
@@ -23,27 +44,55 @@ export async function convergeT3Service(
23
44
  );
24
45
  const preparation = await prepare();
25
46
  if (preparation.length) return preparation;
26
- if (!(await install()))
27
- return pending(
47
+ const words = SESSION_WORDS[session];
48
+ if (!(await install())) return pending(...words.installFailed);
49
+ const unit = t3Unit(session);
50
+ const active = await userctl(run, 'is-active', unit);
51
+ const enabled = await userctl(run, 'is-enabled', unit);
52
+ if (!active.ok || !enabled.ok) return pending(...words.stopped(unit));
53
+ return [{ id: 't3:service', status: 'pass', summary: words.running }];
54
+ }
55
+
56
+ /** How a worker's T3 restarts for a new release or policy, as the manager's
57
+ * bootstrap passes it on to node convergence (`fleet-sync --restart-busy`).
58
+ * The words cross from one revision's script to another: an interface.
59
+ */
60
+ export const T3_RESTART_MODES = ['keep-turns', 'restart-busy'] as const;
61
+
62
+ /** What the check says of each session type: its codes and remedies. */
63
+ const SESSION_WORDS: Record<
64
+ Session,
65
+ {
66
+ installFailed: [string, string];
67
+ stopped: (unit: string) => [string, string];
68
+ running: string;
69
+ }
70
+ > = {
71
+ desktop: {
72
+ installFailed: [
28
73
  'T3_DESKTOP_INSTALL_FAILED',
29
- 'Inspect the t3-desktop.yml Ansible output (package download, apt, GDM auto-login, user unit). T3 data in ~/.t3 is untouched.'
30
- );
31
- const active = await userctl(run, 'is-active', T3_DESKTOP_UNIT);
32
- const enabled = await userctl(run, 'is-enabled', T3_DESKTOP_UNIT);
33
- if (!active.ok || !enabled.ok)
34
- return pending(
74
+ 'Inspect the t3-desktop.yml Ansible output (package download, apt, GDM auto-login, user unit). T3 data in ~/.t3 is untouched.',
75
+ ],
76
+ stopped: (unit) => [
35
77
  'T3_DESKTOP_STOPPED',
36
- `The desktop app needs the auto-login graphical session. Reboot or log in on the machine, then inspect journalctl --user -u ${T3_DESKTOP_UNIT}; ~/.t3 is preserved.`
37
- );
38
- return [
39
- {
40
- id: 't3:service',
41
- status: 'pass',
42
- summary:
43
- 'Pinned T3 desktop app is enabled for the auto-login session and running. Private routing, policy and pairing remain separate checks.',
44
- },
45
- ];
46
- }
78
+ `The desktop app needs the auto-login graphical session. Reboot or log in on the machine, then inspect journalctl --user -u ${unit}; ~/.t3 is preserved.`,
79
+ ],
80
+ running:
81
+ 'Pinned T3 desktop app is enabled for the auto-login session and running. Private routing, policy and pairing remain separate checks.',
82
+ },
83
+ headless: {
84
+ installFailed: [
85
+ 'T3_SERVER_INSTALL_FAILED',
86
+ 'Inspect the t3-headless.yml Ansible output (the pinned server in the toolchain, the user unit). T3 data in ~/.t3 is untouched.',
87
+ ],
88
+ stopped: (unit) => [
89
+ 'T3_SERVER_STOPPED',
90
+ `The T3 server is a lingering user service and needs no login. Inspect journalctl --user -u ${unit} and loginctl show-user for Linger=yes; ~/.t3 is preserved.`,
91
+ ],
92
+ running:
93
+ 'Pinned T3 server is enabled as a lingering user service and running, with no display. Private routing, policy and pairing remain separate checks.',
94
+ },
95
+ };
47
96
 
48
97
  /** The recovery script, installed outside every revision (`tasks/recovery.yml`). */
49
98
  export const T3_HEALTH_SCRIPT = '/usr/local/lib/personal-fleet/t3-health.sh';
@@ -85,7 +134,8 @@ export async function restartDeferral(
85
134
  export async function runningVersionCheck(
86
135
  run: Runner,
87
136
  uid: number,
88
- now: number = Date.now()
137
+ now: number = Date.now(),
138
+ session: Session = 'desktop'
89
139
  ): Promise<Check> {
90
140
  const server = await run(
91
141
  [
@@ -98,11 +148,8 @@ export async function runningVersionCheck(
98
148
  ],
99
149
  { timeoutMs: 15_000 }
100
150
  );
101
- const installed = await run(['cat', T3_DESKTOP_RELEASE]);
102
151
  const running = serverVersion(server.ok ? server.stdout : '');
103
- const release = /^T3-Code-(.+)-[a-z0-9]+\.deb$/.exec(
104
- installed.ok ? installed.stdout.trim() : ''
105
- )?.[1];
152
+ const release = await installedRelease(run, session);
106
153
  const id = 't3:running-version';
107
154
  if (!running || !release)
108
155
  return {
@@ -111,7 +158,7 @@ export async function runningVersionCheck(
111
158
  summary: 'T3_RUNNING_VERSION_UNKNOWN',
112
159
  remedy: {
113
160
  action:
114
- 'The running server or the installed release could not be read. Check worker:t3-health, then rerun fleet-sync to reinstall the pinned desktop app.',
161
+ 'The running server or the installed release could not be read. Check worker:t3-health, then rerun fleet-sync to reinstall the pinned T3.',
115
162
  },
116
163
  };
117
164
  if (running === release)
@@ -127,12 +174,37 @@ export async function runningVersionCheck(
127
174
  summary: 'T3_RESTART_PENDING',
128
175
  remedy: {
129
176
  action: deferral.overdue
130
- ? `T3 still runs ${running} after ${policy.t3RestartMaxDeferralHours} hours because agent turns kept running. Finish or stop them, or restart ${T3_DESKTOP_UNIT} on the worker (this ends its running turns).`
177
+ ? `T3 still runs ${running} after ${policy.t3RestartMaxDeferralHours} hours because agent turns kept running. Finish or stop them, or restart ${t3Unit(session)} on the worker (this ends its running turns).`
131
178
  : `T3 runs ${running}; ${release} is installed. personal-worker-recovery restarts it at the first check with no agent turn running.`,
132
179
  },
133
180
  };
134
181
  }
135
182
 
183
+ /** The release installed for the session: the desktop package the app was
184
+ * unpacked from, or the versioned directory the server unit runs. */
185
+ async function installedRelease(
186
+ run: Runner,
187
+ session: Session
188
+ ): Promise<string | undefined> {
189
+ if (session === 'headless') {
190
+ const shown = await run([
191
+ 'systemctl',
192
+ '--user',
193
+ 'show',
194
+ '--property=ExecStart',
195
+ '--value',
196
+ T3_HEADLESS_UNIT,
197
+ ]);
198
+ return shown.ok
199
+ ? /\/t3-([\w.+-]+?)-linux-[a-z0-9]+\/t3\b/.exec(shown.stdout)?.[1]
200
+ : undefined;
201
+ }
202
+ const installed = await run(['cat', T3_DESKTOP_RELEASE]);
203
+ return /^T3-Code-(.+)-[a-z0-9]+\.deb$/.exec(
204
+ installed.ok ? installed.stdout.trim() : ''
205
+ )?.[1];
206
+ }
207
+
136
208
  function serverVersion(body: string): string | undefined {
137
209
  try {
138
210
  const value: unknown = JSON.parse(body);
@@ -148,15 +220,18 @@ function serverVersion(body: string): string | undefined {
148
220
  }
149
221
  }
150
222
 
151
- /** When the desktop app last started; changes exactly when it restarts. */
152
- export async function t3StartedAt(run: Runner): Promise<string> {
223
+ /** When T3 last started; changes exactly when it restarts. */
224
+ export async function t3StartedAt(
225
+ run: Runner,
226
+ session: Session = 'desktop'
227
+ ): Promise<string> {
153
228
  const shown = await run([
154
229
  'systemctl',
155
230
  '--user',
156
231
  'show',
157
232
  '--property=ActiveEnterTimestampMonotonic',
158
233
  '--value',
159
- T3_DESKTOP_UNIT,
234
+ t3Unit(session),
160
235
  ]);
161
236
  return shown.ok ? shown.stdout.trim() : '';
162
237
  }
@@ -9,6 +9,11 @@ export interface UbuntuRelease {
9
9
  architectures: string[];
10
10
  /** Pins of this release that differ from the policy's `packages`. */
11
11
  packages?: Record<string, string>;
12
+ /** Pins of this release that differ from the policy's `browser.packages`. */
13
+ browserPackages?: Record<string, string>;
14
+ /** The digest of the key Tailscale signs this release's suite with, when
15
+ * it is not the policy's `tailscale.keySha256`. */
16
+ tailscaleKeySha256?: string;
12
17
  }
13
18
 
14
19
  export const UBUNTU_RELEASES: readonly UbuntuRelease[] = policy.releases;
@@ -21,6 +26,25 @@ export function packagePins(
21
26
  return { ...policy.packages, ...release?.packages };
22
27
  }
23
28
 
29
+ /** The browser library pins of one release, combined the same way. */
30
+ export function browserPackagePins(
31
+ release: UbuntuRelease | undefined
32
+ ): Record<string, string> {
33
+ return { ...policy.browser.packages, ...release?.browserPackages };
34
+ }
35
+
36
+ /** The Tailscale version and the digest of the key that signs one release's
37
+ * suite (as tasks/release.yml selects it for Ansible). */
38
+ export function tailscalePins(release: UbuntuRelease | undefined): {
39
+ version: string;
40
+ keySha256: string;
41
+ } {
42
+ return {
43
+ version: policy.tailscale.version,
44
+ keySha256: release?.tailscaleKeySha256 ?? policy.tailscale.keySha256,
45
+ };
46
+ }
47
+
24
48
  /** `uname -m` for each Debian architecture name. */
25
49
  const KERNEL_ARCHITECTURES: Record<string, string> = {
26
50
  amd64: 'x86_64',
@@ -1,9 +1,13 @@
1
1
  import type { Check, Runner } from '../types';
2
+ import type { MachineClass } from '../machine/machine-class';
2
3
 
3
- const LOGIND = {
4
+ /** What a lid does: a virtual machine has none, so these do not apply. */
5
+ const LID = {
4
6
  HandleLidSwitch: 's "ignore"',
5
7
  HandleLidSwitchDocked: 's "ignore"',
6
8
  HandleLidSwitchExternalPower: 's "ignore"',
9
+ };
10
+ const LOGIND = {
7
11
  IdleAction: 's "ignore"',
8
12
  KillUserProcesses: 'b false',
9
13
  StopIdleSessionUSec: 't 18446744073709551615',
@@ -17,9 +21,33 @@ const POWER_FILES = [
17
21
  */
18
22
  export async function verifyUbuntuBoot(
19
23
  user: string,
20
- run: Runner
24
+ run: Runner,
25
+ machine: MachineClass = 'physical'
26
+ ): Promise<Check[]> {
27
+ const virtual = machine === 'virtual';
28
+ // On a virtual machine the lid policy is not applicable; the sleep masks
29
+ // stay, since a guest can still be suspended by its own idle policy.
30
+ const lid: Check[] = virtual
31
+ ? [
32
+ {
33
+ id: 'ubuntu:lid-policy',
34
+ status: 'not-applicable',
35
+ summary: 'Not applicable: a virtual machine has no lid.',
36
+ },
37
+ ]
38
+ : [];
39
+ return [
40
+ ...lid,
41
+ ...(await bootPolicy(user, run, virtual ? LOGIND : { ...LID, ...LOGIND })),
42
+ ];
43
+ }
44
+
45
+ async function bootPolicy(
46
+ user: string,
47
+ run: Runner,
48
+ properties: Record<string, string>
21
49
  ): Promise<Check[]> {
22
- for (const [property, expected] of Object.entries(LOGIND)) {
50
+ for (const [property, expected] of Object.entries(properties)) {
23
51
  const observed = await run([
24
52
  'busctl',
25
53
  'get-property',
@@ -0,0 +1,58 @@
1
+ import type { Runner } from '../types';
2
+ import type { WorkerFeatures } from '../worker-features';
3
+ import {
4
+ browserPackagePins,
5
+ kernelArchitectures,
6
+ packagePins,
7
+ tailscalePins,
8
+ UBUNTU_RELEASES,
9
+ ubuntuRelease,
10
+ } from './ubuntu-release';
11
+ import type { WorkerPlatform, WorkerRelease } from './worker-platform';
12
+
13
+ /** Ubuntu: every release ansible/ubuntu/policy.json accepts, converged by
14
+ * the playbooks under ansible/ubuntu. A release's differences are data of
15
+ * its policy entry (tasks/release.yml selects it from the facts). */
16
+ export const UBUNTU_WORKER: WorkerPlatform = {
17
+ family: 'ubuntu',
18
+ label: 'Ubuntu',
19
+ policy: 'ansible/ubuntu/policy.json',
20
+ playbooks: 'ansible/ubuntu',
21
+ releases: UBUNTU_RELEASES,
22
+ mismatch: 'UBUNTU_BASELINE_MISMATCH',
23
+ packageManager: 'apt',
24
+ packagePins: (release) => packagePins(ubuntuRelease(release?.release)),
25
+ browserPackagePins: (release) =>
26
+ browserPackagePins(ubuntuRelease(release?.release)),
27
+ tailscalePins: (release) => tailscalePins(ubuntuRelease(release?.release)),
28
+ serviceManager: 'systemd',
29
+ sessionModel: 'systemd-user-linger',
30
+ release: (version) => ubuntuRelease(version),
31
+ kernelArchitectures: (release: WorkerRelease) =>
32
+ kernelArchitectures(ubuntuRelease(release.release) ?? release),
33
+ // Loaded when used: the platform probe reads this module on the manager,
34
+ // and the host checks pull in what only a worker runs.
35
+ hostChecks: async (
36
+ user: string,
37
+ run: Runner,
38
+ features?: WorkerFeatures,
39
+ powerRecoveryNote?: string
40
+ ) =>
41
+ (await import('../worker-host-checks')).workerHostChecks(
42
+ user,
43
+ run,
44
+ features,
45
+ powerRecoveryNote
46
+ ),
47
+ };
48
+
49
+ /** Every platform a worker may run. A machine of any other family is
50
+ * refused by the first check that can tell (docs/support-matrix.md). */
51
+ export const WORKER_PLATFORMS: readonly WorkerPlatform[] = [UBUNTU_WORKER];
52
+
53
+ /** The platform of an `ID` from /etc/os-release, when workers may run it. */
54
+ export function workerPlatform(
55
+ family: string | null | undefined
56
+ ): WorkerPlatform | undefined {
57
+ return WORKER_PLATFORMS.find((platform) => platform.family === family);
58
+ }
@@ -0,0 +1,72 @@
1
+ import type { Check, Runner } from '../types';
2
+ import type { WorkerFeatures } from '../worker-features';
3
+
4
+ /** One release of a worker platform, as its policy names it. */
5
+ export interface WorkerRelease {
6
+ /** `VERSION_ID` of /etc/os-release. */
7
+ release: string;
8
+ codename: string;
9
+ /** Debian architecture names, as `dpkg --print-architecture` reports them. */
10
+ architectures: string[];
11
+ }
12
+
13
+ /** What differs between the operating systems a worker may run. Everything
14
+ * the manager and the worker's own checks need to know about one platform is
15
+ * here; what a playbook needs is data of the platform's policy, selected on
16
+ * the machine from its facts. Ubuntu is the one implementation
17
+ * (`src/platform/ubuntu-worker.ts`); docs/support-matrix.md lists the
18
+ * platforms that are not, and what each would need.
19
+ */
20
+ export interface WorkerPlatform {
21
+ /** `ID` of /etc/os-release, as the platform probe prints it. */
22
+ readonly family: string;
23
+ /** The name the support matrix gives it. */
24
+ readonly label: string;
25
+ /** The policy that names its releases and pins, from the package root. */
26
+ readonly policy: string;
27
+ /** The playbooks that converge it, from the package root. */
28
+ readonly playbooks: string;
29
+ /** The releases a worker may run, newest first. */
30
+ readonly releases: readonly WorkerRelease[];
31
+ /** The reason a machine of this family is refused before anything is
32
+ * installed: another release or another CPU. */
33
+ readonly mismatch: string;
34
+
35
+ /** Packages: the system package manager the pins are written for, and the
36
+ * exact pins of one release (common pins with the release's own over them). */
37
+ readonly packageManager: 'apt';
38
+ packagePins(release: WorkerRelease | undefined): Record<string, string>;
39
+ /** The libraries headless Chromium loads, pinned the same way. */
40
+ browserPackagePins(
41
+ release: WorkerRelease | undefined
42
+ ): Record<string, string>;
43
+ /** The Tailscale version and the digest of the key that signs this
44
+ * release's package suite. */
45
+ tailscalePins(release: WorkerRelease | undefined): {
46
+ version: string;
47
+ keySha256: string;
48
+ };
49
+
50
+ /** Service manager: what keeps the always-on system services and the
51
+ * developer's own units running and restarts them. */
52
+ readonly serviceManager: 'systemd';
53
+ /** Session model: how the developer's processes (T3, agents) outlive a
54
+ * login. `systemd-user-linger`: a lingering systemd user manager, started
55
+ * at boot without anyone logged in, never stopped by logind. */
56
+ readonly sessionModel: 'systemd-user-linger';
57
+
58
+ /** The release a probe reported, when the platform accepts it. */
59
+ release(version: string | null | undefined): WorkerRelease | undefined;
60
+ /** The `uname -m` names one release runs on. */
61
+ kernelArchitectures(release: WorkerRelease): string[];
62
+ /** Checks: the measured host evidence of a worker of this platform,
63
+ * read-only, run on the worker itself. What its declared features leave
64
+ * out is not applicable; so is a physical machine's power recovery on a
65
+ * virtual one, which `powerRecoveryNote` may say who provides. */
66
+ hostChecks(
67
+ user: string,
68
+ run: Runner,
69
+ features?: WorkerFeatures,
70
+ powerRecoveryNote?: string
71
+ ): Promise<Check[]>;
72
+ }
package/src/protocol.ts CHANGED
@@ -2,7 +2,7 @@ import { record } from './config';
2
2
  import { FINGERPRINT_FILES } from './input-hashes';
3
3
  import type { Check, Snapshot } from './types';
4
4
 
5
- const STATUSES = ['pass', 'warn', 'fail', 'skip'];
5
+ const STATUSES = ['pass', 'warn', 'fail', 'skip', 'not-applicable'];
6
6
 
7
7
  export function validCheck(value: unknown): value is Check {
8
8
  if (