@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
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
2
2
  import { fleet as definition } from '../fleet-config';
3
3
  import { sshArgs, shellQuote } from '../ssh';
4
4
  import { resolveVaultConfig, usesVault } from '../vault-config';
5
+ import { workerTokenLocation } from '../worker-credentials';
5
6
  import type { Check, FleetNode, Runner } from '../types';
6
7
 
7
8
  const TOOL_PATH =
@@ -15,7 +16,11 @@ const terminal: TerminalRunner = (args) =>
15
16
 
16
17
  type VaultState = 'authenticated' | 'unauthenticated' | 'unavailable';
17
18
 
18
- /** Vault field holding the issued token for one worker node. */
19
+ /** The field of the fleet's shared document that held a worker's token
20
+ * before each worker had a document of its own (src/worker-credentials.ts).
21
+ * Read only when the worker's own document holds none, and reported: every
22
+ * worker that can read the shared document can read that field.
23
+ */
19
24
  export function workerTokenField(node: string): string {
20
25
  return `FLEET_WORKER_TOKEN_${node.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
21
26
  }
@@ -49,7 +54,13 @@ export async function authorizeManager(
49
54
  */
50
55
  export async function authorizeVault(
51
56
  node: FleetNode,
52
- options: { interactive: boolean },
57
+ options: {
58
+ interactive: boolean;
59
+ /** Opt-in: the fleet's shared Vault user, typed on the worker's own
60
+ * terminal, when no token of its own was issued.
61
+ */
62
+ passwordLogin?: boolean;
63
+ },
53
64
  run: Runner,
54
65
  attach: TerminalRunner = terminal
55
66
  ): Promise<Check[]> {
@@ -59,8 +70,10 @@ export async function authorizeVault(
59
70
  if (before === 'authenticated') return [pass(node)];
60
71
  // Fresh node: the locked toolchain (including vault) is not installed yet.
61
72
  if (before === 'unavailable') return [];
62
- if (await deliverWorkerToken(node, run)) return [pass(node)];
63
- if (!options.interactive) return [required(node)];
73
+ const delivered = await deliverWorkerToken(node, run);
74
+ if (delivered === 'own') return [pass(node)];
75
+ if (delivered === 'shared') return [pass(node), shared(node)];
76
+ if (!options.interactive || !options.passwordLogin) return [required(node)];
64
77
  process.stderr.write(
65
78
  `[${node.id}] No issued worker token found. Enter the Vault password to log this machine in.\n`
66
79
  );
@@ -72,30 +85,54 @@ export async function authorizeVault(
72
85
  ];
73
86
  }
74
87
 
88
+ /** Delivers the worker's token, from its own document or else from the
89
+ * field it used to have in the shared one; says which, or that none was.
90
+ */
75
91
  async function deliverWorkerToken(
76
92
  node: FleetNode,
77
93
  run: Runner
78
- ): Promise<boolean> {
79
- const { addr, mount, project, config } = resolveVaultConfig();
80
- const secret = await run(
81
- [
82
- 'vault',
83
- 'kv',
84
- 'get',
85
- `-address=${addr}`,
86
- `-mount=${mount}`,
87
- `-field=${workerTokenField(node.id)}`,
88
- `${project}/${config}`,
89
- ],
90
- { timeoutMs: 20_000 }
91
- );
92
- const token = secret.ok ? secret.stdout.trim() : '';
93
- if (!/^[\x21-\x7e]{16,4096}$/.test(token)) return false;
94
- const delivered = await run(
95
- remote(node, ['vault', 'login', `-address=${addr}`, '-no-print', '-']),
96
- { input: token + '\n', timeoutMs: 30_000 }
97
- );
98
- return delivered.ok && (await vaultState(node, run)) === 'authenticated';
94
+ ): Promise<'own' | 'shared' | null> {
95
+ const vault = resolveVaultConfig();
96
+ const own = workerTokenLocation(node.id, vault);
97
+ const sources = [
98
+ { from: 'own' as const, ...own },
99
+ {
100
+ from: 'shared' as const,
101
+ mount: vault.mount,
102
+ path: `${vault.project}/${vault.config}`,
103
+ field: workerTokenField(node.id),
104
+ },
105
+ ];
106
+ for (const source of sources) {
107
+ const secret = await run(
108
+ [
109
+ 'vault',
110
+ 'kv',
111
+ 'get',
112
+ `-address=${vault.addr}`,
113
+ `-mount=${source.mount}`,
114
+ `-field=${source.field}`,
115
+ source.path,
116
+ ],
117
+ { timeoutMs: 20_000 }
118
+ );
119
+ const token = secret.ok ? secret.stdout.trim() : '';
120
+ if (!/^[\x21-\x7e]{16,4096}$/.test(token)) continue;
121
+ const delivered = await run(
122
+ remote(node, [
123
+ 'vault',
124
+ 'login',
125
+ `-address=${vault.addr}`,
126
+ '-no-print',
127
+ '-',
128
+ ]),
129
+ { input: token + '\n', timeoutMs: 30_000 }
130
+ );
131
+ return delivered.ok && (await vaultState(node, run)) === 'authenticated'
132
+ ? source.from
133
+ : null;
134
+ }
135
+ return null;
99
136
  }
100
137
 
101
138
  async function vaultState(node: FleetNode, run: Runner): Promise<VaultState> {
@@ -153,6 +190,18 @@ function pass(node: FleetNode): Check {
153
190
  };
154
191
  }
155
192
 
193
+ function shared(node: FleetNode): Check {
194
+ const own = workerTokenLocation(node.id);
195
+ return {
196
+ id: 'auth:vault-isolation',
197
+ status: 'warn',
198
+ summary: 'VAULT_WORKER_TOKEN_SHARED',
199
+ remedy: {
200
+ action: `${node.id}'s token was read from the fleet's shared document, which every worker can read. Issue it a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes (kept at ${own.mount}/${own.path}), then remove ${workerTokenField(node.id)} from the shared document.`,
201
+ },
202
+ };
203
+ }
204
+
156
205
  function required(node: FleetNode): Check {
157
206
  const { addr } = resolveVaultConfig();
158
207
  return {
@@ -160,7 +209,7 @@ function required(node: FleetNode): Check {
160
209
  status: 'fail',
161
210
  summary: 'VAULT_LOGIN_REQUIRED',
162
211
  remedy: {
163
- action: `Run devfleet fleet-sync in a terminal, or store this node's token in the shared Vault document as ${workerTokenField(node.id)}. Manual fallback on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
212
+ action: `Issue ${node.id} a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes. Opt-in fallback: devfleet fleet-sync --vault-password-login in a terminal, or on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
164
213
  },
165
214
  };
166
215
  }
@@ -1,3 +1,4 @@
1
+ import type { ManagerReport } from '../manager-sync';
1
2
  import { localCheckoutRevision } from '../machine/local-source';
2
3
  import {
3
4
  definitionSource,
@@ -31,6 +32,8 @@ import {
31
32
  DIVERGED_REMEDY,
32
33
  mutateState,
33
34
  refresh,
35
+ leaseConflict,
36
+ leaseHeldMessage,
34
37
  stateContext,
35
38
  type Standing,
36
39
  } from '../state/sync';
@@ -61,6 +64,14 @@ export interface FleetSyncOptions {
61
64
  json?: boolean;
62
65
  nonInteractive?: boolean;
63
66
  node?: string[];
67
+ /** The worker to prove a revision new to the fleet on first, when it is
68
+ * reachable; overrides `canary` in fleet.json.
69
+ */
70
+ canary?: string;
71
+ /** Opt-in: a worker without a token of its own may be logged in with the
72
+ * fleet's shared Vault user, typed on its own terminal.
73
+ */
74
+ vaultPasswordLogin?: boolean;
64
75
  all?: boolean;
65
76
  schedule?: ScheduleAction;
66
77
  /** `accounts`: only the account-derived configuration, on every node. */
@@ -72,6 +83,8 @@ export interface FleetSyncOptions {
72
83
  /** Internal: set for interactive terminal runs (sudo enrollment prompts). */
73
84
  interactiveAuthorization?: boolean;
74
85
  expectedPlanFingerprint?: string;
86
+ /** Take the manager lease from another manager before it expired. */
87
+ takeOver?: boolean;
75
88
  }
76
89
  export function machineRoot(): string {
77
90
  const root = join(homedir(), '.local/share/personal/fleet/manager');
@@ -140,6 +153,31 @@ export interface ManagerSync {
140
153
  restart: () => Promise<CommandOutcome>;
141
154
  }
142
155
 
156
+ /** Why the fleet state stops this run before any machine or the state is
157
+ * changed, if it does.
158
+ */
159
+ function stateRefusal(
160
+ standing: Standing,
161
+ options: FleetSyncOptions,
162
+ run: Runner,
163
+ revision: string
164
+ ): CommandOutcome | null {
165
+ if (standing === 'diverged')
166
+ return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
167
+ // One manager at a time: another one's lease stops this run.
168
+ const held = leaseConflict({
169
+ ...stateContext(run),
170
+ takeOver: options.takeOver === true,
171
+ });
172
+ return held
173
+ ? actionRequired(
174
+ 'FLEET_MANAGER_LEASE_HELD',
175
+ leaseHeldMessage(held),
176
+ revision
177
+ )
178
+ : null;
179
+ }
180
+
143
181
  /** fleet-sync on the machine already known to be the manager. */
144
182
  export async function managerSync(
145
183
  options: FleetSyncOptions,
@@ -177,8 +215,8 @@ export async function managerSync(
177
215
  // version of the configuration, the roster and the approval, not from a
178
216
  // stale local copy. Refreshing never uploads.
179
217
  const fresh = await refresh(stateContext(run));
180
- if (fresh.standing === 'diverged')
181
- return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
218
+ const refused = stateRefusal(fresh.standing, options, run, revision);
219
+ if (refused) return refused;
182
220
  if (fresh.replaced) {
183
221
  // Everything read so far came from the stale copy, the manager's own
184
222
  // identity included.
@@ -274,7 +312,7 @@ function applyLocked(
274
312
  ): Promise<CommandOutcome> {
275
313
  const { settings, run, revision } = context;
276
314
  const record = runRecord(state, revision, {
277
- context: stateContext(run),
315
+ context: { ...stateContext(run), takeOver: request.takeOver === true },
278
316
  standing: context.standing,
279
317
  ...(definitionSource().kind === 'file'
280
318
  ? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
@@ -287,7 +325,7 @@ function applyLocked(
287
325
  { ...request, rolledOut: state?.appliedRevision === revision },
288
326
  settings,
289
327
  run,
290
- (report) => record.settle(report.ok)
328
+ (report) => record.settle(report.ok, appliedRevisions(report))
291
329
  );
292
330
  } finally {
293
331
  await record.abandon();
@@ -327,7 +365,7 @@ async function offManager(
327
365
  if (forwardingDisabled())
328
366
  return actionRequired(
329
367
  'FLEET_NOT_MANAGER',
330
- `fleet-sync runs only on the manager (${definition.manager.id}); nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
368
+ `fleet-sync runs only on the manager (${definition.manager.id}), which is always a macOS machine; nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
331
369
  revision ?? null
332
370
  );
333
371
  const runScript = revision
@@ -491,3 +529,14 @@ async function selectedRevision(
491
529
  approved
492
530
  );
493
531
  }
532
+
533
+ /** The revision each worker holds after this run, for the fleet state: a
534
+ * worker that converged keeps its progress even when the fleet is not ready.
535
+ */
536
+ function appliedRevisions(report: ManagerReport): Record<string, string> {
537
+ return Object.fromEntries(
538
+ report.nodes
539
+ .filter((node) => node.role !== 'manager' && node.appliedRevision)
540
+ .map((node) => [node.node, node.appliedRevision!])
541
+ );
542
+ }
@@ -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
+ }