@crvouga/dev-fleet 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +88 -7
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +83 -0
  14. package/ansible/macos/manager.yml +164 -9
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/macos/worker-host-key.yml +33 -0
  19. package/ansible/monitoring/agent-darwin.yml +3 -0
  20. package/ansible/monitoring/agent.yml +2 -0
  21. package/ansible/monitoring/hub-darwin.yml +3 -0
  22. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  23. package/ansible/net/remote-probe.sh +4 -1
  24. package/ansible/platforms.json +2 -2
  25. package/ansible/pyproject.toml +5 -0
  26. package/ansible/ubuntu/baseline.yml +39 -13
  27. package/ansible/ubuntu/disk-gc.py +6 -2
  28. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  29. package/ansible/ubuntu/policy.json +125 -6
  30. package/ansible/ubuntu/reboot.yml +89 -0
  31. package/ansible/ubuntu/recovery.yml +13 -3
  32. package/ansible/ubuntu/t3/policy.json +3 -0
  33. package/ansible/ubuntu/t3-desktop.yml +23 -2
  34. package/ansible/ubuntu/t3-headless.yml +128 -0
  35. package/ansible/ubuntu/t3-health.sh +24 -10
  36. package/ansible/ubuntu/t3-idle.py +37 -2
  37. package/ansible/ubuntu/t3-policy.yml +4 -1
  38. package/ansible/ubuntu/t3-route.yml +1 -0
  39. package/ansible/ubuntu/tailscale.yml +54 -8
  40. package/ansible/ubuntu/tasks/browser.yml +3 -0
  41. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  42. package/ansible/ubuntu/tasks/docker.yml +53 -6
  43. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  44. package/ansible/ubuntu/tasks/features.yml +22 -0
  45. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  46. package/ansible/ubuntu/tasks/install.yml +2 -1
  47. package/ansible/ubuntu/tasks/packages.yml +4 -2
  48. package/ansible/ubuntu/tasks/release.yml +6 -0
  49. package/ansible/ubuntu/tasks/resources.yml +6 -4
  50. package/ansible/ubuntu/tasks/services.yml +2 -2
  51. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  52. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  53. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  54. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  55. package/ansible/ubuntu/tasks/user.yml +1 -0
  56. package/ansible/ubuntu/worker-account.yml +117 -0
  57. package/ansible/uv.lock +265 -0
  58. package/bin/devfleet.mjs +42 -4
  59. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  60. package/bootstrap/global-config.ts +29 -3
  61. package/bootstrap/package-smoke.ts +127 -0
  62. package/bootstrap/pins.ts +189 -0
  63. package/bootstrap/release-notes.ts +88 -0
  64. package/bootstrap/release-version.ts +41 -7
  65. package/bootstrap/worker-features.ts +114 -0
  66. package/bootstrap/worker-seed.yaml +65 -0
  67. package/bootstrap/worker-tools.ts +39 -9
  68. package/cliproxy/README.md +1 -1
  69. package/cliproxy/docs/health-workflow.example.yml +20 -143
  70. package/docs/authentication.md +21 -11
  71. package/docs/consumer-setup.md +74 -58
  72. package/docs/convergence.md +67 -0
  73. package/docs/declared.md +61 -34
  74. package/docs/machine-setup.md +73 -7
  75. package/docs/manager-setup.md +34 -0
  76. package/docs/operations.md +64 -9
  77. package/docs/releasing.md +49 -1
  78. package/docs/runbooks.md +112 -7
  79. package/docs/state.md +23 -11
  80. package/docs/support-matrix.md +40 -4
  81. package/docs/topology.md +82 -12
  82. package/docs/ubuntu-sync.md +98 -42
  83. package/docs/worker-contract.md +122 -19
  84. package/docs/worker-requirements.md +107 -0
  85. package/examples/fleet.json +1 -0
  86. package/examples/workers-from-infrastructure/main.tf +132 -0
  87. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  88. package/llms.txt +3 -1
  89. package/mise.lock +29 -0
  90. package/mise.toml +5 -3
  91. package/package.json +2 -2
  92. package/schema/check-ids.json +1991 -162
  93. package/schema/check-ids.schema.json +34 -0
  94. package/schema/fleet.schema.json +349 -10
  95. package/schema/output/check.schema.json +1 -1
  96. package/schema/output/fleet-state-definition.schema.json +14 -0
  97. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  98. package/schema/output/fleet-sync.schema.json +18 -0
  99. package/schema/output/report.schema.json +3 -2
  100. package/schema/worker-record.schema.json +76 -0
  101. package/src/agents/converge.ts +140 -24
  102. package/src/agents/files.ts +2 -32
  103. package/src/agents/native-verify.ts +34 -3
  104. package/src/agents/secrets.ts +39 -4
  105. package/src/check-owners.ts +62 -2
  106. package/src/check-registry.ts +630 -0
  107. package/src/cli/agent-guide.ts +3 -3
  108. package/src/cli/cli.ts +4 -0
  109. package/src/cli/fleet-manifest.ts +108 -0
  110. package/src/cli/fleet-node-lifecycle.ts +704 -0
  111. package/src/cli/fleet-node-park.ts +238 -0
  112. package/src/cli/fleet-node-seed.ts +167 -0
  113. package/src/cli/fleet-state.ts +2 -1
  114. package/src/cli/fleet-sync-checkout.ts +7 -0
  115. package/src/cli/fleet-sync-enrollment.ts +193 -32
  116. package/src/cli/fleet-sync-forward.ts +6 -0
  117. package/src/cli/fleet-sync-manager.ts +123 -8
  118. package/src/cli/fleet-sync-output.ts +31 -11
  119. package/src/cli/fleet-sync-plan.ts +65 -9
  120. package/src/cli/fleet-sync-records.ts +113 -0
  121. package/src/cli/fleet-sync-remote.ts +172 -15
  122. package/src/cli/fleet-sync-schedule.ts +14 -4
  123. package/src/cli/fleet-sync-state.ts +31 -10
  124. package/src/cli/fleet-sync-vault.ts +152 -26
  125. package/src/cli/fleet-sync-worker.ts +214 -0
  126. package/src/cli/fleet-sync.ts +172 -21
  127. package/src/cli/fleet-validate.ts +277 -0
  128. package/src/cli/fleet-watch.ts +7 -1
  129. package/src/cli/fleet.ts +87 -5
  130. package/src/cli/managed-health.ts +25 -1
  131. package/src/cli/register-fleet.ts +251 -4
  132. package/src/cli/registry.ts +47 -0
  133. package/src/cli/tailnet.ts +43 -10
  134. package/src/cli/ui-data.ts +3 -1
  135. package/src/cli/worker-usage-report.ts +63 -0
  136. package/src/clock.ts +42 -0
  137. package/src/definition.ts +762 -232
  138. package/src/diagnostics.ts +5 -2
  139. package/src/exposure.ts +102 -0
  140. package/src/fleet-config.ts +3 -1
  141. package/src/git-identity.ts +76 -3
  142. package/src/ide/ide.ts +5 -0
  143. package/src/ide/t3.ts +12 -2
  144. package/src/input-hashes.ts +6 -0
  145. package/src/machine/machine-class.ts +59 -0
  146. package/src/machine/platform.ts +90 -11
  147. package/src/machine/state.ts +70 -16
  148. package/src/machine/support-matrix.ts +11 -9
  149. package/src/machine-health.ts +28 -5
  150. package/src/manager-health.ts +148 -2
  151. package/src/manager-sync.ts +89 -8
  152. package/src/monitoring/beszel.ts +8 -8
  153. package/src/monitoring/enroll.ts +8 -1
  154. package/src/monitoring/hub-access.ts +10 -4
  155. package/src/monitoring/systems.ts +138 -0
  156. package/src/network-definition.ts +13 -4
  157. package/src/node-converge-linux.ts +136 -27
  158. package/src/node-converge.ts +13 -3
  159. package/src/platform/ansible-receipt.ts +17 -2
  160. package/src/platform/ansible.ts +40 -4
  161. package/src/platform/manager-host.ts +25 -8
  162. package/src/platform/resource-verify.ts +18 -7
  163. package/src/platform/t3-policy.ts +24 -10
  164. package/src/platform/t3-service.ts +109 -34
  165. package/src/platform/ubuntu-release.ts +24 -0
  166. package/src/platform/ubuntu-verify.ts +31 -3
  167. package/src/platform/ubuntu-worker.ts +58 -0
  168. package/src/platform/worker-platform.ts +72 -0
  169. package/src/protocol.ts +1 -1
  170. package/src/public-reachability.ts +186 -0
  171. package/src/remote-bootstrap.ts +50 -12
  172. package/src/remote-operation.ts +18 -4
  173. package/src/repair-plan.ts +23 -12
  174. package/src/report.ts +2 -1
  175. package/src/schedule-check.ts +21 -2
  176. package/src/secrets.ts +7 -0
  177. package/src/service-auth.ts +55 -48
  178. package/src/ssh-connection.ts +141 -0
  179. package/src/ssh.ts +63 -24
  180. package/src/state/store.ts +4 -2
  181. package/src/state/sync.ts +35 -6
  182. package/src/state/workers.ts +140 -0
  183. package/src/t3/pairing.ts +59 -0
  184. package/src/t3/peer-link.ts +333 -0
  185. package/src/t3/version.ts +1 -1
  186. package/src/tailnet-cleanup.ts +185 -0
  187. package/src/tailnet-control.ts +346 -0
  188. package/src/tailscale.ts +1 -1
  189. package/src/toolchain-checks.ts +11 -1
  190. package/src/types.ts +4 -2
  191. package/src/worker-account.ts +63 -0
  192. package/src/worker-activity.ts +303 -0
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-credentials.ts +201 -0
  195. package/src/worker-exposure.ts +388 -0
  196. package/src/worker-features.ts +201 -0
  197. package/src/worker-host-checks.ts +71 -20
  198. package/src/worker-manifest.ts +224 -0
  199. package/src/worker-parking.ts +70 -0
  200. package/src/worker-policy.ts +38 -36
  201. package/src/worker-readiness.ts +15 -3
  202. package/src/worker-reboot.ts +129 -0
  203. package/src/worker-records.ts +305 -0
  204. package/src/worker-seed.ts +143 -0
  205. package/src/worker-sizing.ts +76 -0
  206. package/src/cli/schedule-logs.ts +0 -36
