@crvouga/dev-fleet 0.6.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.
Files changed (56) hide show
  1. package/README.md +32 -13
  2. package/ansible/macos/manager-privilege.yml +37 -0
  3. package/ansible/macos/manager.yml +51 -6
  4. package/ansible/macos/policy.json +6 -1
  5. package/ansible/macos/worker-host-key.yml +33 -0
  6. package/ansible/monitoring/agent.yml +2 -0
  7. package/ansible/ubuntu/baseline.yml +6 -0
  8. package/ansible/ubuntu/recovery.yml +2 -0
  9. package/ansible/ubuntu/tailscale.yml +2 -0
  10. package/bootstrap/worker-seed.yaml +61 -0
  11. package/docs/authentication.md +2 -0
  12. package/docs/consumer-setup.md +34 -35
  13. package/docs/declared.md +24 -24
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/manager-setup.md +27 -0
  16. package/docs/operations.md +8 -0
  17. package/docs/runbooks.md +15 -1
  18. package/docs/state.md +38 -6
  19. package/docs/topology.md +19 -11
  20. package/docs/ubuntu-sync.md +21 -15
  21. package/package.json +2 -2
  22. package/schema/check-ids.json +36 -0
  23. package/schema/fleet.schema.json +22 -2
  24. package/src/check-owners.ts +164 -0
  25. package/src/cli/fleet-manifest.ts +102 -0
  26. package/src/cli/fleet-node-lifecycle.ts +455 -0
  27. package/src/cli/fleet-node-seed.ts +151 -0
  28. package/src/cli/fleet-restore.ts +205 -0
  29. package/src/cli/fleet-state.ts +57 -5
  30. package/src/cli/fleet-sync-forward.ts +2 -0
  31. package/src/cli/fleet-sync-manager.ts +101 -18
  32. package/src/cli/fleet-sync-remote.ts +21 -6
  33. package/src/cli/fleet-sync-state.ts +30 -11
  34. package/src/cli/fleet-sync-vault.ts +75 -26
  35. package/src/cli/fleet-sync.ts +54 -5
  36. package/src/cli/fleet-validate.ts +239 -0
  37. package/src/cli/register-fleet.ts +167 -0
  38. package/src/cli/registry.ts +47 -1
  39. package/src/definition.ts +444 -237
  40. package/src/machine/state.ts +33 -1
  41. package/src/manager-sync.ts +24 -4
  42. package/src/platform/manager-host.ts +184 -0
  43. package/src/repair-catalog.ts +64 -0
  44. package/src/repair-plan.ts +25 -13
  45. package/src/secrets.ts +3 -0
  46. package/src/state/check.ts +7 -0
  47. package/src/state/holder.ts +50 -0
  48. package/src/state/litestream.ts +6 -3
  49. package/src/state/migrations/0001-documents.sql +13 -0
  50. package/src/state/migrations/0002-lease.sql +7 -0
  51. package/src/state/store.ts +125 -26
  52. package/src/state/sync.ts +131 -10
  53. package/src/worker-credentials.ts +201 -0
  54. package/src/worker-manifest.ts +201 -0
  55. package/src/worker-policy.ts +21 -35
  56. package/src/worker-seed.ts +138 -0
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { FleetDefinition } from '../definition';
5
+ import { ubuntuRelease } from '../platform/ubuntu-release';
6
+ import { FleetUsageError } from '../errors';
7
+ import { fleet, secret } from '../fleet-config';
8
+ import type { SecretsProvider } from '../secrets';
9
+ import type { Check } from '../types';
10
+ import {
11
+ generateHostKey,
12
+ renderSeed,
13
+ seedUserData,
14
+ tailscalePins,
15
+ } from '../worker-seed';
16
+ import type { CommandOutcome } from './contracts';
17
+ import {
18
+ keyName,
19
+ lifecycleServices,
20
+ type NodeLifecycleServices,
21
+ } from './fleet-node-lifecycle';
22
+
23
+ export interface NodeSeedOptions {
24
+ id?: string;
25
+ output?: string;
26
+ publicKey?: string;
27
+ autoinstall?: boolean;
28
+ apply?: boolean;
29
+ yes?: boolean;
30
+ }
31
+
32
+ export interface NodeSeedServices extends Pick<
33
+ NodeLifecycleServices,
34
+ 'run' | 'playbook' | 'knownHosts'
35
+ > {
36
+ definition: () => FleetDefinition;
37
+ secret: SecretsProvider;
38
+ home: string;
39
+ }
40
+
41
+ export function seedServices(roster: string): NodeSeedServices {
42
+ const { run, playbook, knownHosts } = lifecycleServices(roster);
43
+ return {
44
+ run,
45
+ playbook,
46
+ knownHosts,
47
+ definition: () => fleet,
48
+ secret,
49
+ home: homedir(),
50
+ };
51
+ }
52
+
53
+ /** Writes the first-boot seed of a declared worker to a private file: the
54
+ * seed carries a single-use auth key, so it is never printed. With --apply
55
+ * --yes the manager pins the host key the seed installs, so the new machine
56
+ * is trusted without a first-use step.
57
+ */
58
+ export async function fleetNodeSeed(
59
+ options: NodeSeedOptions,
60
+ services: NodeSeedServices
61
+ ): Promise<CommandOutcome> {
62
+ const worker = services
63
+ .definition()
64
+ .workers.find((entry) => entry.id === options.id);
65
+ if (!worker)
66
+ throw new FleetUsageError('--id must name a worker fleet.json declares.');
67
+ if (!options.output)
68
+ throw new FleetUsageError(
69
+ '--output PATH is required: the seed holds a single-use auth key and is never printed.'
70
+ );
71
+ const keyFile =
72
+ options.publicKey ?? join(services.home, '.ssh/id_ed25519.pub');
73
+ const managerKey = await readFile(keyFile, 'utf8').catch(() => {
74
+ throw new FleetUsageError(
75
+ `No manager public key at ${keyFile}; name one with --public-key PATH.`
76
+ );
77
+ });
78
+ const authKey = await services.secret('tailnet-auth-key');
79
+ if (!authKey)
80
+ throw new FleetUsageError(
81
+ 'The secrets provider holds no tailnet-auth-key. Store a short-lived, single-use, tagged Tailscale auth key there first.'
82
+ );
83
+ const release = ubuntuRelease(services.definition().ubuntu.release);
84
+ if (!release)
85
+ throw new FleetUsageError(
86
+ 'fleet.json names an Ubuntu release the support matrix does not list.'
87
+ );
88
+ const hostKey = await generateHostKey(worker.id, services.run);
89
+ const seed = renderSeed(
90
+ await seedUserData({
91
+ worker,
92
+ managerKey,
93
+ authKey,
94
+ hostKey,
95
+ tailscale: await tailscalePins(),
96
+ codename: release.codename,
97
+ }),
98
+ Boolean(options.autoinstall)
99
+ );
100
+ await writeFile(options.output, seed, { mode: 0o600 });
101
+ const host = await keyName(worker, services.run);
102
+ const publicKey = hostKey.public.split(' ').slice(0, 2).join(' ');
103
+ const pin: Check =
104
+ options.apply && options.yes
105
+ ? (await services.playbook('macos/worker-host-key.yml', {
106
+ host,
107
+ pin: 'present',
108
+ key: publicKey,
109
+ known_hosts: services.knownHosts,
110
+ }))
111
+ ? {
112
+ id: 'ssh:host-key',
113
+ status: 'pass',
114
+ summary: `The manager trusts the seed's host key for ${host}.`,
115
+ }
116
+ : {
117
+ id: 'ssh:host-key',
118
+ status: 'fail',
119
+ summary: 'SSH_HOST_KEY_NOT_CHANGED',
120
+ remedy: {
121
+ action:
122
+ 'The pinned Ansible could not change known_hosts; run the command again.',
123
+ },
124
+ }
125
+ : {
126
+ id: 'ssh:host-key',
127
+ status: 'skip',
128
+ summary: `Planned: pin the seed's host key for ${host}; run again with --apply --yes.`,
129
+ };
130
+ return {
131
+ exitCode: pin.status === 'fail' ? 1 : 0,
132
+ data: {
133
+ version: 1,
134
+ command: 'fleet-node-seed',
135
+ ok: pin.status !== 'fail',
136
+ worker: worker.id,
137
+ output: options.output,
138
+ format: options.autoinstall ? 'autoinstall' : 'cloud-init',
139
+ hostKey: publicKey,
140
+ checks: [pin],
141
+ },
142
+ text: [
143
+ `Wrote the ${options.autoinstall ? 'autoinstall' : 'cloud-init'} seed of ${worker.id} to ${options.output} (private; it holds a single-use auth key).`,
144
+ `Host key: ${publicKey}`,
145
+ `${pin.status.toUpperCase()} ${pin.id}: ${pin.summary}`,
146
+ 'Boot a stock Ubuntu image with it; once the machine is on the tailnet, add it with fleet-node-add and run devfleet fleet-sync --node ' +
147
+ worker.id +
148
+ '.',
149
+ ].join('\n'),
150
+ };
151
+ }