@crvouga/dev-fleet 0.7.0 → 0.8.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.
@@ -66,6 +66,14 @@
66
66
  "id": "auth:vault",
67
67
  "meaning": "This machine has its own Vault session."
68
68
  },
69
+ {
70
+ "id": "auth:vault-isolation",
71
+ "meaning": "A worker reads its Vault token from a document of its own, not from the fleet's shared one."
72
+ },
73
+ {
74
+ "id": "auth:worker-token",
75
+ "meaning": "A worker has a Vault token of its own that only it and the manager can read, or a retired worker's token is revoked."
76
+ },
69
77
  {
70
78
  "id": "beszel:*",
71
79
  "meaning": "A monitoring (Beszel) check of this machine."
@@ -150,6 +158,18 @@
150
158
  "id": "configuration:operation",
151
159
  "meaning": "The remote configuration operation completed."
152
160
  },
161
+ {
162
+ "id": "definition:inventory",
163
+ "meaning": "Ansible reads the inventory the fleet definition describes."
164
+ },
165
+ {
166
+ "id": "definition:playbooks",
167
+ "meaning": "Every playbook passes ansible-playbook --syntax-check."
168
+ },
169
+ {
170
+ "id": "definition:schema",
171
+ "meaning": "fleet.json follows every rule of the fleet definition."
172
+ },
153
173
  {
154
174
  "id": "enrollment:connection",
155
175
  "meaning": "The manager has an enrolled connection to this worker."
@@ -306,6 +326,10 @@
306
326
  "id": "rollout:evidence",
307
327
  "meaning": "A worker's rollout produced verification evidence."
308
328
  },
329
+ {
330
+ "id": "rollout:next",
331
+ "meaning": "Names the command that converges a worker after it was replaced."
332
+ },
309
333
  {
310
334
  "id": "rollout:operation",
311
335
  "meaning": "The remote rollout operation completed."
@@ -390,6 +414,10 @@
390
414
  "id": "state:replica",
391
415
  "meaning": "The bucket holds this manager's fleet state."
392
416
  },
417
+ {
418
+ "id": "state:retired",
419
+ "meaning": "The fleet state records that a retired worker was retired."
420
+ },
393
421
  {
394
422
  "id": "t3:api",
395
423
  "meaning": "The local T3 API answers."
@@ -538,6 +566,10 @@
538
566
  "id": "worker:package:*",
539
567
  "meaning": "The pinned Ubuntu package is installed at or past its pin."
540
568
  },
569
+ {
570
+ "id": "worker:manifest",
571
+ "meaning": "A worker is made of what the declared pins and a fresh build of the same revision are made of."
572
+ },
541
573
  {
542
574
  "id": "worker:power-loss-recovery",
543
575
  "meaning": "The worker powers on after AC loss."
@@ -58,7 +58,6 @@
58
58
  },
59
59
  "workers": {
60
60
  "type": "array",
61
- "minItems": 1,
62
61
  "maxItems": 64,
63
62
  "items": {
64
63
  "type": "object",
@@ -68,7 +67,7 @@
68
67
  "id": {
69
68
  "type": "string",
70
69
  "pattern": "^[a-z][a-z0-9-]{0,62}$",
71
- "description": "A short stable id for the worker. The first worker listed is the canary: a revision new to the fleet must pass there before the others receive it."
70
+ "description": "A short stable id for the worker. A revision new to the fleet must pass on one worker, the canary, before the others receive it: the one named under canary, or else the first reachable worker in this order."
72
71
  },
73
72
  "ssh": {
74
73
  "type": "string",
@@ -119,6 +118,11 @@
119
118
  }
120
119
  }
121
120
  },
121
+ "canary": {
122
+ "type": "string",
123
+ "pattern": "^[a-z][a-z0-9-]{0,62}$",
124
+ "description": "The id of the worker that proves a revision new to the fleet first. When it is unreachable, the next reachable worker proves it instead. Default: the first reachable worker."
125
+ },
122
126
  "services": {
123
127
  "type": "object",
124
128
  "additionalProperties": false,
@@ -626,6 +630,22 @@
626
630
  "pattern": "^[A-Za-z0-9_]+$"
627
631
  }