@@ -0,0 +1,138 @@
1
+ import { record } from '../config';
2
+ import type { Check } from '../types';
3
+ import type { HubRequest } from './enroll';
4
+
5
+ /** The hub keeps one system per worker, named by its fleet ID. These change
6
+ * that list when a worker is replaced or retired, and find the systems no
7
+ * declared worker matches. Only names and hosts are read; a record's other
8
+ * fields (tokens, users) are never returned.
9
+ */
10
+ export interface HubSystem {
11
+ id: string;
12
+ name: string;
13
+ host: string;
14
+ }
15
+
16
+ function systems(value: unknown): HubSystem[] {
17
+ if (!record(value) || !Array.isArray(value['items']))
18
+ throw new Error('BESZEL_PROTOCOL');
19
+ return value['items'].flatMap((item) =>
20
+ record(item) &&
21
+ typeof item['id'] === 'string' &&
22
+ /^[a-z0-9]{15}$/.test(item['id']) &&
23
+ typeof item['name'] === 'string'
24
+ ? [
25
+ {
26
+ id: item['id'],
27
+ name: item['name'],
28
+ host: typeof item['host'] === 'string' ? item['host'] : '',
29
+ },
30
+ ]
31
+ : []
32
+ );
33
+ }
34
+
35
+ export async function listSystems(request: HubRequest): Promise<HubSystem[]> {
36
+ return systems(
37
+ await request(
38
+ '/api/collections/systems/records?perPage=500&fields=id,name,host'
39
+ )
40
+ );
41
+ }
42
+
43
+ async function named(request: HubRequest, node: string): Promise<HubSystem[]> {
44
+ return systems(
45
+ await request(
46
+ `/api/collections/systems/records?filter=${encodeURIComponent(`name="${node}"`)}&perPage=2&fields=id,name,host`
47
+ )
48
+ );
49
+ }
50
+
51
+ const check = (status: Check['status'], summary: string, action?: string) => ({
52
+ id: 'beszel:system',
53
+ status,
54
+ summary,
55
+ ...(action ? { remedy: { action } } : {}),
56
+ });
57
+
58
+ /** Points a replaced worker's system at the host it is reached by now, so
59
+ * the hub keeps one system for the ID and its history.
60
+ */
61
+ export async function moveSystem(
62
+ request: HubRequest,
63
+ node: string,
64
+ host: string
65
+ ): Promise<Check> {
66
+ const found = await named(request, node);
67
+ if (found.length > 1)
68
+ return check(
69
+ 'fail',
70
+ 'BESZEL_DUPLICATE_NODE',
71
+ `The hub holds more than one system named ${node}. Delete the extra ones in the dashboard, then run the command again.`
72
+ );
73
+ if (!found[0])
74
+ return check(
75
+ 'pass',
76
+ `The hub holds no system for ${node}; fleet-sync enrolls the new machine.`
77
+ );
78
+ if (found[0].host === host)
79
+ return check('pass', `The hub's system ${node} already watches ${host}.`);
80
+ await request(
81
+ `/api/collections/systems/records/${found[0].id}`,
82
+ { host },
83
+ 'PATCH'
84
+ );
85
+ return check('pass', `The hub's system ${node} now watches ${host}.`);
86
+ }
87
+
88
+ /** Deletes a retired worker's system and its agent fingerprint. */
89
+ export async function removeSystem(
90
+ request: HubRequest,
91
+ node: string
92
+ ): Promise<Check> {
93
+ const found = await named(request, node);
94
+ for (const system of found) {
95
+ const prints = await request(
96
+ `/api/collections/fingerprints/records?filter=${encodeURIComponent(`system="${system.id}"`)}&perPage=10&fields=id`
97
+ );
98
+ for (const print of record(prints) && Array.isArray(prints['items'])
99
+ ? prints['items']
100
+ : [])
101
+ if (record(print) && typeof print['id'] === 'string')
102
+ await request(
103
+ `/api/collections/fingerprints/records/${print['id']}`,
104
+ undefined,
105
+ 'DELETE'
106
+ );
107
+ await request(
108
+ `/api/collections/systems/records/${system.id}`,
109
+ undefined,
110
+ 'DELETE'
111
+ );
112
+ }
113
+ return check(
114
+ 'pass',
115
+ found.length
116
+ ? `The hub no longer watches ${node}.`
117
+ : `The hub holds no system for ${node}.`
118
+ );
119
+ }
120
+
121
+ /** A system no declared worker (and not the manager) is named after: what a
122
+ * worker removed by hand, or destroyed outside dev-fleet, leaves behind.
123
+ */
124
+ export function leftoverSystems(
125
+ found: HubSystem[],
126
+ declared: string[]
127
+ ): Check[] {
128
+ return found
129
+ .filter((system) => !declared.includes(system.name))
130
+ .map((system) => ({
131
+ id: 'beszel:leftover',
132
+ status: 'warn' as const,
133
+ summary: `The hub watches ${system.name}, which is no declared worker.`,
134
+ remedy: {
135
+ action: `If ${system.name} left the fleet, retire it (devfleet fleet-node-retire --id ${system.name} --apply --yes while it is still declared) or delete the system in the dashboard.`,
136
+ },
137
+ }));
138
+ }
@@ -1,4 +1,8 @@
1
- import { type FleetDefinition, type MachineDefinition } from './definition';
1
+ import {
2
+ sshTarget,
3
+ type FleetDefinition,
4
+ type MachineDefinition,
5
+ } from './definition';
2
6
  import { record } from './config';
