@crvouga/dev-fleet 0.8.0 → 0.9.1

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 (205) hide show
  1. package/README.md +65 -9
  2. package/agents/audit.md +18 -18
  3. package/ansible/agents/agents.yml +134 -0
  4. package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
  5. package/ansible/agents/templates/claude.settings.json.j2 +3 -0
  6. package/ansible/agents/templates/codex.config.toml.j2 +11 -0
  7. package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
  8. package/ansible/agents/templates/grok.config.toml.j2 +19 -0
  9. package/ansible/agents/templates/opencode.json.j2 +24 -0
  10. package/ansible/agents/templates/receipt.json.j2 +10 -0
  11. package/ansible/ansible.cfg +14 -0
  12. package/ansible/github/git.yml +82 -0
  13. package/ansible/macos/manager-privilege.yml +55 -9
  14. package/ansible/macos/manager.yml +154 -7
  15. package/ansible/macos/policy.json +13 -0
  16. package/ansible/macos/t3-policy.yml +5 -3
  17. package/ansible/macos/vault-renew.yml +85 -0
  18. package/ansible/monitoring/agent-darwin.yml +3 -0
  19. package/ansible/monitoring/agent.yml +2 -0
  20. package/ansible/monitoring/hub-darwin.yml +3 -0
  21. package/ansible/monitoring/hub-route-darwin.yml +1 -0
  22. package/ansible/net/remote-probe.sh +4 -1
  23. package/ansible/platforms.json +2 -2
  24. package/ansible/pyproject.toml +5 -0
  25. package/ansible/ubuntu/baseline.yml +39 -13
  26. package/ansible/ubuntu/disk-gc.py +6 -2
  27. package/ansible/ubuntu/firewall.nft.j2 +50 -0
  28. package/ansible/ubuntu/policy.json +125 -6
  29. package/ansible/ubuntu/reboot.yml +89 -0
  30. package/ansible/ubuntu/recovery.yml +13 -3
  31. package/ansible/ubuntu/t3/policy.json +3 -0
  32. package/ansible/ubuntu/t3-desktop.yml +23 -2
  33. package/ansible/ubuntu/t3-headless.yml +128 -0
  34. package/ansible/ubuntu/t3-health.sh +24 -10
  35. package/ansible/ubuntu/t3-idle.py +37 -2
  36. package/ansible/ubuntu/t3-policy.yml +4 -1
  37. package/ansible/ubuntu/t3-route.yml +1 -0
  38. package/ansible/ubuntu/tailscale.yml +54 -8
  39. package/ansible/ubuntu/tasks/browser.yml +3 -0
  40. package/ansible/ubuntu/tasks/containers-off.yml +43 -0
  41. package/ansible/ubuntu/tasks/docker.yml +53 -6
  42. package/ansible/ubuntu/tasks/dpkg.yml +4 -0
  43. package/ansible/ubuntu/tasks/features.yml +22 -0
  44. package/ansible/ubuntu/tasks/firewall.yml +172 -0
  45. package/ansible/ubuntu/tasks/install.yml +2 -1
  46. package/ansible/ubuntu/tasks/packages.yml +4 -2
  47. package/ansible/ubuntu/tasks/release.yml +6 -0
  48. package/ansible/ubuntu/tasks/resources.yml +6 -4
  49. package/ansible/ubuntu/tasks/services.yml +2 -2
  50. package/ansible/ubuntu/tasks/sizing.yml +49 -0
  51. package/ansible/ubuntu/tasks/snapshot.yml +14 -3
  52. package/ansible/ubuntu/tasks/ssh.yml +61 -4
  53. package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
  54. package/ansible/ubuntu/tasks/user.yml +1 -0
  55. package/ansible/ubuntu/worker-account.yml +117 -0
  56. package/ansible/uv.lock +265 -0
  57. package/bin/devfleet.mjs +42 -4
  58. package/bootstrap/fleet-remote-bootstrap.sh +91 -25
  59. package/bootstrap/global-config.ts +29 -3
  60. package/bootstrap/package-smoke.ts +127 -0
  61. package/bootstrap/pins.ts +189 -0
  62. package/bootstrap/release-notes.ts +88 -0
  63. package/bootstrap/release-version.ts +41 -7
  64. package/bootstrap/worker-features.ts +114 -0
  65. package/bootstrap/worker-seed.yaml +7 -3
  66. package/bootstrap/worker-tools.ts +39 -9
  67. package/cliproxy/README.md +1 -1
  68. package/cliproxy/docs/health-workflow.example.yml +20 -143
  69. package/docs/authentication.md +19 -11
  70. package/docs/consumer-setup.md +42 -27
  71. package/docs/convergence.md +67 -0
  72. package/docs/declared.md +61 -34
  73. package/docs/machine-setup.md +73 -7
  74. package/docs/manager-setup.md +10 -3
  75. package/docs/operations.md +64 -9
  76. package/docs/releasing.md +49 -1
  77. package/docs/runbooks.md +98 -7
  78. package/docs/state.md +23 -11
  79. package/docs/support-matrix.md +40 -4
  80. package/docs/topology.md +67 -6
  81. package/docs/ubuntu-sync.md +78 -28
  82. package/docs/worker-contract.md +122 -19
  83. package/docs/worker-requirements.md +107 -0
  84. package/examples/fleet.json +1 -0
  85. package/examples/workers-from-infrastructure/main.tf +132 -0
  86. package/examples/workers-from-infrastructure/worker-records.json +14 -0
  87. package/llms.txt +3 -1
  88. package/mise.lock +29 -0
  89. package/mise.toml +5 -3
  90. package/package.json +2 -2
  91. package/schema/check-ids.json +1967 -170
  92. package/schema/check-ids.schema.json +34 -0
  93. package/schema/fleet.schema.json +327 -8
  94. package/schema/output/check.schema.json +1 -1
  95. package/schema/output/fleet-state-definition.schema.json +14 -0
  96. package/schema/output/fleet-sync-schedule.schema.json +26 -0
  97. package/schema/output/fleet-sync.schema.json +18 -0
  98. package/schema/output/report.schema.json +3 -2
  99. package/schema/worker-record.schema.json +76 -0
  100. package/src/agents/converge.ts +140 -24
  101. package/src/agents/files.ts +2 -32
  102. package/src/agents/native-verify.ts +34 -3
  103. package/src/agents/secrets.ts +39 -4
  104. package/src/check-owners.ts +61 -2
  105. package/src/check-registry.ts +630 -0
  106. package/src/cli/agent-guide.ts +3 -3
  107. package/src/cli/cli.ts +4 -0
  108. package/src/cli/fleet-manifest.ts +9 -3
  109. package/src/cli/fleet-node-lifecycle.ts +313 -64
  110. package/src/cli/fleet-node-park.ts +238 -0
  111. package/src/cli/fleet-node-seed.ts +51 -35
  112. package/src/cli/fleet-state.ts +2 -1
  113. package/src/cli/fleet-sync-checkout.ts +7 -0
  114. package/src/cli/fleet-sync-enrollment.ts +193 -32
  115. package/src/cli/fleet-sync-forward.ts +4 -0
  116. package/src/cli/fleet-sync-manager.ts +103 -33
  117. package/src/cli/fleet-sync-output.ts +31 -11
  118. package/src/cli/fleet-sync-plan.ts +65 -9
  119. package/src/cli/fleet-sync-records.ts +113 -0
  120. package/src/cli/fleet-sync-remote.ts +151 -9
  121. package/src/cli/fleet-sync-schedule.ts +14 -4
  122. package/src/cli/fleet-sync-state.ts +28 -16
  123. package/src/cli/fleet-sync-vault.ts +77 -0
  124. package/src/cli/fleet-sync-worker.ts +214 -0
  125. package/src/cli/fleet-sync.ts +150 -19
  126. package/src/cli/fleet-validate.ts +59 -21
  127. package/src/cli/fleet-watch.ts +7 -1
  128. package/src/cli/fleet.ts +87 -5
  129. package/src/cli/managed-health.ts +25 -1
  130. package/src/cli/register-fleet.ts +115 -4
  131. package/src/cli/registry.ts +14 -0
  132. package/src/cli/tailnet.ts +43 -10
  133. package/src/cli/ui-data.ts +3 -1
  134. package/src/cli/worker-usage-report.ts +63 -0
  135. package/src/clock.ts +42 -0
  136. package/src/definition.ts +332 -9
  137. package/src/diagnostics.ts +5 -2
  138. package/src/exposure.ts +102 -0
  139. package/src/fleet-config.ts +3 -1
  140. package/src/git-identity.ts +76 -3
  141. package/src/ide/ide.ts +5 -0
  142. package/src/ide/t3.ts +12 -2
  143. package/src/input-hashes.ts +6 -0
  144. package/src/machine/machine-class.ts +59 -0
  145. package/src/machine/platform.ts +90 -11
  146. package/src/machine/state.ts +37 -15
  147. package/src/machine/support-matrix.ts +11 -9
  148. package/src/machine-health.ts +28 -5
  149. package/src/manager-health.ts +148 -2
  150. package/src/manager-sync.ts +65 -4
  151. package/src/monitoring/beszel.ts +8 -8
  152. package/src/monitoring/enroll.ts +8 -1
  153. package/src/monitoring/hub-access.ts +10 -4
  154. package/src/monitoring/systems.ts +138 -0
  155. package/src/network-definition.ts +13 -4
  156. package/src/node-converge-linux.ts +136 -27
  157. package/src/node-converge.ts +13 -3
  158. package/src/platform/ansible-receipt.ts +17 -2
  159. package/src/platform/ansible.ts +40 -4
  160. package/src/platform/manager-host.ts +25 -8
  161. package/src/platform/resource-verify.ts +18 -7
  162. package/src/platform/t3-policy.ts +24 -10
  163. package/src/platform/t3-service.ts +109 -34
  164. package/src/platform/ubuntu-release.ts +24 -0
  165. package/src/platform/ubuntu-verify.ts +31 -3
  166. package/src/platform/ubuntu-worker.ts +58 -0
  167. package/src/platform/worker-platform.ts +72 -0
  168. package/src/protocol.ts +1 -1
  169. package/src/public-reachability.ts +186 -0
  170. package/src/remote-bootstrap.ts +50 -12
  171. package/src/remote-operation.ts +18 -4
  172. package/src/repair-plan.ts +23 -12
  173. package/src/report.ts +2 -1
  174. package/src/schedule-check.ts +21 -2
  175. package/src/secrets.ts +4 -0
  176. package/src/service-auth.ts +55 -48
  177. package/src/ssh-connection.ts +141 -0
  178. package/src/ssh.ts +63 -24
  179. package/src/state/store.ts +4 -2
  180. package/src/state/sync.ts +35 -6
  181. package/src/state/workers.ts +140 -0
  182. package/src/t3/pairing.ts +59 -0
  183. package/src/t3/peer-link.ts +333 -0
  184. package/src/t3/version.ts +1 -1
  185. package/src/tailnet-cleanup.ts +185 -0
  186. package/src/tailnet-control.ts +346 -0
  187. package/src/tailscale.ts +1 -1
  188. package/src/toolchain-checks.ts +11 -1
  189. package/src/types.ts +4 -2
  190. package/src/worker-account.ts +63 -0
  191. package/src/worker-activity.ts +303 -0
  192. package/src/worker-capability-checks.ts +55 -16
  193. package/src/worker-capacity.ts +31 -0
  194. package/src/worker-exposure.ts +388 -0
  195. package/src/worker-features.ts +201 -0
  196. package/src/worker-host-checks.ts +71 -20
  197. package/src/worker-manifest.ts +27 -4
  198. package/src/worker-parking.ts +70 -0
  199. package/src/worker-policy.ts +17 -1
  200. package/src/worker-readiness.ts +15 -3
  201. package/src/worker-reboot.ts +129 -0
  202. package/src/worker-records.ts +305 -0
  203. package/src/worker-seed.ts +16 -11
  204. package/src/worker-sizing.ts +76 -0
  205. package/src/cli/schedule-logs.ts +0 -36