628
632
  }
633
+ },
634
+ "tailnet-auth-key": {
635
+ "type": "object",
636
+ "additionalProperties": false,
637
+ "required": ["path", "field"],
638
+ "properties": {
639
+ "path": {
640
+ "type": "string",
641
+ "pattern": "^[A-Za-z0-9._/-]+$"
642
+ },
643
+ "field": {
644
+ "type": "string",
645
+ "pattern": "^[A-Za-z0-9_]+$"
646
+ }
647
+ },
648
+ "description": "Optional: a short-lived, single-use, tagged Tailscale auth key a new worker joins the tailnet with (fleet-node-seed)."
629
649
  }
630
650
  },
631
651
  "description": "Required for every secret except github-token, which defaults to the field GITHUB_TOKEN beside proxy-client-key."
@@ -130,6 +130,7 @@ export const CHECK_OWNERS: CheckOwner[] = [
130
130
  { pattern: 'github:rate-limit', manual: EVIDENCE },
131
131
  { pattern: 'github:workflow', manual: EVIDENCE },
132
132
  { pattern: 'fleet:*', manual: DECLARATION },
133
+ { pattern: 'definition:*', manual: DECLARATION },
133
134
  { pattern: 'enrollment:*', manual: DECLARATION },
134
135
  { pattern: 'notify:*', manual: DECLARATION },
135
136
  {
@@ -0,0 +1,102 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { FleetUsageError } from '../errors';
3
+ import { runCommand } from '../runner';
4
+ import type { Runner } from '../types';
5
+ import {
6
+ declaredDifferences,
7
+ diffManifests,
8
+ manifestCheck,
9
+ manifestDigest,
10
+ measureManifest,
11
+ ubuntuPolicy,
12
+ type WorkerManifest,
13
+ } from '../worker-manifest';
14
+ import type { CommandOutcome } from './contracts';
15
+ import { readRoster } from './roster';
16
+
17
+ export interface FleetManifestOptions {
18
+ id?: string;
19
+ /** A manifest from a fresh build of the same revision (fleet-manifest
20
+ * --json of that build) to compare the worker with.
21
+ */
22
+ reference?: string;
23
+ }
24
+
25
+ export interface FleetManifestServices {
26
+ run: Runner;
27
+ roster: string;
28
+ root?: string;
29
+ }
30
+
31
+ export const manifestServices = (roster: string): FleetManifestServices => ({
32
+ run: runCommand,
33
+ roster,
34
+ });
35
+
36
+ async function reference(path: string): Promise<WorkerManifest> {
37
+ try {
38
+ const value = JSON.parse(await readFile(path, 'utf8')) as {
39
+ manifest?: WorkerManifest;
40
+ } & Partial<WorkerManifest>;
41
+ const manifest = value.manifest ?? (value as WorkerManifest);
42
+ if (manifest.version !== 1 || !manifest.packages) throw new Error();
43
+ return manifest;
44
+ } catch {
45
+ throw new FleetUsageError(
46
+ '--reference must name a manifest written by devfleet fleet-manifest --json.'
47
+ );
48
+ }
49
+ }
50
+
51
+ /** Read-only: measures what an enrolled worker is made of, and the check
52
+ * worker:manifest against the declared pins and, with --reference, a fresh
53
+ * build of the same revision.
54
+ */
55
+ export async function fleetManifest(
56
+ options: FleetManifestOptions,
57
+ services: FleetManifestServices
58
+ ): Promise<CommandOutcome> {
59
+ const roster = await readRoster(services.roster);
60
+ const node = roster?.nodes.find((entry) => entry.id === options.id);
61
+ if (!node) throw new FleetUsageError('--id must name an enrolled worker.');
62
+ const fresh = options.reference
63
+ ? await reference(options.reference)
64
+ : undefined;
65
+ const policy = await ubuntuPolicy(services.root);
66
+ const measured = await measureManifest(node, services.run, policy);
67
+ if ('check' in measured)
68
+ return {
69
+ exitCode: 1,
70
+ data: {
71
+ version: 1,
72
+ command: 'fleet-manifest',
73
+ ok: false,
74
+ node: node.id,
75
+ checks: [measured.check],
76
+ },
77
+ };
78
+ const { manifest } = measured;
79
+ const differences = [
80
+ ...declaredDifferences(manifest, policy),
81
+ ...(fresh ? diffManifests(fresh, manifest) : []),
82
+ ];
83
+ const check = manifestCheck(differences);
84
+ const digest = manifestDigest(manifest);
85
+ return {
86
+ exitCode: check.status === 'fail' ? 1 : 0,
87
+ data: {
88
+ version: 1,
89
+ command: 'fleet-manifest',
90
+ ok: check.status !== 'fail',
91
+ node: node.id,
92
+ digest,
93
+ manifest,
94
+ differences,
95
+ checks: [check],
96
+ },
97
+ text: [
98
+ `${node.id}: ${Object.keys(manifest.packages).length} packages, ${Object.keys(manifest.tools).length} tools, digest ${digest}`,
99
+ `${check.status.toUpperCase()} ${check.id}: ${check.summary}`,
100
+ ].join('\n'),
101
+ };
102
+ }
@@ -0,0 +1,455 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { parseConfig } from '../config';
4
+ import type { FleetDefinition, MachineDefinition } from '../definition';
5
+ import { FleetUsageError } from '../errors';
6
+ import { fleet } from '../fleet-config';
7
+ import type { Enrollment } from '../machine/state';
8
+ import { PACKAGE_ROOT } from '../package-info';
9
+ import { runAnsible } from '../platform/ansible';
10
+ import { makeReport, renderReport } from '../report';
11
+ import { runCommand } from '../runner';
12
+ import { loadEnrollment, storeEnrollment } from '../state/enrollment';
13
+ import { STATE_ROSTER, writeRosterText } from '../state/roster';
14
+ import { mutateState, stateContext, type StateContext } from '../state/sync';
15
+ import { discoverTailnet, type TailnetComputer } from '../tailscale';
16
+ import type { Check, FleetConfig, Runner } from '../types';
17
+ import { usesVault } from '../vault-config';
18
+ import {
19
+ issueWorkerToken,
20
+ revokeWorkerToken,
21
+ workerTokenLocation,
22
+ } from '../worker-credentials';
23
+ import type { CommandOutcome } from './contracts';
24
+ import { readRoster } from './roster';
25
+
26
+ export interface NodeLifecycleOptions {
27
+ id?: string;
28
+ /** The replacement's SSH host key, `TYPE BASE64`, verified out of band or
29
+ * written by the seed that created it.
30
+ */
31
+ hostKey?: string;
32
+ apply?: boolean;
33
+ yes?: boolean;
34
+ }
35
+
36
+ export interface NodeLifecycleServices {
37
+ run: Runner;
38
+ /** The roster: the fleet state's, or a file `--config` names. */
39
+ roster: string;
40
+ definition: () => FleetDefinition;
41
+ vault: () => boolean;
42
+ /** The manager's known_hosts file. */
43
+ knownHosts: string;
44
+ state: () => StateContext;
45
+ /** Applies a playbook of the package with these variables. */
46
+ playbook: (name: string, variables: object) => Promise<boolean>;
47
+ now: () => Date;
48
+ }
49
+
50
+ export function lifecycleServices(roster: string): NodeLifecycleServices {
51
+ return {
52
+ run: runCommand,
53
+ roster,
54
+ definition: () => fleet,
55
+ vault: usesVault,
56
+ knownHosts: join(homedir(), '.ssh/known_hosts'),
57
+ state: () => stateContext(runCommand),
58
+ playbook: async (name, variables) =>
59
+ (
60
+ await runAnsible(runCommand, join(PACKAGE_ROOT, 'ansible', name), {
61
+ input: JSON.stringify(variables),
62
+ timeoutMs: 300_000,
63
+ })
64
+ ).ok,
65
+ now: () => new Date(),
66
+ };
67
+ }
68
+
69
+ function declared(
70
+ definition: FleetDefinition,
71
+ id: string | undefined
72
+ ): MachineDefinition {
73
+ const worker = definition.workers.find((entry) => entry.id === id);
74
+ if (!worker)
75
+ throw new FleetUsageError(
76
+ '--id must name a worker fleet.json declares. To retire a worker, retire it first and then remove it from fleet.json.'
77
+ );
78
+ return worker;
79
+ }
80
+
81
+ /** The name ssh checks the host key of: the HostName the alias resolves to,
82
+ * as `[host]:port` when the port is not 22.
83
+ */
84
+ export async function keyName(
85
+ worker: MachineDefinition,
86
+ run: Runner
87
+ ): Promise<string> {
88
+ const resolved = await run(['ssh', '-G', worker.ssh], { timeoutMs: 10_000 });
89
+ const option = (name: string) =>
90
+ resolved.ok
91
+ ? new RegExp(`^${name} (\\S+)$`, 'm').exec(resolved.stdout)?.[1]
92
+ : undefined;
93
+ const host =
94
+ option('hostname') ?? worker.network?.tailscaleName ?? worker.ssh;
95
+ const port = option('port') ?? String(worker.network?.sshPort ?? 22);
96
+ return port === '22' ? host : `[${host}]:${port}`;
97
+ }
98
+
99
+ /** Changes the roster in the fleet state (or the file `--config` names). */
100
+ async function changeRoster(
101
+ services: NodeLifecycleServices,
102
+ change: (roster: FleetConfig | null) => FleetConfig | null
103
+ ): Promise<void> {
104
+ const save = async () => {
105
+ const next = change(await readRoster(services.roster));
106
+ if (next)
107
+ await writeRosterText(
108
+ services.roster,
109
+ `${JSON.stringify(parseConfig(next), null, 2)}\n`
110
+ );
111
+ };
112
+ if (services.roster === STATE_ROSTER)
113
+ await mutateState(services.state(), save);
114
+ else await save();
115
+ }
116
+
117
+ function outcome(
118
+ command: string,
119
+ id: string,
120
+ checks: Check[],
121
+ applied: boolean
122
+ ): CommandOutcome {
123
+ const report = { ...makeReport(command, [{ node: id, checks }]), applied };
124
+ return {
125
+ exitCode: checks.some((check) => check.status === 'fail') ? 1 : 0,
126
+ data: report,
127
+ text: renderReport(report),
128
+ };
129
+ }
130
+
131
+ const planned = (id: string, summary: string): Check => ({
132
+ id,
133
+ status: 'skip',
134
+ summary: `Planned: ${summary}`,
135
+ });
136
+
137
+ const preview = (options: NodeLifecycleOptions): boolean =>
138
+ !(options.apply && options.yes);
139
+
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.
143
+ */
144
+ export async function fleetNodeRetire(
145
+ options: NodeLifecycleOptions,
146
+ services: NodeLifecycleServices
147
+ ): Promise<CommandOutcome> {
148
+ const worker = declared(services.definition(), options.id);
149
+ 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)
152
+ throw new FleetUsageError(
153
+ 'The roster must keep at least one worker. Add the replacement with fleet-node-add first.'
154
+ );
155
+ const host = await keyName(worker, services.run);
156
+ if (preview(options))
157
+ return outcome(
158
+ 'fleet-node-retire',
159
+ 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
+ ],
173
+ false
174
+ );
175
+ const checks: Check[] = [];
176
+ if (services.vault())
177
+ checks.push(await revokeWorkerToken(worker.id, services.run));
178
+ 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()
190
+ );
191
+ await changeRoster(services, (current) =>
192
+ current && current.nodes.some((node) => node.id === worker.id)
193
+ ? {
194
+ ...current,
195
+ nodes: current.nodes.filter((node) => node.id !== worker.id),
196
+ }
197
+ : null
198
+ );
199
+ checks.push({
200
+ id: 'enrollment:connection',
201
+ status: 'pass',
202
+ summary: `${worker.id} is not in the roster.`,
203
+ });
204
+ checks.push(await tombstone(worker.id, services));
205
+ return outcome('fleet-node-retire', worker.id, checks, true);
206
+ }
207
+
208
+ async function tombstone(
209
+ id: string,
210
+ services: NodeLifecycleServices
211
+ ): Promise<Check> {
212
+ const record = async (): Promise<Enrollment | null> => {
213
+ const enrollment = await loadEnrollment(null);
214
+ if (!enrollment) return null;
215
+ const { [id]: _applied, ...workers } = enrollment.workers ?? {};
216
+ const retired = {
217
+ ...enrollment,
218
+ workers,
219
+ retired: {
220
+ ...enrollment.retired,
221
+ [id]: enrollment.retired?.[id] ?? services.now().toISOString(),
222
+ },
223
+ };
224
+ storeEnrollment(retired);
225
+ return retired;
226
+ };
227
+ const stored = (await mutateState(services.state(), record)).value;
228
+ return stored
229
+ ? {
230
+ id: 'state:retired',
231
+ status: 'pass',
232
+ summary: `The fleet state records that ${id} was retired at ${stored.retired![id]}.`,
233
+ }
234
+ : {
235
+ id: 'state:retired',
236
+ status: 'skip',
237
+ summary:
238
+ 'No revision was ever applied to this fleet, so there is nothing to record.',
239
+ };
240
+ }
241
+
242
+ /** Puts a fresh machine in place of a worker under the same id: verifies the
243
+ * 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.
248
+ */
249
+ export async function fleetNodeReplace(
250
+ options: NodeLifecycleOptions,
251
+ services: NodeLifecycleServices
252
+ ): Promise<CommandOutcome> {
253
+ const worker = declared(services.definition(), options.id);
254
+ const entry = await replaceable(worker, options, services);
255
+ const device = await newDevice(worker, services.run);
256
+ if ('check' in device)
257
+ return outcome('fleet-node-replace', worker.id, [device.check], false);
258
+ const plan: Replacement = {
259
+ worker,
260
+ device: device.computer.id,
261
+ host: await keyName(worker, services.run),
262
+ hostKey: options.hostKey,
263
+ replaced: entry.tailscaleId !== device.computer.id,
264
+ needsToken: false,
265
+ };
266
+ plan.needsToken =
267
+ services.vault() &&
268
+ (plan.replaced || !(await ownToken(worker.id, services.run)));
269
+ const identity: Check = {
270
+ id: 'tailscale:identity',
271
+ status: 'pass',
272
+ summary: `${worker.id} is the online device ${plan.device}${plan.replaced ? `, replacing ${entry.tailscaleId ?? 'none'}` : ''}.`,
273
+ };
274
+ if (preview(options))
275
+ return outcome(
276
+ 'fleet-node-replace',
277
+ worker.id,
278
+ [identity, ...planReplacement(plan)],
279
+ false
280
+ );
281
+ return outcome(
282
+ 'fleet-node-replace',
283
+ worker.id,
284
+ [identity, ...(await carryOut(plan, services))],
285
+ true
286
+ );
287
+ }
288
+
289
+ interface Replacement {
290
+ worker: MachineDefinition;
291
+ device: string;
292
+ host: string;
293
+ hostKey: string | undefined;
294
+ replaced: boolean;
295
+ needsToken: boolean;
296
+ }
297
+
298
+ async function replaceable(
299
+ worker: MachineDefinition,
300
+ options: NodeLifecycleOptions,
301
+ services: NodeLifecycleServices
302
+ ) {
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
+ )
309
+ throw new FleetUsageError(
310
+ '--host-key expects one public key, "TYPE BASE64", as ssh-keyscan or the seed prints it.'
311
+ );
312
+ const roster = await readRoster(services.roster);
313
+ const entry = roster?.nodes.find((node) => node.id === worker.id);
314
+ if (!entry)
315
+ throw new FleetUsageError(
316
+ `${worker.id} is not in the roster. Add it with fleet-node-add first.`
317
+ );
318
+ return entry;
319
+ }
320
+
321
+ function planReplacement(plan: Replacement): Check[] {
322
+ const steps: Check[] = [];
323
+ if (plan.hostKey)
324
+ steps.push(planned('ssh:host-key', `pin the host key of ${plan.host}`));
325
+ if (plan.replaced)
326
+ steps.push(
327
+ planned(
328
+ 'enrollment:connection',
329
+ `bind ${plan.worker.id} to ${plan.device}`
330
+ )
331
+ );
332
+ if (plan.needsToken)
333
+ steps.push(
334
+ planned('auth:worker-token', `issue ${plan.worker.id} a token of its own`)
335
+ );
336
+ return steps;
337
+ }
338
+
339
+ async function carryOut(
340
+ plan: Replacement,
341
+ services: NodeLifecycleServices
342
+ ): Promise<Check[]> {
343
+ const { worker } = plan;
344
+ const steps: Check[] = [];
345
+ 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
+ );
360
+ if (plan.replaced) {
361
+ await changeRoster(services, (current) =>
362
+ current
363
+ ? {
364
+ ...current,
365
+ nodes: current.nodes.map((node) =>
366
+ node.id === worker.id
367
+ ? { ...node, tailscaleId: plan.device }
368
+ : node
369
+ ),
370
+ }
371
+ : null
372
+ );
373
+ steps.push({
374
+ id: 'enrollment:connection',
375
+ status: 'pass',
376
+ summary: `The roster binds ${worker.id} to the device ${plan.device}.`,
377
+ });
378
+ }
379
+ if (plan.needsToken) {
380
+ const revoked = await revokeWorkerToken(worker.id, services.run);
381
+ steps.push(
382
+ revoked.status === 'fail'
383
+ ? revoked
384
+ : await issueWorkerToken(worker.id, services.run)
385
+ );
386
+ }
387
+ steps.push({
388
+ id: 'rollout:next',
389
+ status: 'pass',
390
+ summary: `Run devfleet fleet-sync --node ${worker.id} to converge the new machine.`,
391
+ });
392
+ return steps;
393
+ }
394
+
395
+ async function ownToken(id: string, run: Runner): Promise<boolean> {
396
+ const location = workerTokenLocation(id);
397
+ return (
398
+ await run(
399
+ [
400
+ 'vault',
401
+ 'kv',
402
+ 'get',
403
+ `-mount=${location.mount}`,
404
+ '-field=accessor',
405
+ location.path,
406
+ ],
407
+ { timeoutMs: 20_000 }
408
+ )
409
+ ).ok;
410
+ }
411
+
412
+ /** The one online computer on the tailnet that carries the worker's name. */
413
+ async function newDevice(
414
+ worker: MachineDefinition,
415
+ run: Runner
416
+ ): Promise<{ computer: TailnetComputer } | { check: Check }> {
417
+ const tailnet = await discoverTailnet(run);
418
+ const name = (worker.network?.tailscaleName ?? worker.ssh).toLowerCase();
419
+ const short = name.split('.')[0]!;
420
+ const found = tailnet.computers.filter(
421
+ (computer) =>
422
+ !computer.self &&
423
+ computer.online &&
424
+ [computer.dns.toLowerCase(), computer.name.toLowerCase()].some(
425
+ (candidate) => candidate === name || candidate.split('.')[0] === short
426
+ )
427
+ );
428
+ if (found.length === 1) return { computer: found[0]! };
429
+ return {
430
+ check: {
431
+ id: 'tailscale:identity',
432
+ status: 'fail',
433
+ summary: found.length
434
+ ? 'TAILSCALE_IDENTITY_CONFLICT'
435
+ : 'TAILSCALE_NODE_MISSING',
436
+ remedy: {
437
+ action: found.length
438
+ ? `More than one online device is named like ${worker.id}. Remove the old device from the tailnet, then run the command again.`
439
+ : `No online device is named like ${worker.id}. Start the new machine, join it to the tailnet under that name, then run the command again.`,
440
+ },
441
+ },
442
+ };
443
+ }
444
+
445
+ function hostKeyFailure(): Check {
446
+ return {
447
+ id: 'ssh:host-key',
448
+ status: 'fail',
449
+ summary: 'SSH_HOST_KEY_NOT_CHANGED',
450
+ remedy: {
451
+ action:
452
+ 'The pinned Ansible could not change known_hosts on this manager. Run devfleet fleet-validate --ansible, then this command again.',
453
+ },
454
+ };
455
+ }