3
7
  import { FleetUsageError } from './errors';
4
8
 
@@ -19,8 +23,11 @@ export function networkProjection(
19
23
  node.network
20
24
  ? [
21
25
  {
22
- alias: node.ssh,
23
- ...node.network,
26
+ alias: sshTarget(node),
27
+ tailscaleName: node.network.tailscaleName,
28
+ user: node.network.user,
29
+ sshPort: node.network.sshPort,
30
+ t3Port: node.network.t3Port,
24
31
  os: node.os,
25
32
  identityFile: identities[node.id] ?? '',
26
33
  },
@@ -34,7 +41,9 @@ function compatible(
34
41
  ): node is MachineDefinition {
35
42
  return Boolean(
36
43
  node?.network &&
37
- Object.entries(node.network).every(([key, value]) => host[key] === value) &&
44
+ Object.entries(node.network)
45
+ .filter(([key]) => key !== 'hostKey')
46
+ .every(([key, value]) => host[key] === value) &&
38
47
  node.os === host['os']
39
48
  );
40
49
  }
@@ -18,17 +18,27 @@ import {
18
18
  convergeT3Service,
19
19
  runningVersionCheck,
20
20
  t3StartedAt,
21
+ t3Unit,
21
22
  } from './platform/t3-service';
23
+ import {
24
+ featuresCheck,
25
+ presentParts,
26
+ workerFeatures,
27
+ type WorkerFeatures,
28
+ } from './worker-features';
22
29
  import { userInfo } from 'node:os';
23
30
  import { resolve } from 'node:path';
24
31
  import { auditUbuntu } from './platform/ubuntu-audit';
32
+ import { detectMachineClass } from './machine/machine-class';
25
33
  import { convergeGitIdentity } from './git-identity';
26
34
  import { workerSelection } from '../bootstrap/worker-tools';
35
+ import { workerExposure } from './exposure';
27
36
  import {
28
37
  applyAgentProfiles,
29
38
  attempt,
30
39
  configureWorkstation,
31
40
  } from './node-converge-shared';
41
+ import { loginServer } from './tailnet-control';
32
42
  import type { Check, Runner } from './types';
33
43
 
34
44
  /** Ubuntu worker convergence: Ansible owns OS policy, services and recovery; the
@@ -36,10 +46,11 @@ import type { Check, Runner } from './types';
36
46
  */
37
47
  export async function convergeLinux(
38
48
  root: string,
39
- target: { node: string; revision: string },
49
+ target: { node: string; revision: string; restartBusy?: boolean },
40
50
  run: Runner
41
51
  ): Promise<Check[]> {
42
52
  const { node, revision } = target;
53
+ const restartBusy = target.restartBusy === true;
43
54
  const audit = await auditUbuntu(run);
44
55
  if (!audit.supported || !audit.passwordlessSudo)
45
56
  return [
@@ -58,12 +69,23 @@ export async function convergeLinux(
58
69
  const checks: Check[] = [];
59
70
  checks.push(
60
71
  ...(await attempt('tailscale:configuration', async () => [
61
- await applyTailscale(root, run),
72
+ await applyTailscale(root, node, run),
62
73
  ]))
63
74
  );
75
+ const features = workerFeatures(fleet, node);
76
+ const before = await presentParts(run, userInfo().username, features);
64
77
  const { baseline, verified } = await baselineStep(root, run, node);
65
78
  checks.push(...baseline, ...verified);
66
- checks.push(...(await t3AfterBaseline(root, { node, baseline, run })));
79
+ checks.push(
80
+ featuresCheck(
81
+ features,
82
+ await presentParts(run, userInfo().username, features),
83
+ before
84
+ )
85
+ );
86
+ checks.push(
87
+ ...(await t3AfterBaseline(root, { node, baseline, run, restartBusy }))
88
+ );
67
89
  checks.push(
68
90
  ...(await attempt('workstation:configuration', () =>
69
91
  configureWorkstation(root, run, node)
@@ -120,9 +142,10 @@ async function baselineStep(
120
142
  run: Runner,
121
143
  node: string
122
144
  ): Promise<{ baseline: Check[]; verified: Check[] }> {
145
+ const { machine } = await detectMachineClass(run);
123
146
  const measure = async () => [
124
147
  ...(await attempt('ubuntu:boot-policy', () =>
125
- verifyUbuntuBoot(userInfo().username, run)
148
+ verifyUbuntuBoot(userInfo().username, run, machine)
126
149
  )),
127
150
  ...(await attempt('ubuntu:resource-safety', () =>
128
151
  verifyResourceSafety(userInfo().homedir, run)
@@ -151,6 +174,7 @@ async function applyBaseline(
151
174
  node: string
152
175
  ): Promise<{ cached: boolean; checks: Check[] }> {
153
176
  const user = userInfo();
177
+ const features = workerFeatures(fleet, node);
154
178
  const ubuntu = resolve(root, 'ansible/ubuntu');
155
179
  const result = await runAnsibleWithReceipt(
156
180
  run,
@@ -165,13 +189,18 @@ async function applyBaseline(
165
189
  development_home: user.homedir,
166
190
  // The web profile brings headless Chromium (tasks/browser.yml).
167
191
  worker_tool_profiles: workerSelection(fleet, node).profiles,
192
+ // What the firewall opens beyond the tailnet (tasks/firewall.yml).
193
+ worker_exposure: workerExposure(fleet, node).policy,
194
+ // What the declared features leave out (tasks/features.yml).
195
+ worker_containers: features.containers,
196
+ worker_session: features.session,
168
197
  }),
169
198
  },
170
199
  {
171
200
  name: 'ubuntu-baseline',
172
201
  sources: [ubuntu, resolve(root, 'ansible/github')],
173
202
  force,
174
- verify: () => baselineServicesActive(run),
203
+ verify: () => baselineServicesActive(run, features),
175
204
  }
176
205
  );
177
206
  return {
@@ -191,14 +220,17 @@ async function applyBaseline(
191
220
  }
192
221
 
193
222
  /** Cheap probe for what the baseline owns: system services and user timers. */
194
- async function baselineServicesActive(run: Runner): Promise<boolean> {
223
+ async function baselineServicesActive(
224
+ run: Runner,
225
+ features: WorkerFeatures
226
+ ): Promise<boolean> {
195
227
  const system = await run([
196
228
  'systemctl',
197
229
  'is-active',
198
230
  '--quiet',
199
231
  'ssh.service',
200
232
  'tailscaled.service',
201
- 'docker.service',
233
+ ...(features.containers ? ['docker.service'] : []),
202
234
  ]);
203
235
  if (!system.ok) return false;
204
236
  const user = await run([
@@ -212,12 +244,27 @@ async function baselineServicesActive(run: Runner): Promise<boolean> {
212
244
  return user.ok;
213
245
  }
214
246
 
215
- async function applyTailscale(root: string, run: Runner): Promise<Check> {
247
+ /** A worker that was never logged in joins under its declared ID when the
248
+ * seed or the bootstrap left it a single-use key; the key carries the fleet's
249
+ * tag.
250
+ */
251
+ async function applyTailscale(
252
+ root: string,
253
+ node: string,
254
+ run: Runner
255
+ ): Promise<Check> {
216
256
  const ubuntu = resolve(root, 'ansible/ubuntu');
217
257
  const tailscale = await runAnsibleWithReceipt(
218
258
  run,
219
259
  'tailscale.yml',
220
- { cwd: ubuntu, timeoutMs: 300_000, input: '{}' },
260
+ {
261
+ cwd: ubuntu,
262
+ timeoutMs: 300_000,
263
+ input: JSON.stringify({
264
+ tailnet_hostname: node,
265
+ tailnet_login_server: loginServer(fleet.tailnet) ?? '',
266
+ }),
267
+ },
221
268
  {
222
269
  name: 'ubuntu-tailscale',
223
270
  sources: [ubuntu],
@@ -243,23 +290,30 @@ async function applyTailscale(root: string, run: Runner): Promise<Check> {
243
290
  async function convergeT3(
244
291
  root: string,
245
292
  node: string,
246
- run: Runner
293
+ run: Runner,
294
+ restartBusy: boolean
247
295
  ): Promise<Check[]> {
248
296
  const artifact = managedT3();
249
- const startedAt = await t3StartedAt(run);
250
- const checks = await convergeT3Service(
251
- artifact,
252
- run,
253
- async () => {
254
- const staged = await stageT3Policy(root, artifact.path, run, node);
297
+ const { session } = workerFeatures(fleet, node);
298
+ const startedAt = await t3StartedAt(run, session);
299
+ const checks = await convergeT3Service(artifact, run, {
300
+ prepare: async () => {
301
+ const staged = await stageT3Policy(root, artifact.path, run, {
302
+ node,
303
+ restartBusy,
304
+ });
255
305
  return staged.status === 'pass' ? [] : [staged];
256
306
  },
257
- () => installT3Desktop(root, run)
258
- );
307
+ install: () =>
308
+ session === 'headless'
309
+ ? installT3Headless(root, artifact.path, run, restartBusy)
310
+ : installT3Desktop(root, run, restartBusy),
311
+ session,
312
+ });
259
313
  if (checks.every((check) => check.status === 'pass'))
260
314
  checks.push(
261
315
  await loadedT3Policy(artifact.path, run, {
262
- restarted: startedAt !== (await t3StartedAt(run)),
316
+ restarted: startedAt !== (await t3StartedAt(run, session)),
263
317
  })
264
318
  );
265
319
  // Settings are watched by T3: providers, names and the profile apply
@@ -275,12 +329,16 @@ async function convergeT3(
275
329
  const serviceReady = checks.some(
276
330
  (check) => check.id === 't3:service' && check.status === 'pass'
277
331
  );
278
- if (serviceReady) checks.push(await runningVersionCheck(run, userInfo().uid));
332
+ if (serviceReady)
333
+ checks.push(
334
+ await runningVersionCheck(run, userInfo().uid, Date.now(), session)
335
+ );
279
336
  if (serviceReady)
280
337
  checks.push(
281
338
  ...(await convergeT3Route(run, () => publishT3Route(root, run)))
282
339
  );
283
- if (serviceReady) checks.push(...(await enableWorkerRecovery(root, run)));
340
+ if (serviceReady)
341
+ checks.push(...(await enableWorkerRecovery(root, run, session)));
284
342
  return checks;
285
343
  }
286
344
 
@@ -293,7 +351,11 @@ async function publishT3Route(root: string, run: Runner): Promise<boolean> {
293
351
  return result.ok;
294
352
  }
295
353
 
296
- async function installT3Desktop(root: string, run: Runner): Promise<boolean> {
354
+ async function installT3Desktop(
355
+ root: string,
356
+ run: Runner,
357
+ restartBusy: boolean
358
+ ): Promise<boolean> {
297
359
  const user = userInfo();
298
360
  const desktop = managedT3Desktop();
299
361
  const ubuntu = resolve(root, 'ansible/ubuntu');
@@ -311,6 +373,7 @@ async function installT3Desktop(root: string, run: Runner): Promise<boolean> {
311
373
  t3_desktop_url: desktop.url,
312
374
  t3_desktop_name: desktop.name,
313
375
  t3_desktop_sha256: desktop.sha256,
376
+ t3_restart_busy: restartBusy,
314
377
  }),
315
378
  },
316
379
  {
@@ -341,11 +404,55 @@ async function installT3Desktop(root: string, run: Runner): Promise<boolean> {
341
404
  return result.ok;
342
405
  }
343
406
 
407
+ /** A headless worker's T3: the pinned server the toolchain installed, as a
408
+ * lingering user service (t3-headless.yml). */
409
+ async function installT3Headless(
410
+ root: string,
411
+ server: string,
412
+ run: Runner,
413
+ restartBusy: boolean
414
+ ): Promise<boolean> {
415
+ const user = userInfo();
416
+ const ubuntu = resolve(root, 'ansible/ubuntu');
417
+ const unit = t3Unit('headless');
418
+ const result = await runAnsibleWithReceipt(
419
+ run,
420
+ resolve(ubuntu, 't3-headless.yml'),
421
+ {
422
+ cwd: ubuntu,
423
+ timeoutMs: 600_000,
424
+ input: JSON.stringify({
425
+ development_source: root,
426
+ development_user: user.username,
427
+ development_uid: user.uid,
428
+ development_home: user.homedir,
429
+ t3_server_path: server,
430
+ t3_restart_busy: restartBusy,
431
+ }),
432
+ },
433
+ {
434
+ name: 'ubuntu-t3-headless',
435
+ sources: [ubuntu],
436
+ // A stopped or disabled server reruns the play, which starts it.
437
+ verify: async () =>
438
+ (await run(['systemctl', '--user', 'is-enabled', '--quiet', unit]))
439
+ .ok &&
440
+ (await run(['systemctl', '--user', 'is-active', '--quiet', unit])).ok,
441
+ }
442
+ );
443
+ return result.ok;
444
+ }
445
+
344
446
  async function t3AfterBaseline(
345
447
  root: string,
346
- context: { node: string; baseline: Check[]; run: Runner }
448
+ context: {
449
+ node: string;
450
+ baseline: Check[];
451
+ run: Runner;
452
+ restartBusy: boolean;
453
+ }
347
454
  ): Promise<Check[]> {
348
- const { node, baseline, run } = context;
455
+ const { node, baseline, run, restartBusy } = context;
349
456
  if (baseline.some((check) => check.status !== 'pass'))
350
457
  return [
351
458
  {
@@ -358,12 +465,13 @@ async function t3AfterBaseline(
358
465
  },
359
466
  },
360
467
  ];
361
- return attempt('t3:service', () => convergeT3(root, node, run));
468
+ return attempt('t3:service', () => convergeT3(root, node, run, restartBusy));
362
469
  }
363
470
 
364
471
  async function enableWorkerRecovery(
365
472
  root: string,
366
- run: Runner
473
+ run: Runner,
474
+ session: WorkerFeatures['session']
367
475
  ): Promise<Check[]> {
368
476
  const user = userInfo();
369
477
  const ubuntu = resolve(root, 'ansible/ubuntu');
@@ -378,6 +486,7 @@ async function enableWorkerRecovery(
378
486
  development_user: user.username,
379
487
  development_uid: user.uid,
380
488
  development_home: user.homedir,
489
+ ...(session === 'headless' ? { t3_unit: t3Unit(session) } : {}),
381
490
  }),
382
491
  },
383
492
  {
@@ -400,7 +509,7 @@ async function enableWorkerRecovery(
400
509
  id: 'worker:service-recovery',
401
510
  status: result.ok ? 'pass' : 'fail',
402
511
  summary: result.ok
403
- ? 'T3 recovery enabled: a stopped desktop app is started and an unresponsive one restarted after three failed one-minute health checks.'
512
+ ? `T3 recovery enabled: a stopped ${session === 'headless' ? 'server' : 'desktop app'} is started and an unresponsive one restarted after three failed one-minute health checks.`
404
513
  : 'WORKER_RECOVERY_SETUP_FAILED',
405
514
  },
406
515
  ];
@@ -1,6 +1,7 @@
1
1
  import { runLockedCommand as runCommand } from './runner';
2
2
  import { isRevision } from './machine/state';
3
3
  import { convergeLinux } from './node-converge-linux';
4
+ import { T3_RESTART_MODES } from './platform/t3-service';
4
5
  import type { Check } from './types';
5
6
 
6
7
  /** Internal remote entry, run on a worker only. The manager owns the operation
@@ -8,14 +9,23 @@ import type { Check } from './types';
8
9
  * prints child output. Workers are Linux.
9
10
  */
10
11
  async function main(): Promise<Check[]> {
11
- const [node, revision] = process.argv.slice(2);
12
- if (!node || !/^[a-z][a-z0-9-]{0,62}$/.test(node) || !isRevision(revision))
12
+ const [node, revision, t3Restart = 'keep-turns'] = process.argv.slice(2);
13
+ if (
14
+ !node ||
15
+ !/^[a-z][a-z0-9-]{0,62}$/.test(node) ||
16
+ !isRevision(revision) ||
17
+ !(T3_RESTART_MODES as readonly string[]).includes(t3Restart)
18
+ )
13
19
  throw new Error();
14
20
  const root = process.cwd();
15
21
  const head = await runCommand(['git', 'rev-parse', 'HEAD']);
16
22
  if (!head.ok || head.stdout.trim() !== revision) throw new Error();
17
23
  if (process.platform === 'linux')
18
- return convergeLinux(root, { node, revision }, runCommand);
24
+ return convergeLinux(
25
+ root,
26
+ { node, revision, restartBusy: t3Restart === 'restart-busy' },
27
+ runCommand
28
+ );
19
29
  return [
20
30
  {
21
31
  id: 'platform:unsupported',
@@ -19,12 +19,18 @@ export interface Receipt {
19
19
  force?: boolean;
20
20
  /** Receipt directory; defaults to the user's fleet state. */
21
21
  directory?: string;
22
+ /** Run the playbook in check mode first, and apply only when that passes. */
23
+ checkFirst?: boolean;
24
+ /** Where the playbook's variables are written while it runs. */
25
+ scratch?: string;
22
26
  now?: number;
23
27
  }
24
28
 
25
29
  export interface ReceiptResult extends CommandResult {
26
30
  /** True when the playbook was skipped on an unchanged, verified receipt. */
27
31
  cached: boolean;
32
+ /** Which run the result is of: the check-mode run when it failed. */
33
+ stage?: 'check' | 'apply';
28
34
  }
29
35
 
30
36
  export function receiptDirectory(home = homedir()): string {
@@ -56,10 +62,19 @@ export async function runAnsibleWithReceipt(
56
62
  )
57
63
  return { ok: true, reason: 'exit', stdout: '', stderr: '', cached: true };
58
64
  await rm(path, { force: true }).catch(() => undefined);
59
- const result = await runAnsible(run, playbook, options);
65
+ if (receipt.checkFirst) {
66
+ const checked = await runAnsible(
67
+ run,
68
+ playbook,
69
+ { ...options, check: true },
70
+ receipt.scratch
71
+ );
72
+ if (!checked.ok) return { ...checked, cached: false, stage: 'check' };
73
+ }
74
+ const result = await runAnsible(run, playbook, options, receipt.scratch);
60
75
  if (result.ok && key !== null)
61
76
  await record(directory, path, { version: 1, key, appliedAt: now });
62
- return { ...result, cached: false };
77
+ return { ...result, cached: false, stage: 'apply' };
63
78
  }
64
79
 
65
80
  /** Best effort: a missing receipt only means the next run applies in full. */
@@ -30,6 +30,14 @@ export function ansibleCommand(
30
30
  ];
31
31
  }
32
32
 
33
+ /** The package's Ansible configuration (ansible/ansible.cfg): how Ansible
34
+ * reaches a worker over SSH (timeouts, retries, pipelining, connection
35
+ * reuse) beside the options src/ssh-connection.ts gives every session.
36
+ */
37
+ export function ansibleConfig(root = PACKAGE_ROOT): NodeJS.ProcessEnv {
38
+ return { ANSIBLE_CONFIG: join(root, 'ansible', 'ansible.cfg') };
39
+ }
40
+
33
41
  /** Private fleet state on disk, never /tmp: a worker's /tmp is RAM-backed with a
34
42
  * per-user quota that agent caches can exhaust, and the baseline that bounds it
35
43
  * must still be able to run then.
@@ -45,7 +53,11 @@ export function variablesDirectory(home = homedir()): string {
45
53
  export async function runAnsible(
46
54
  run: Runner,
47
55
  playbook: string,
48
- options: CommandOptions & { input: string },
56
+ options: CommandOptions & {
57
+ input: string;
58
+ /** Run in check mode: report what would change, change nothing. */
59
+ check?: boolean;
60
+ },
49
61
  scratch = variablesDirectory()
50
62
  ): Promise<CommandResult> {
51
63
  if (Buffer.byteLength(options.input) > 256 * 1024)
@@ -55,12 +67,36 @@ export async function runAnsible(
55
67
  const path = join(directory, 'variables.json');
56
68
  try {
57
69
  await writeFile(path, options.input, { mode: 0o600, flag: 'wx' });
58
- const { input: _input, ...commandOptions } = options;
70
+ const { input: _input, check, ...commandOptions } = options;
59
71
  return await run(
60
- [...ansibleCommand(), playbook, '--extra-vars', '@' + path],
61
- commandOptions
72
+ [
73
+ ...ansibleCommand(),
74
+ playbook,
75
+ ...(check ? ['--check'] : []),
76
+ '--extra-vars',
77
+ '@' + path,
78
+ ],
79
+ { ...commandOptions, env: { ...ansibleConfig(), ...commandOptions.env } }
62
80
  );
63
81
  } finally {
64
82
  await rm(directory, { recursive: true, force: true });
65
83
  }
66
84
  }
85
+
86
+ /** A playbook of the package, applied from the manager with these variables:
87
+ * one that addresses a worker over SSH itself (ansible/ubuntu/reboot.yml).
88
+ * Scenarios answer it in place of Ansible.
89
+ */
90
+ export type PackagePlaybook = (
91
+ name: string,
92
+ variables: object,
93
+ timeoutMs: number
94
+ ) => Promise<CommandResult>;
95
+
96
+ export function packagePlaybook(run: Runner): PackagePlaybook {
97
+ return (name, variables, timeoutMs) =>
98
+ runAnsible(run, join(PACKAGE_ROOT, 'ansible', name), {
99
+ input: JSON.stringify(variables),
100
+ timeoutMs,
101
+ });
102
+ }