@@ -1,5 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { FleetUsageError } from '../errors';
3
+ import { fleet } from '../fleet-config';
4
+ import { declaredFor, workerFeatures } from '../worker-features';
3
5
  import { runCommand } from '../runner';
4
6
  import type { Runner } from '../types';
5
7
  import {
@@ -62,7 +64,11 @@ export async function fleetManifest(
62
64
  const fresh = options.reference
63
65
  ? await reference(options.reference)
64
66
  : undefined;
65
- const policy = await ubuntuPolicy(services.root);
67
+ // A worker is measured against what its own features install.
68
+ const policy = declaredFor(
69
+ await ubuntuPolicy(services.root),
70
+ workerFeatures(fleet, node.id)
71
+ );
66
72
  const measured = await measureManifest(node, services.run, policy);
67
73
  if ('check' in measured)
68
74
  return {
@@ -75,9 +81,9 @@ export async function fleetManifest(
75
81
  checks: [measured.check],
76
82
  },
77
83
  };
78
- const { manifest } = measured;
84
+ const { manifest, release } = measured;
79
85
  const differences = [
80
- ...declaredDifferences(manifest, policy),
86
+ ...declaredDifferences(manifest, policy, release),
81
87
  ...(fresh ? diffManifests(fresh, manifest) : []),
82
88
  ];
83
89
  const check = manifestCheck(differences);
@@ -1,9 +1,28 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { parseConfig } from '../config';
4
- import type { FleetDefinition, MachineDefinition } from '../definition';
4
+ import {
5
+ HOST_KEY,
6
+ type FleetDefinition,
7
+ type MachineDefinition,
8
+ } from '../definition';
9
+ import { hubAccess } from '../monitoring/hub-access';
10
+ import type { HubRequest } from '../monitoring/enroll';
11
+ import { moveSystem, removeSystem } from '../monitoring/systems';
12
+ import {
13
+ declaredConnection,
14
+ hostKeyAlias,
15
+ renderKnownHosts,
16
+ } from '../ssh-connection';
17
+ import { changeWorkers, readPins } from '../state/workers';
18
+ import { hubUrl } from '../topology';
5
19
  import { FleetUsageError } from '../errors';
6
- import { fleet } from '../fleet-config';
20
+ import { fleet, secret } from '../fleet-config';
21
+ import {
22
+ deviceLeftToPerson,
23
+ tailnetCleanup,
24
+ type TailnetCleanup,
25
+ } from '../tailnet-cleanup';
7
26
  import type { Enrollment } from '../machine/state';
8
27
  import { PACKAGE_ROOT } from '../package-info';
9
28
  import { runAnsible } from '../platform/ansible';
@@ -13,8 +32,10 @@ import { loadEnrollment, storeEnrollment } from '../state/enrollment';
13
32
  import { STATE_ROSTER, writeRosterText } from '../state/roster';
14
33
  import { mutateState, stateContext, type StateContext } from '../state/sync';
15
34
  import { discoverTailnet, type TailnetComputer } from '../tailscale';
16
- import type { Check, FleetConfig, Runner } from '../types';
35
+ import type { Check, FleetConfig, FleetNode, Runner } from '../types';
17
36
  import { usesVault } from '../vault-config';
37
+ import { managerT3, type ManagerT3 } from '../t3/peer-link';
38
+ import { pairAndRecord } from '../t3/pairing';
18
39
  import {
19
40
  issueWorkerToken,
20
41
  revokeWorkerToken,
@@ -45,8 +66,22 @@ export interface NodeLifecycleServices {
45
66
  /** Applies a playbook of the package with these variables. */
46
67
  playbook: (name: string, variables: object) => Promise<boolean>;
47
68
  now: () => Date;
69
+ /** The monitoring hub's API, signed in as the fleet account. Absent, the
70
+ * hub is left as it is and the step says so.
71
+ */
72
+ monitoring?: () => Promise<HubRequest>;
73
+ /** Removes a worker's old device from the tailnet, so a new machine gets
74
+ * the declared name: the control plane fleet.json declares, asked with the
75
+ * `tailnet-admin` secret. Absent, no device is removed and the step is
76
+ * reported as skipped, with what a person does instead.
77
+ */
78
+ tailnet?: TailnetCleanup;
79
+ /** The manager's own T3, which pairs with a replacement's environment. */
80
+ t3: () => ManagerT3;
48
81
  }
49
82
 
83
+ export type { TailnetCleanup };
84
+
50
85
  export function lifecycleServices(roster: string): NodeLifecycleServices {
51
86
  return {
52
87
  run: runCommand,
@@ -63,6 +98,12 @@ export function lifecycleServices(roster: string): NodeLifecycleServices {
63
98
  })
64
99
  ).ok,
65
100
  now: () => new Date(),
101
+ monitoring: async () => (await hubAccess(hubUrl(fleet))).request,
102
+ // Only a fleet that declares a tailnet control plane removes devices.
103
+ ...(fleet.tailnet
104
+ ? { tailnet: tailnetCleanup(fleet.tailnet, secret) }
105
+ : {}),
106
+ t3: () => managerT3(runCommand),
66
107
  };
67
108
  }
68
109
 
@@ -85,6 +126,8 @@ export async function keyName(
85
126
  worker: MachineDefinition,
86
127
  run: Runner
87
128
  ): Promise<string> {
129
+ // A worker reached from declared data is pinned under its fleet ID.
130
+ if (!worker.ssh) return hostKeyAlias(worker.id);
88
131
  const resolved = await run(['ssh', '-G', worker.ssh], { timeoutMs: 10_000 });
89
132
  const option = (name: string) =>
90
133
  resolved.ok
@@ -137,9 +180,11 @@ const planned = (id: string, summary: string): Check => ({
137
180
  const preview = (options: NodeLifecycleOptions): boolean =>
138
181
  !(options.apply && options.yes);
139
182
 
140
- /** Takes a worker out of the fleet for good: revokes its Vault token, forgets
141
- * its pinned host key, removes its roster entry and leaves a tombstone in the
142
- * fleet state. Preview until --apply --yes. Running it again changes nothing.
183
+ /** Takes a worker out of the fleet for good: revokes its Vault token,
184
+ * forgets its pinned host key, deletes its monitoring system, removes the
185
+ * manager's T3 link to it and its tailnet device (when the tailnet control is
186
+ * wired), removes its roster entry and leaves a tombstone in the fleet state.
187
+ * Preview until --apply --yes. Running it again changes nothing.
143
188
  */
144
189
  export async function fleetNodeRetire(
145
190
  options: NodeLifecycleOptions,
@@ -147,47 +192,42 @@ export async function fleetNodeRetire(
147
192
  ): Promise<CommandOutcome> {
148
193
  const worker = declared(services.definition(), options.id);
149
194
  const roster = await readRoster(services.roster);
150
- const enrolled = roster?.nodes.some((node) => node.id === worker.id) ?? false;
151
- if (enrolled && roster!.nodes.length === 1)
195
+ const entry = roster?.nodes.find((node) => node.id === worker.id);
196
+ if (entry && roster!.nodes.length === 1)
152
197
  throw new FleetUsageError(
153
198
  'The roster must keep at least one worker. Add the replacement with fleet-node-add first.'
154
199
  );
200
+ return retireWorker(worker, entry, services, !preview(options));
201
+ }
202
+
203
+ /** Retires one worker; with `apply` false it only plans. Shared with the
204
+ * retirement of a worker whose record disappeared (fleet-sync).
205
+ */
206
+ export async function retireWorker(
207
+ worker: MachineDefinition,
208
+ entry: FleetNode | undefined,
209
+ services: NodeLifecycleServices,
210
+ apply: boolean
211
+ ): Promise<CommandOutcome> {
155
212
  const host = await keyName(worker, services.run);
156
- if (preview(options))
213
+ if (!apply)
157
214
  return outcome(
158
215
  'fleet-node-retire',
159
216
  worker.id,
160
- [
161
- ...(services.vault()
162
- ? [planned('auth:worker-token', `revoke ${worker.id}'s Vault token`)]
163
- : []),
164
- planned('ssh:host-key', `forget the host key of ${host}`),
165
- planned(
166
- 'enrollment:connection',
167
- enrolled
168
- ? `remove ${worker.id} from the roster`
169
- : `${worker.id} is not in the roster`
170
- ),
171
- planned('state:retired', `record that ${worker.id} is retired`),
172
- ],
217
+ planRetirement(worker, entry, host, services.vault()),
173
218
  false
174
219
  );
175
220
  const checks: Check[] = [];
176
221
  if (services.vault())
177
222
  checks.push(await revokeWorkerToken(worker.id, services.run));
223
+ checks.push(await pinHostKey(worker, host, null, services));
178
224
  checks.push(
179
- (await services.playbook('macos/worker-host-key.yml', {
180
- host,
181
- pin: 'absent',
182
- known_hosts: services.knownHosts,
183
- }))
184
- ? {
185
- id: 'ssh:host-key',
186
- status: 'pass',
187
- summary: `The manager no longer trusts any host key of ${host}.`,
188
- }
189
- : hostKeyFailure()
225
+ await onHub(services, (request) => removeSystem(request, worker.id))
190
226
  );
227
+ if (entry?.t3EnvironmentId)
228
+ checks.push(await unlinkT3(entry.t3EnvironmentId, services.run));
229
+ if (entry?.tailscaleId)
230
+ checks.push(await removeDevice(worker, entry.tailscaleId, services));
191
231
  await changeRoster(services, (current) =>
192
232
  current && current.nodes.some((node) => node.id === worker.id)
193
233
  ? {
@@ -201,10 +241,161 @@ export async function fleetNodeRetire(
201
241
  status: 'pass',
202
242
  summary: `${worker.id} is not in the roster.`,
203
243
  });
244
+ // Nothing of the worker stays in the fleet state but its tombstone.
245
+ await changeWorkers(services.state(), () => ({ [worker.id]: null }));
204
246
  checks.push(await tombstone(worker.id, services));
205
247
  return outcome('fleet-node-retire', worker.id, checks, true);
206
248
  }
207
249
 
250
+ function planRetirement(
251
+ worker: MachineDefinition,
252
+ entry: FleetNode | undefined,
253
+ host: string,
254
+ vault: boolean
255
+ ): Check[] {
256
+ return [
257
+ ...(vault
258
+ ? [planned('auth:worker-token', `revoke ${worker.id}'s Vault token`)]
259
+ : []),
260
+ planned('ssh:host-key', `forget the host key of ${host}`),
261
+ planned('beszel:system', `delete the monitoring system ${worker.id}`),
262
+ ...(entry?.t3EnvironmentId
263
+ ? [
264
+ planned(
265
+ 't3:link',
266
+ `remove the manager's T3 link to ${entry.t3EnvironmentId}`
267
+ ),
268
+ ]
269
+ : []),
270
+ ...(entry?.tailscaleId
271
+ ? [
272
+ planned(
273
+ 'tailscale:device',
274
+ `remove the tailnet device ${entry.tailscaleId}`
275
+ ),
276
+ ]
277
+ : []),
278
+ planned(
279
+ 'enrollment:connection',
280
+ entry
281
+ ? `remove ${worker.id} from the roster`
282
+ : `${worker.id} is not in the roster`
283
+ ),
284
+ planned('state:retired', `record that ${worker.id} is retired`),
285
+ ];
286
+ }
287
+
288
+ /** Pins `key` as a worker's host key, or forgets it (null). A worker reached
289
+ * from declared data is pinned in the fleet state, and the fleet-owned
290
+ * known_hosts is rendered again; one reached through the operator's own SSH
291
+ * alias is pinned in their known_hosts by the pinned Ansible.
292
+ */
293
+ export async function pinHostKey(
294
+ worker: MachineDefinition,
295
+ host: string,
296
+ key: string | null,
297
+ services: Pick<
298
+ NodeLifecycleServices,
299
+ 'state' | 'playbook' | 'knownHosts' | 'now' | 'definition'
300
+ >
301
+ ): Promise<Check> {
302
+ if (!declaredConnection(worker)) {
303
+ const pinned = await services.playbook('macos/worker-host-key.yml', {
304
+ host,
305
+ ...(key ? { pin: 'present', key } : { pin: 'absent' }),
306
+ known_hosts: services.knownHosts,
307
+ });
308
+ if (!pinned) return hostKeyFailure();
309
+ return {
310
+ id: 'ssh:host-key',
311
+ status: 'pass',
312
+ summary: key
313
+ ? `The manager trusts exactly the given host key of ${host}.`
314
+ : `The manager no longer trusts any host key of ${host}.`,
315
+ };
316
+ }
317
+ const at = services.now().toISOString();
318
+ await changeWorkers(services.state(), (current) => {
319
+ const {
320
+ hostKey: _key,
321
+ hostKeyPinnedAt: _at,
322
+ ...rest
323
+ } = current[worker.id] ?? {};
324
+ return {
325
+ [worker.id]: key ? { ...rest, hostKey: key, hostKeyPinnedAt: at } : rest,
326
+ };
327
+ });
328
+ renderKnownHosts(services.definition(), process.env, readPins());
329
+ return {
330
+ id: 'ssh:host-key',
331
+ status: 'pass',
332
+ summary: key
333
+ ? `The fleet state pins exactly the given host key of ${worker.id}.`
334
+ : `The fleet state pins no host key of ${worker.id}.`,
335
+ };
336
+ }
337
+
338
+ /** One change on the monitoring hub. A hub that cannot be reached is a
339
+ * warning, never a reason to stop.
340
+ */
341
+ async function onHub(
342
+ services: NodeLifecycleServices,
343
+ change: (request: HubRequest) => Promise<Check>
344
+ ): Promise<Check> {
345
+ if (!services.monitoring)
346
+ return {
347
+ id: 'beszel:system',
348
+ status: 'skip',
349
+ summary: 'The monitoring hub was not asked: there is no hub access here.',
350
+ };
351
+ try {
352
+ return await change(await services.monitoring());
353
+ } catch (error) {
354
+ return {
355
+ id: 'beszel:system',
356
+ status: 'warn',
357
+ summary:
358
+ error instanceof Error && /^BESZEL_[A-Z_]+$/.test(error.message)
359
+ ? error.message
360
+ : 'BESZEL_API_UNAVAILABLE',
361
+ remedy: {
362
+ action:
363
+ 'The hub was not changed. Run fleet-sync on the manager so the hub answers, then run this command again; fleet-status --workers lists systems that match no declared worker.',
364
+ },
365
+ };
366
+ }
367
+ }
368
+
369
+ /** Forgets the manager's T3 link to an environment that is gone. */
370
+ async function unlinkT3(environment: string, run: Runner): Promise<Check> {
371
+ const result = await run(['t3', 'environment', 'unlink', environment], {
372
+ timeoutMs: 30_000,
373
+ });
374
+ return result.ok
375
+ ? {
376
+ id: 't3:link',
377
+ status: 'pass',
378
+ summary: `The manager no longer links to the T3 environment ${environment}.`,
379
+ }
380
+ : {
381
+ id: 't3:link',
382
+ status: 'warn',
383
+ summary: 'T3_LINK_UNLINK_FAILED',
384
+ remedy: {
385
+ action: `Remove the link to ${environment} in the manager's T3 (Settings, Connections), then run the command again.`,
386
+ },
387
+ };
388
+ }
389
+
390
+ async function removeDevice(
391
+ worker: MachineDefinition,
392
+ device: string,
393
+ services: NodeLifecycleServices
394
+ ): Promise<Check> {
395
+ if (services.tailnet) return services.tailnet.removeDevice(worker, device);
396
+ return deviceLeftToPerson(worker, device);
397
+ }
398
+
208
399
  async function tombstone(
209
400
  id: string,
210
401
  services: NodeLifecycleServices
@@ -241,10 +432,14 @@ async function tombstone(
241
432
 
242
433
  /** Puts a fresh machine in place of a worker under the same id: verifies the
243
434
  * new device on the tailnet, pins its host key, binds the roster entry to the
244
- * new device and issues it a token of its own (revoking the old one). Preview
245
- * until --apply --yes; then `fleet-sync --node ID` brings it to ready. When
246
- * nothing changed (the same device, the same key, a token of its own) it
247
- * changes nothing.
435
+ * new device, points its monitoring system at it, removes the manager's T3
436
+ * link to the old environment, pairs the manager's T3 with the new one (now,
437
+ * or in the next fleet-sync once it runs), removes the old tailnet device
438
+ * (when the tailnet control is wired) and issues it a token of its own
439
+ * (revoking the old one).
440
+ * Preview until --apply --yes; then `fleet-sync --node ID` brings it to
441
+ * ready. When nothing changed (the same device, the same key, a token of its
442
+ * own) it changes nothing.
248
443
  */
249
444
  export async function fleetNodeReplace(
250
445
  options: NodeLifecycleOptions,
@@ -255,13 +450,19 @@ export async function fleetNodeReplace(
255
450
  const device = await newDevice(worker, services.run);
256
451
  if ('check' in device)
257
452
  return outcome('fleet-node-replace', worker.id, [device.check], false);
453
+ const replaced = entry.tailscaleId !== device.computer.id;
258
454
  const plan: Replacement = {
259
455
  worker,
456
+ node: { ...entry, tailscaleId: device.computer.id },
260
457
  device: device.computer.id,
261
458
  host: await keyName(worker, services.run),
262
459
  hostKey: options.hostKey,
263
- replaced: entry.tailscaleId !== device.computer.id,
460
+ replaced,
264
461
  needsToken: false,
462
+ ...(replaced && entry.tailscaleId ? { previous: entry.tailscaleId } : {}),
463
+ ...(replaced && entry.t3EnvironmentId
464
+ ? { environment: entry.t3EnvironmentId }
465
+ : {}),
265
466
  };
266
467
  plan.needsToken =
267
468
  services.vault() &&
@@ -288,11 +489,17 @@ export async function fleetNodeReplace(
288
489
 
289
490
  interface Replacement {
290
491
  worker: MachineDefinition;
492
+ /** The roster entry, bound to the new device. */
493
+ node: FleetNode;
291
494
  device: string;
292
495
  host: string;
293
496
  hostKey: string | undefined;
294
497
  replaced: boolean;
295
498
  needsToken: boolean;
499
+ /** The device the worker ran as before, when it changed. */
500
+ previous?: string;
501
+ /** The T3 environment the manager linked to before, when it changed. */
502
+ environment?: string;
296
503
  }
297
504
 
298
505
  async function replaceable(
@@ -300,15 +507,14 @@ async function replaceable(
300
507
  options: NodeLifecycleOptions,
301
508
  services: NodeLifecycleServices
302
509
  ) {
303
- if (
304
- options.hostKey !== undefined &&
305
- !/^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$/.test(
306
- options.hostKey
307
- )
308
- )
510
+ if (options.hostKey !== undefined && !HOST_KEY.test(options.hostKey))
309
511
  throw new FleetUsageError(
310
512
  '--host-key expects one public key, "TYPE BASE64", as ssh-keyscan or the seed prints it.'
311
513
  );
514
+ if (options.hostKey !== undefined && worker.network?.hostKey)
515
+ throw new FleetUsageError(
516
+ `${worker.id}'s host key is declared by its worker record. Change hostKey in the record instead; it is pinned on the next command.`
517
+ );
312
518
  const roster = await readRoster(services.roster);
313
519
  const entry = roster?.nodes.find((node) => node.id === worker.id);
314
520
  if (!entry)
@@ -327,8 +533,27 @@ function planReplacement(plan: Replacement): Check[] {
327
533
  planned(
328
534
  'enrollment:connection',
329
535
  `bind ${plan.worker.id} to ${plan.device}`
536
+ ),
537
+ planned(
538
+ 'beszel:system',
539
+ `point the monitoring system ${plan.worker.id} at the new machine`
330
540
  )
331
541
  );
542
+ if (plan.environment)
543
+ steps.push(
544
+ planned('t3:link', `remove the manager's T3 link to ${plan.environment}`)
545
+ );
546
+ if (plan.replaced)
547
+ steps.push(
548
+ planned(
549
+ 't3:link',
550
+ `pair the manager's T3 with the new T3 environment of ${plan.worker.id}`
551
+ )
552
+ );
553
+ if (plan.previous)
554
+ steps.push(
555
+ planned('tailscale:device', `remove the tailnet device ${plan.previous}`)
556
+ );
332
557
  if (plan.needsToken)
333
558
  steps.push(
334
559
  planned('auth:worker-token', `issue ${plan.worker.id} a token of its own`)
@@ -343,30 +568,19 @@ async function carryOut(
343
568
  const { worker } = plan;
344
569
  const steps: Check[] = [];
345
570
  if (plan.hostKey)
346
- steps.push(
347
- (await services.playbook('macos/worker-host-key.yml', {
348
- host: plan.host,
349
- pin: 'present',
350
- key: plan.hostKey,
351
- known_hosts: services.knownHosts,
352
- }))
353
- ? {
354
- id: 'ssh:host-key',
355
- status: 'pass',
356
- summary: `The manager trusts exactly the given host key of ${plan.host}.`,
357
- }
358
- : hostKeyFailure()
359
- );
571
+ steps.push(await pinHostKey(worker, plan.host, plan.hostKey, services));
360
572
  if (plan.replaced) {
361
573
  await changeRoster(services, (current) =>
362
574
  current
363
575
  ? {
364
576
  ...current,
365
- nodes: current.nodes.map((node) =>
366
- node.id === worker.id
367
- ? { ...node, tailscaleId: plan.device }
368
- : node
369
- ),
577
+ nodes: current.nodes.map((node) => {
578
+ if (node.id !== worker.id) return node;
579
+ // The old environment's identity goes with the old machine:
580
+ // the next sync records the new one once it is linked.
581
+ const { t3EnvironmentId: _old, ...rest } = node;
582
+ return { ...rest, tailscaleId: plan.device };
583
+ }),
370
584
  }
371
585
  : null
372
586
  );
@@ -375,7 +589,21 @@ async function carryOut(
375
589
  status: 'pass',
376
590
  summary: `The roster binds ${worker.id} to the device ${plan.device}.`,
377
591
  });
592
+ steps.push(
593
+ await onHub(services, (request) =>
594
+ moveSystem(
595
+ request,
596
+ worker.id,
597
+ worker.network?.tailscaleName ?? plan.host
598
+ )
599
+ )
600
+ );
378
601
  }
602
+ if (plan.environment)
603
+ steps.push(await unlinkT3(plan.environment, services.run));
604
+ if (plan.replaced) steps.push(await pairReplacement(plan.node, services));
605
+ if (plan.previous)
606
+ steps.push(await removeDevice(worker, plan.previous, services));
379
607
  if (plan.needsToken) {
380
608
  const revoked = await revokeWorkerToken(worker.id, services.run);
381
609
  steps.push(
@@ -392,6 +620,23 @@ async function carryOut(
392
620
  return steps;
393
621
  }
394
622
 
623
+ /** Pairs the manager's T3 with a replacement's environment and records it
624
+ * in the roster. A machine whose T3 does not run yet (or refuses) is left to
625
+ * the next fleet-sync, which pairs it once it runs.
626
+ */
627
+ function pairReplacement(
628
+ node: FleetNode,
629
+ services: NodeLifecycleServices
630
+ ): Promise<Check> {
631
+ return pairAndRecord(node, {
632
+ roster: services.roster,
633
+ run: services.run,
634
+ manager: services.t3(),
635
+ context: services.state,
636
+ now: services.now,
637
+ });
638
+ }
639
+
395
640
  async function ownToken(id: string, run: Runner): Promise<boolean> {
396
641
  const location = workerTokenLocation(id);
397
642
  return (
@@ -415,7 +660,11 @@ async function newDevice(
415
660
  run: Runner
416
661
  ): Promise<{ computer: TailnetComputer } | { check: Check }> {
417
662
  const tailnet = await discoverTailnet(run);
418
- const name = (worker.network?.tailscaleName ?? worker.ssh).toLowerCase();
663
+ const name = (
664
+ worker.network?.tailscaleName ??
665
+ worker.ssh ??
666
+ ''
667
+ ).toLowerCase();
419
668
  const short = name.split('.')[0]!;
420
669
  const found = tailnet.computers.filter(
421
670
  (computer) =>