@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
@@ -1,7 +1,11 @@
1
1
  import { spawnSync } from 'node:child_process';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  import { fleet as definition } from '../fleet-config';
3
5
  import { sshArgs, shellQuote } from '../ssh';
4
6
  import { resolveVaultConfig, usesVault } from '../vault-config';
7
+ import { workerTokenLocation } from '../worker-credentials';
8
+ import { runAnsible } from '../platform/ansible';
5
9
  import type { Check, FleetNode, Runner } from '../types';
6
10
 
7
11
  const TOOL_PATH =
@@ -15,7 +19,11 @@ const terminal: TerminalRunner = (args) =>
15
19
 
16
20
  type VaultState = 'authenticated' | 'unauthenticated' | 'unavailable';
17
21
 
18
- /** Vault field holding the issued token for one worker node. */
22
+ /** The field of the fleet's shared document that held a worker's token
23
+ * before each worker had a document of its own (src/worker-credentials.ts).
24
+ * Read only when the worker's own document holds none, and reported: every
25
+ * worker that can read the shared document can read that field.
26
+ */
19
27
  export function workerTokenField(node: string): string {
20
28
  return `FLEET_WORKER_TOKEN_${node.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
21
29
  }
@@ -49,7 +57,13 @@ export async function authorizeManager(
49
57
  */
50
58
  export async function authorizeVault(
51
59
  node: FleetNode,
52
- options: { interactive: boolean },
60
+ options: {
61
+ interactive: boolean;
62
+ /** Opt-in: the fleet's shared Vault user, typed on the worker's own
63
+ * terminal, when no token of its own was issued.
64
+ */
65
+ passwordLogin?: boolean;
66
+ },
53
67
  run: Runner,
54
68
  attach: TerminalRunner = terminal
55
69
  ): Promise<Check[]> {
@@ -59,8 +73,10 @@ export async function authorizeVault(
59
73
  if (before === 'authenticated') return [pass(node)];
60
74
  // Fresh node: the locked toolchain (including vault) is not installed yet.
61
75
  if (before === 'unavailable') return [];
62
- if (await deliverWorkerToken(node, run)) return [pass(node)];
63
- if (!options.interactive) return [required(node)];
76
+ const delivered = await deliverWorkerToken(node, run);
77
+ if (delivered === 'own') return [pass(node)];
78
+ if (delivered === 'shared') return [pass(node), shared(node)];
79
+ if (!options.interactive || !options.passwordLogin) return [required(node)];
64
80
  process.stderr.write(
65
81
  `[${node.id}] No issued worker token found. Enter the Vault password to log this machine in.\n`
66
82
  );
@@ -72,30 +88,54 @@ export async function authorizeVault(
72
88
  ];
73
89
  }
74
90
 
91
+ /** Delivers the worker's token, from its own document or else from the
92
+ * field it used to have in the shared one; says which, or that none was.
93
+ */
75
94
  async function deliverWorkerToken(
76
95
  node: FleetNode,
77
96
  run: Runner
78
- ): Promise<boolean> {
79
- const { addr, mount, project, config } = resolveVaultConfig();
80
- const secret = await run(
81
- [
82
- 'vault',
83
- 'kv',
84
- 'get',
85
- `-address=${addr}`,
86
- `-mount=${mount}`,
87
- `-field=${workerTokenField(node.id)}`,
88
- `${project}/${config}`,
89
- ],
90
- { timeoutMs: 20_000 }
91
- );
92
- const token = secret.ok ? secret.stdout.trim() : '';
93
- if (!/^[\x21-\x7e]{16,4096}$/.test(token)) return false;
94
- const delivered = await run(
95
- remote(node, ['vault', 'login', `-address=${addr}`, '-no-print', '-']),
96
- { input: token + '\n', timeoutMs: 30_000 }
97
- );
98
- return delivered.ok && (await vaultState(node, run)) === 'authenticated';
97
+ ): Promise<'own' | 'shared' | null> {
98
+ const vault = resolveVaultConfig();
99
+ const own = workerTokenLocation(node.id, vault);
100
+ const sources = [
101
+ { from: 'own' as const, ...own },
102
+ {
103
+ from: 'shared' as const,
104
+ mount: vault.mount,
105
+ path: `${vault.project}/${vault.config}`,
106
+ field: workerTokenField(node.id),
107
+ },
108
+ ];
109
+ for (const source of sources) {
110
+ const secret = await run(
111
+ [
112
+ 'vault',
113
+ 'kv',
114
+ 'get',
115
+ `-address=${vault.addr}`,
116
+ `-mount=${source.mount}`,
117
+ `-field=${source.field}`,
118
+ source.path,
119
+ ],
120
+ { timeoutMs: 20_000 }
121
+ );
122
+ const token = secret.ok ? secret.stdout.trim() : '';
123
+ if (!/^[\x21-\x7e]{16,4096}$/.test(token)) continue;
124
+ const delivered = await run(
125
+ remote(node, [
126
+ 'vault',
127
+ 'login',
128
+ `-address=${vault.addr}`,
129
+ '-no-print',
130
+ '-',
131
+ ]),
132
+ { input: token + '\n', timeoutMs: 30_000 }
133
+ );
134
+ return delivered.ok && (await vaultState(node, run)) === 'authenticated'
135
+ ? source.from
136
+ : null;
137
+ }
138
+ return null;
99
139
  }
100
140
 
101
141
  async function vaultState(node: FleetNode, run: Runner): Promise<VaultState> {
@@ -153,6 +193,18 @@ function pass(node: FleetNode): Check {
153
193
  };
154
194
  }
155
195
 
196
+ function shared(node: FleetNode): Check {
197
+ const own = workerTokenLocation(node.id);
198
+ return {
199
+ id: 'auth:vault-isolation',
200
+ status: 'warn',
201
+ summary: 'VAULT_WORKER_TOKEN_SHARED',
202
+ remedy: {
203
+ action: `${node.id}'s token was read from the fleet's shared document, which every worker can read. Issue it a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes (kept at ${own.mount}/${own.path}), then remove ${workerTokenField(node.id)} from the shared document.`,
204
+ },
205
+ };
206
+ }
207
+
156
208
  function required(node: FleetNode): Check {
157
209
  const { addr } = resolveVaultConfig();
158
210
  return {
@@ -160,7 +212,81 @@ function required(node: FleetNode): Check {
160
212
  status: 'fail',
161
213
  summary: 'VAULT_LOGIN_REQUIRED',
162
214
  remedy: {
163
- action: `Run devfleet fleet-sync in a terminal, or store this node's token in the shared Vault document as ${workerTokenField(node.id)}. Manual fallback on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
215
+ action: `Issue ${node.id} a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes. Opt-in fallback: devfleet fleet-sync --vault-password-login in a terminal, or on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
164
216
  },
165
217
  };
166
218
  }
219
+
220
+ /**
221
+ * The manager's own Vault session renews itself every ten minutes, like each
222
+ * worker's: `ansible/macos/vault-renew.yml`, applied on every applying sync.
223
+ * Measured as the session answering and the job being loaded; a lapsed
224
+ * session needs one login by a person, and until then agents on the manager
225
+ * use the proxy client key they last read (src/agents/secrets.ts).
226
+ */
227
+ export async function holdManagerVault(
228
+ run: Runner,
229
+ options: {
230
+ apply: boolean;
231
+ root: string;
232
+ home?: string;
233
+ uid?: number;
234
+ scratch?: string;
235
+ }
236
+ ): Promise<Check[]> {
237
+ if (!usesVault()) return [];
238
+ const { addr } = resolveVaultConfig();
239
+ const home = options.home ?? homedir();
240
+ if (options.apply)
241
+ await runAnsible(
242
+ run,
243
+ join(options.root, 'ansible/macos/vault-renew.yml'),
244
+ {
245
+ input: JSON.stringify({
246
+ vault_address: addr,
247
+ mise: join(home, '.local/bin/mise'),
248
+ }),
249
+ timeoutMs: 120_000,
250
+ },
251
+ options.scratch
252
+ );
253
+ const loaded = (
254
+ await run(
255
+ [
256
+ 'launchctl',
257
+ 'print',
258
+ `gui/${options.uid ?? process.getuid?.() ?? 0}/${MANAGER_VAULT_RENEWAL}`,
259
+ ],
260
+ { timeoutMs: 10_000 }
261
+ )
262
+ ).ok;
263
+ const session = (await run(lookup(), { timeoutMs: 20_000 })).ok;
264
+ return [vaultRenewalCheck(session, loaded, addr)];
265
+ }
266
+
267
+ function vaultRenewalCheck(
268
+ session: boolean,
269
+ loaded: boolean,
270
+ addr: string
271
+ ): Check {
272
+ if (session && loaded)
273
+ return {
274
+ id: 'manager:vault-renewal',
275
+ status: 'pass',
276
+ summary:
277
+ 'The manager is logged in to Vault and renews its session every ten minutes.',
278
+ };
279
+ return {
280
+ id: 'manager:vault-renewal',
281
+ status: 'fail',
282
+ summary: session ? 'VAULT_RENEWAL_MISSING' : 'VAULT_LOGIN_REQUIRED',
283
+ remedy: {
284
+ action: session
285
+ ? `The manager's Vault session is not renewed and lapses at its TTL. Run devfleet fleet-sync on the manager; it loads ${MANAGER_VAULT_RENEWAL}.`
286
+ : `Log the manager in once, in your own terminal: vault login -address=${addr} -method=userpass username=${definition.worker?.vaultUser ?? 'YOUR_USERNAME'}. Then run devfleet fleet-sync. Until then the manager's agents use the proxy client key they last read, and new worker tokens cannot be issued.`,
287
+ },
288
+ };
289
+ }
290
+
291
+ /** The launchd label of the manager's Vault renewal job (vault-renew.yml). */
292
+ export const MANAGER_VAULT_RENEWAL = 'dev.devfleet.vault-renew';
@@ -0,0 +1,214 @@
1
+ import type { FleetDefinition } from '../definition';
2
+ import {
3
+ controlRemedy,
4
+ TailnetControlError,
5
+ type TailnetControl,
6
+ } from '../tailnet-control';
7
+ import type { TailnetComputer } from '../tailscale';
8
+ import type { PackagePlaybook } from '../platform/ansible';
9
+ import type { Check, FleetNode, NodeReport } from '../types';
10
+ import { createWorkerAccount } from '../worker-account';
11
+ import {
12
+ rebootNeeded,
13
+ rebootWorker,
14
+ updatesPending,
15
+ type RebootReason,
16
+ } from '../worker-reboot';
17
+
18
+ /** What one worker's deployment needs around its converge: the converge
19
+ * itself (bootstrap, verification, monitoring), the tailnet control plane
20
+ * when the fleet declares one, the tailnet as the manager sees it, and what
21
+ * the run was asked to do.
22
+ */
23
+ export interface WorkerDeployment {
24
+ definition: FleetDefinition;
25
+ /** Applies a playbook of the package from the manager. */
26
+ playbook: PackagePlaybook;
27
+ /** One converge of the worker; `tailnetKey` is handed to its bootstrap. */
28
+ converge: (
29
+ node: FleetNode,
30
+ revision: string,
31
+ tailnetKey?: string
32
+ ) => Promise<NodeReport>;
33
+ /** Null when the fleet declares no tailnet control plane. */
34
+ control: () => Promise<TailnetControl | null>;
35
+ /** The devices of the tailnet, as the manager's Tailscale lists them. */
36
+ computers: () => Promise<TailnetComputer[]>;
37
+ /** The workers that converged in an earlier run. */
38
+ readyBefore: string[];
39
+ maintenanceReboot?: boolean;
40
+ force?: boolean;
41
+ /** Whether the bootstrap of this revision reads a tailnet key. */
42
+ acceptsTailnetKey: boolean;
43
+ }
44
+
45
+ /** Deploys one worker and, around its converge: hands a worker that is not
46
+ * on the tailnet a single-use key, keeps an enrolled worker's node key from
47
+ * expiring, creates the declared account on an image that lets only root
48
+ * in, and reboots the worker once when its first converge needs it (or for
49
+ * maintenance, when asked), converging it again to verify it.
50
+ */
51
+ export async function deployWorker(
52
+ node: FleetNode,
53
+ revision: string,
54
+ deployment: WorkerDeployment
55
+ ): Promise<NodeReport> {
56
+ const before: Check[] = [];
57
+ const key = await enrolmentKey(node, deployment, before);
58
+ before.push(...(await keyExpiry(node, deployment)));
59
+ let target = node;
60
+ let result = await deployment.converge(target, revision, key);
61
+ const account = await rootOnly(node, result, deployment);
62
+ if (account) {
63
+ before.push(account.check);
64
+ if (account.node) {
65
+ target = account.node;
66
+ result = await deployment.converge(target, revision, key);
67
+ }
68
+ }
69
+ const reason = rebootReason(node.id, result.checks, deployment);
70
+ if (reason) {
71
+ const reboot = await rebootWorker(
72
+ target,
73
+ {
74
+ revision,
75
+ reason,
76
+ ...(reason === 'maintenance' && deployment.force
77
+ ? { force: true }
78
+ : {}),
79
+ },
80
+ deployment.playbook
81
+ );
82
+ if (reboot.status === 'pass')
83
+ result = await deployment.converge(target, revision);
84
+ result.checks.unshift(reboot);
85
+ }
86
+ result.checks.unshift(...before);
87
+ return result;
88
+ }
89
+
90
+ /** Why this worker is rebooted now, if it is. A worker that converged
91
+ * before is never rebooted for its first converge; only an explicit
92
+ * maintenance run reboots it.
93
+ */
94
+ function rebootReason(
95
+ id: string,
96
+ checks: Check[],
97
+ deployment: WorkerDeployment
98
+ ): RebootReason | null {
99
+ if (deployment.maintenanceReboot && updatesPending(checks))
100
+ return 'maintenance';
101
+ if (!deployment.readyBefore.includes(id) && rebootNeeded(checks))
102
+ return 'first-converge';
103
+ return null;
104
+ }
105
+
106
+ /** A single-use, pre-authorised, tagged key for a worker the roster has no
107
+ * tailnet identity for yet. The key is never reported.
108
+ */
109
+ async function enrolmentKey(
110
+ node: FleetNode,
111
+ deployment: WorkerDeployment,
112
+ checks: Check[]
113
+ ): Promise<string | undefined> {
114
+ if (node.tailscaleId || !deployment.acceptsTailnetKey) return undefined;
115
+ try {
116
+ const control = await deployment.control();
117
+ if (!control) return undefined;
118
+ const minted = await control.createAuthKey({ worker: node.id });
119
+ checks.push({
120
+ id: 'tailscale:enrolment-key',
121
+ status: 'pass',
122
+ summary: `A single-use, pre-authorised key tagged ${deployment.definition.tailnet!.tag}, expiring within minutes, was handed to the worker on the bootstrap's stdin.`,
123
+ });
124
+ return minted.key;
125
+ } catch (error) {
126
+ if (!(error instanceof TailnetControlError)) throw error;
127
+ checks.push(controlFailure('tailscale:enrolment-key', error));
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ /** An enrolled worker's node key never expires: the control plane disables
133
+ * expiry for a worker whose key has one (tagged workers have none on the
134
+ * hosted Tailscale). The tailscale:key-expiry check still measures it.
135
+ */
136
+ async function keyExpiry(
137
+ node: FleetNode,
138
+ deployment: WorkerDeployment
139
+ ): Promise<Check[]> {
140
+ if (!node.tailscaleId) return [];
141
+ try {
142
+ const control = await deployment.control();
143
+ if (!control) return [];
144
+ const peer = (await deployment.computers()).find(
145
+ (computer) => computer.id === node.tailscaleId
146
+ );
147
+ if (peer?.keyExpiry === undefined) return [];
148
+ await control.disableKeyExpiry(node.tailscaleId);
149
+ return [
150
+ {
151
+ id: 'tailscale:key-expiry-policy',
152
+ status: 'pass',
153
+ summary:
154
+ 'Key expiry of this worker was disabled through the tailnet control plane; tailscale:key-expiry measures it again on the next run.',
155
+ },
156
+ ];
157
+ } catch (error) {
158
+ if (!(error instanceof TailnetControlError)) throw error;
159
+ return [
160
+ {
161
+ ...controlFailure('tailscale:key-expiry-policy', error),
162
+ status: 'warn',
163
+ },
164
+ ];
165
+ }
166
+ }
167
+
168
+ function controlFailure(id: string, error: TailnetControlError): Check {
169
+ return {
170
+ id,
171
+ status: 'fail',
172
+ summary: error.code,
173
+ remedy: { action: controlRemedy(error.code) },
174
+ };
175
+ }
176
+
177
+ /** The bootstrap refused to run as root: the alias logs in as root. With a
178
+ * declared account, it is created over that login and the run continues as
179
+ * it (`user@alias`); the next runs need the alias to name it, since the SSH
180
+ * policy turns root login off.
181
+ */
182
+ async function rootOnly(
183
+ node: FleetNode,
184
+ result: NodeReport,
185
+ deployment: WorkerDeployment
186
+ ): Promise<{ check: Check; node?: FleetNode } | null> {
187
+ if (
188
+ !result.checks.some(
189
+ (check) => check.summary === 'ROOT_DEVELOPMENT_USER_REJECTED'
190
+ )
191
+ )
192
+ return null;
193
+ const user = deployment.definition.workers.find(
194
+ (worker) => worker.id === node.id
195
+ )?.network?.user;
196
+ const check = await createWorkerAccount(
197
+ { id: node.id, ssh: node.ssh, login: '' },
198
+ user,
199
+ deployment.playbook
200
+ );
201
+ if (check.status !== 'pass') return { check };
202
+ return {
203
+ check: {
204
+ ...check,
205
+ status: 'warn',
206
+ summary: `${check.summary} This run continued as ${user}@${node.ssh}; set User ${user} for the SSH alias ${node.ssh}, since root login is turned off.`,
207
+ },
208
+ node: {
209
+ ...node,
210
+ ssh: `${user}@${node.ssh}`,
211
+ workspace: `/home/${user}/.local/share/personal/fleet`,
212
+ },
213
+ };
214
+ }