@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.
@@ -0,0 +1,239 @@
1
+ import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import {
5
+ DefinitionError,
6
+ parseDefinition,
7
+ type DefinitionProblem,
8
+ type FleetDefinition,
9
+ } from '../definition';
10
+ import { FleetUsageError } from '../errors';
11
+ import { PACKAGE_ROOT } from '../package-info';
12
+ import { ansibleCommand } from '../platform/ansible';
13
+ import { runCommand } from '../runner';
14
+ import type { Check, Runner } from '../types';
15
+ import type { CommandOutcome } from './contracts';
16
+
17
+ export interface FleetValidateOptions {
18
+ file?: string;
19
+ ansible?: boolean;
20
+ json?: boolean;
21
+ }
22
+
23
+ export interface FleetValidateServices {
24
+ run: Runner;
25
+ env: NodeJS.ProcessEnv;
26
+ platform: NodeJS.Platform;
27
+ /** The package's Ansible playbooks, relative to `root`. */
28
+ root: string;
29
+ }
30
+
31
+ const SERVICES: FleetValidateServices = {
32
+ run: runCommand,
33
+ env: process.env,
34
+ platform: process.platform,
35
+ root: PACKAGE_ROOT,
36
+ };
37
+
38
+ /** The playbooks a definition must be able to drive, in the package. */
39
+ const PLAYBOOK_DIRECTORIES = ['ansible/ubuntu', 'ansible/macos'];
40
+
41
+ /** The Ansible inventory a definition describes: the manager, and every
42
+ * worker in the `workers` group in the order it is declared. Host names are
43
+ * the fleet ids; a machine is reached as fleet-sync reaches it.
44
+ */
45
+ export function fleetInventory(definition: FleetDefinition): unknown {
46
+ const workers = Object.fromEntries(
47
+ definition.workers.map((worker) => [
48
+ worker.id,
49
+ {
50
+ ansible_host: worker.ssh,
51
+ ...(worker.network
52
+ ? {
53
+ ansible_user: worker.network.user,
54
+ ansible_port: worker.network.sshPort,
55
+ }
56
+ : {}),
57
+ },
58
+ ])
59
+ );
60
+ return {
61
+ all: {
62
+ children: {
63
+ manager: {
64
+ hosts: {
65
+ [definition.manager.id]: {
66
+ ansible_connection: 'local',
67
+ ansible_user: definition.manager.network.user,
68
+ },
69
+ },
70
+ },
71
+ workers: { hosts: workers },
72
+ },
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Reads and judges one fleet.json. Nothing is contacted: with `--ansible`
78
+ * the pinned Ansible renders the inventory and checks the playbooks' syntax
79
+ * on this machine.
80
+ */
81
+ export async function fleetValidate(
82
+ options: FleetValidateOptions,
83
+ services: FleetValidateServices = SERVICES
84
+ ): Promise<CommandOutcome> {
85
+ const path = options.file ?? services.env['DEVFLEET_CONFIG'];
86
+ if (!path)
87
+ throw new FleetUsageError(
88
+ 'No fleet configuration to validate. Run devfleet from the project that holds your fleet.json, or pass --file PATH.'
89
+ );
90
+ let definition: FleetDefinition;
91
+ try {
92
+ definition = parseDefinition(await readJson(path));
93
+ } catch (error) {
94
+ if (error instanceof DefinitionError) return invalid(path, error.problem);
95
+ throw error;
96
+ }
97
+ const checks: Check[] = [
98
+ {
99
+ id: 'definition:schema',
100
+ status: 'pass',
101
+ summary: `${path} is a valid fleet definition: a manager and ${definition.workers.length} worker(s).`,
102
+ },
103
+ ];
104
+ if (options.ansible)
105
+ checks.push(...(await ansibleChecks(definition, services)));
106
+ const ok = checks.every((check) => check.status !== 'fail');
107
+ return {
108
+ exitCode: ok ? 0 : 1,
109
+ data: { version: 1, command: 'fleet-validate', ok, path, checks },
110
+ text: checks
111
+ .map(
112
+ (check) => `${check.status.toUpperCase()} ${check.id}: ${check.summary}`
113
+ )
114
+ .join('\n'),
115
+ };
116
+ }
117
+
118
+ async function readJson(path: string): Promise<unknown> {
119
+ let text: string;
120
+ try {
121
+ text = await readFile(path, 'utf8');
122
+ } catch {
123
+ throw new DefinitionError({
124
+ code: 'DEFINITION_UNREADABLE',
125
+ path: '$',
126
+ expected: 'a readable file',
127
+ actual: 'no file',
128
+ });
129
+ }
130
+ try {
131
+ return JSON.parse(text) as unknown;
132
+ } catch {
133
+ throw new DefinitionError({
134
+ code: 'DEFINITION_UNREADABLE',
135
+ path: '$',
136
+ expected: 'JSON',
137
+ actual: 'text that is not JSON',
138
+ });
139
+ }
140
+ }
141
+
142
+ function invalid(path: string, problem: DefinitionProblem): CommandOutcome {
143
+ const data = {
144
+ version: 1,
145
+ command: 'fleet-validate',
146
+ ok: false,
147
+ path,
148
+ problem,
149
+ };
150
+ return {
151
+ exitCode: 1,
152
+ data,
153
+ text: `${problem.code} at ${problem.path}: expected ${problem.expected}, found ${problem.actual}.`,
154
+ };
155
+ }
156
+
157
+ /** Renders the inventory, lists it with `ansible-inventory` and checks the
158
+ * syntax of every playbook against it.
159
+ */
160
+ async function ansibleChecks(
161
+ definition: FleetDefinition,
162
+ services: FleetValidateServices
163
+ ): Promise<Check[]> {
164
+ const directory = await mkdtemp(join(tmpdir(), 'devfleet-validate-'));
165
+ const inventory = join(directory, 'inventory.json');
166
+ try {
167
+ await writeFile(inventory, JSON.stringify(fleetInventory(definition)), {
168
+ mode: 0o600,
169
+ });
170
+ const playbook = ansibleCommand(services.platform, services.root);
171
+ const tool = (name: string) => [...playbook.slice(0, -1), name];
172
+ const listed = await services.run(
173
+ [...tool('ansible-inventory'), '-i', inventory, '--list'],
174
+ { timeoutMs: 120_000 }
175
+ );
176
+ const checks: Check[] = [
177
+ listed.ok
178
+ ? {
179
+ id: 'definition:inventory',
180
+ status: 'pass',
181
+ summary: 'Ansible reads the inventory the definition describes.',
182
+ }
183
+ : {
184
+ id: 'definition:inventory',
185
+ status: 'fail',
186
+ summary: 'DEFINITION_INVENTORY_REJECTED',
187
+ remedy: {
188
+ action:
189
+ 'Run devfleet fleet-validate --ansible on the manager after fleet-sync installed the pinned Ansible; report the failure as an issue if it persists.',
190
+ },
191
+ },
192
+ ];
193
+ const failed: string[] = [];
194
+ for (const file of await playbooks(services.root)) {
195
+ const result = await services.run(
196
+ [
197
+ ...playbook,
198
+ '-i',
199
+ inventory,
200
+ '--syntax-check',
201
+ join(services.root, file),
202
+ ],
203
+ { timeoutMs: 120_000 }
204
+ );
205
+ if (!result.ok) failed.push(file);
206
+ }
207
+ checks.push(
208
+ failed.length
209
+ ? {
210
+ id: 'definition:playbooks',
211
+ status: 'fail',
212
+ summary: `DEFINITION_PLAYBOOK_SYNTAX: ${failed.join(', ')}`,
213
+ remedy: {
214
+ action:
215
+ 'The installed dev-fleet package carries a playbook Ansible cannot parse; report it as an issue.',
216
+ },
217
+ }
218
+ : {
219
+ id: 'definition:playbooks',
220
+ status: 'pass',
221
+ summary: 'Every playbook passes ansible-playbook --syntax-check.',
222
+ }
223
+ );
224
+ return checks;
225
+ } finally {
226
+ await rm(directory, { recursive: true, force: true });
227
+ }
228
+ }
229
+
230
+ /** Every top-level playbook the fleet applies, sorted. */
231
+ async function playbooks(root: string): Promise<string[]> {
232
+ const found: string[] = [];
233
+ for (const directory of PLAYBOOK_DIRECTORIES) {
234
+ const entries = await readdir(join(root, directory)).catch(() => []);
235
+ for (const entry of entries)
236
+ if (entry.endsWith('.yml')) found.push(join(directory, entry));
237
+ }
238
+ return found.sort();
239
+ }
@@ -13,6 +13,23 @@ import { fleetWatch, watchServices } from './fleet-watch';
13
13
  import type { NodeReport } from '../types';
14
14
  import { registerGithubVerify } from './github-verify';
15
15
  import { fleetRestore, type FleetRestoreOptions } from './fleet-restore';
16
+ import { fleetValidate, type FleetValidateOptions } from './fleet-validate';
17
+ import {
18
+ fleetManifest,
19
+ manifestServices,
20
+ type FleetManifestOptions,
21
+ } from './fleet-manifest';
22
+ import {
23
+ fleetNodeSeed,
24
+ seedServices,
25
+ type NodeSeedOptions,
26
+ } from './fleet-node-seed';
27
+ import {
28
+ fleetNodeReplace,
29
+ fleetNodeRetire,
30
+ lifecycleServices,
31
+ type NodeLifecycleOptions,
32
+ } from './fleet-node-lifecycle';
16
33
 
17
34
  type Complete = (outcome: CommandOutcome) => void;
18
35
 
@@ -187,6 +204,14 @@ function registerSync({ program, defaults, complete }: Registration) {
187
204
  collect,
188
205
  []
189
206
  )
207
+ .option(
208
+ '--vault-password-login',
209
+ 'Opt-in: log a worker without a token of its own in with the shared Vault user, on its own terminal'
210
+ )
211
+ .option(
212
+ '--canary <id>',
213
+ 'The worker that proves a new revision first when it is reachable; defaults to canary in fleet.json, then the first worker'
214
+ )
190
215
  .option(
191
216
  '--all',
192
217
  'Explicit spelling of the default enrolled fleet scope; never discovered peers'
@@ -362,6 +387,114 @@ function registerRestore({ program, complete }: Registration) {
362
387
  });
363
388
  }
364
389
 
390
+ function registerValidate({ program, complete }: Registration) {
391
+ program
392
+ .command('fleet-validate')
393
+ .description(
394
+ 'Validate fleet.json and name the field that breaks a rule; contacts nothing'
395
+ )
396
+ .option(
397
+ '--file <path>',
398
+ 'The fleet.json to validate; defaults to the project’s'
399
+ )
400
+ .option(
401
+ '--ansible',
402
+ 'Also render the Ansible inventory and syntax-check every playbook with the pinned Ansible'
403
+ )
404
+ .action(async (_options, command: Command) => {
405
+ complete(
406
+ await fleetValidate(command.optsWithGlobals<FleetValidateOptions>())
407
+ );
408
+ });
409
+ }
410
+
411
+ function registerLifecycle({ program, defaults, complete }: Registration) {
412
+ const action =
413
+ (name: 'replace' | 'retire') =>
414
+ async (_options: unknown, command: Command) => {
415
+ const options = command.optsWithGlobals<
416
+ NodeLifecycleOptions & CliOptions
417
+ >();
418
+ const settings = await withSettings(defaults, options);
419
+ const services = lifecycleServices(settings.config);
420
+ complete(
421
+ name === 'replace'
422
+ ? await fleetNodeReplace(options, services)
423
+ : await fleetNodeRetire(options, services)
424
+ );
425
+ };
426
+ program
427
+ .command('fleet-node-replace')
428
+ .description(
429
+ 'Put a fresh machine in place of a worker under the same id; writes need --apply --yes'
430
+ )
431
+ .requiredOption('--id <name>', 'The declared worker being replaced')
432
+ .option(
433
+ '--host-key <key>',
434
+ 'The new machine’s SSH host key, "TYPE BASE64", verified out of band or printed by fleet-node-seed'
435
+ )
436
+ .option('--apply', 'Change the manager and Vault; requires --yes')
437
+ .option('--yes', 'Confirm an explicitly requested --apply operation')
438
+ .action(action('replace'));
439
+ program
440
+ .command('fleet-node-retire')
441
+ .description(
442
+ 'Take a worker out of the fleet: revoke its token, forget its host key and roster entry; writes need --apply --yes'
443
+ )
444
+ .requiredOption('--id <name>', 'The declared worker to retire')
445
+ .option(
446
+ '--apply',
447
+ 'Change the manager, Vault and the fleet state; requires --yes'
448
+ )
449
+ .option('--yes', 'Confirm an explicitly requested --apply operation')
450
+ .action(action('retire'));
451
+ program
452
+ .command('fleet-node-seed')
453
+ .description(
454
+ 'Write cloud-init (or autoinstall) user data a new worker joins the fleet with by itself'
455
+ )
456
+ .requiredOption('--id <name>', 'The declared worker to seed')
457
+ .requiredOption(
458
+ '--output <path>',
459
+ 'Where to write the seed (mode 600); it holds a single-use auth key and is never printed'
460
+ )
461
+ .option(
462
+ '--public-key <path>',
463
+ 'The manager public key to authorize; default ~/.ssh/id_ed25519.pub'
464
+ )
465
+ .option('--autoinstall', 'An Ubuntu autoinstall file, for bare metal')
466
+ .option(
467
+ '--apply',
468
+ 'Also pin the seed host key on this manager; requires --yes'
469
+ )
470
+ .option('--yes', 'Confirm an explicitly requested --apply operation')
471
+ .action(async (_options: unknown, command: Command) => {
472
+ const options = command.optsWithGlobals<NodeSeedOptions & CliOptions>();
473
+ const settings = await withSettings(defaults, options);
474
+ complete(await fleetNodeSeed(options, seedServices(settings.config)));
475
+ });
476
+ }
477
+
478
+ function registerManifest({ program, defaults, complete }: Registration) {
479
+ program
480
+ .command('fleet-manifest')
481
+ .description(
482
+ 'Measure what a worker is made of and check it against the pins and a fresh build'
483
+ )
484
+ .requiredOption('--id <name>', 'The enrolled worker to measure')
485
+ .option(
486
+ '--reference <path>',
487
+ 'A fleet-manifest --json of a fresh build of the same revision'
488
+ )
489
+ .action(async (_options: unknown, command: Command) => {
490
+ const options = command.optsWithGlobals<
491
+ FleetManifestOptions & CliOptions
492
+ >();
493
+ const settings = await withSettings(defaults, options);
494
+ complete(await fleetManifest(options, manifestServices(settings.config)));
495
+ });
496
+ }
497
+
365
498
  export function registerFleet(
366
499
  program: Command,
367
500
  defaults: Settings,
@@ -379,6 +512,9 @@ export function registerFleet(
379
512
  registerReport(registration);
380
513
  registerWatch(registration);
381
514
  registerRestore(registration);
515
+ registerValidate(registration);
516
+ registerLifecycle(registration);
517
+ registerManifest(registration);
382
518
  registerRepair(registration);
383
519
  registerNodes(registration);
384
520
  registerAccounts(registration);
@@ -116,6 +116,18 @@ export const COMMANDS: CommandSpec[] = [
116
116
  ['manager', 'restore', 'replace', 'lost', 'recovery'],
117
117
  true,
118
118
  ],
119
+ [
120
+ 'fleet-validate',
121
+ 'Validate the fleet configuration',
122
+ 'Read-only: judge fleet.json without contacting anything. An invalid file names the first rule it breaks as {code, path, expected, actual}. --ansible also renders the Ansible inventory and syntax-checks every playbook with the pinned Ansible.',
123
+ ['validate', 'lint', 'schema', 'fleet.json', 'configuration', 'ansible'],
124
+ ],
125
+ [
126
+ 'fleet-manifest',
127
+ 'Measure a worker and compare it with a fresh build',
128
+ 'Read-only over verified SSH: every installed package, the active mise tools and the state of each always-on service, with a digest that is the same for two workers built from the same revision. The check worker:manifest reports every difference from the declared pins and, with --reference, from a fresh build.',
129
+ ['manifest', 'drift', 'reproducible', 'rebuild', 'packages', 'audit'],
130
+ ],
119
131
  [
120
132
  'fleet-node-sync',
121
133
  'Sync this node',
@@ -181,6 +193,27 @@ export const COMMANDS: CommandSpec[] = [
181
193
  ['github', 'verify', 'pull request', 'push', 'permissions', 'workflow'],
182
194
  true,
183
195
  ],
196
+ [
197
+ 'fleet-node-replace',
198
+ 'Replace a worker with a fresh machine',
199
+ 'Preview, or with --apply --yes carry out, putting a fresh machine in place of a declared worker under the same id: verify the new device on the tailnet, pin its host key (--host-key), bind the roster entry to it and issue it a Vault token of its own, revoking the old one. Then fleet-sync --node ID converges it. Running it again changes nothing.',
200
+ ['replace', 'rebuild', 'reinstall', 'host key', 'token', 'device'],
201
+ true,
202
+ ],
203
+ [
204
+ 'fleet-node-seed',
205
+ 'Seed a new worker that joins by itself',
206
+ 'Write cloud-init user data (or an Ubuntu autoinstall file with --autoinstall) for a declared worker to a private file: its account with the manager key, the privilege rule, a host key generated for it, the pinned Tailscale and a short-lived single-use auth key from the secrets provider (tailnet-auth-key). --apply --yes also pins that host key on the manager, so there is no trust on first use.',
207
+ ['seed', 'cloud-init', 'autoinstall', 'new', 'bootstrap', 'provision'],
208
+ true,
209
+ ],
210
+ [
211
+ 'fleet-node-retire',
212
+ 'Retire a worker',
213
+ 'Preview, or with --apply --yes carry out, taking a worker out of the fleet: revoke its Vault token, forget its pinned host key, remove its roster entry and record a tombstone in the fleet state. Then remove it from fleet.json.',
214
+ ['retire', 'remove', 'decommission', 'revoke', 'delete'],
215
+ true,
216
+ ],
184
217
  ]),
185
218
  ...group('T3', [
186
219
  [