@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,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
+ }
@@ -104,9 +104,11 @@ export function forwardedFlags(
104
104
  ): string[] {
105
105
  const flags = revision ? ['--revision', revision] : [];
106
106
  for (const node of options.node ?? []) flags.push('--node', node);
107
+ if (options.canary) flags.push('--canary', options.canary);
107
108
  if (options.schedule) flags.push('--schedule', options.schedule);
108
109
  const switches: [boolean | undefined, string][] = [
109
110
  [options.all, '--all'],
111
+ [options.vaultPasswordLogin, '--vault-password-login'],
110
112
  [options.dryRun, '--dry-run'],
111
113
  [options.apply, '--apply'],
112
114
  [options.yes, '--yes'],
@@ -13,7 +13,7 @@ import { IDES } from '../ide';
13
13
  import { enabled, managerChecks } from '../ide/orchestrate';
14
14
  import { idesOf } from '../ide/definition';
15
15
  import { convergeGitIdentity, gitChecks } from '../git-identity';
16
- import { ansibleCommand } from '../platform/ansible';
16
+ import { ansibleCommand, runAnsible } from '../platform/ansible';
17
17
  import { convergeGithub } from '../github/converge';
18
18
  import { PACKAGE_ROOT } from '../package-info';
19
19
  import { managerHealth } from '../manager-health';
@@ -47,7 +47,11 @@ export async function convergeManager(
47
47
  if (identity.status === 'pass') {
48
48
  // Launchers and scheduled jobs on the manager read the installed copy.
49
49
  if (input.apply) installConfig();
50
- checks.push(await managerPower(run, input.workspace, host));
50
+ checks.push(
51
+ input.apply
52
+ ? await holdManagerPower(run, input.workspace, host)
53
+ : await managerPower(run, input.workspace, host)
54
+ );
51
55
  checks.push(await managerBootRecovery(run, input.workspace, host));
52
56
  checks.push(await managerDocker(run));
53
57
  // Each enabled IDE: its settings, pinned version and policy.
@@ -202,7 +206,8 @@ export async function managerAgentProfiles(
202
206
  export async function managerIdentity(
203
207
  id: string,
204
208
  run: Runner,
205
- host: ManagerHost = MACOS_MANAGER
209
+ host: ManagerHost = MACOS_MANAGER,
210
+ platform: NodeJS.Platform = process.platform
206
211
  ): Promise<Check> {
207
212
  const local = (await host.localHostName(run)) ?? '';
208
213
  return local === id
@@ -216,7 +221,10 @@ export async function managerIdentity(
216
221
  status: 'fail',
217
222
  summary: 'FLEET_NOT_MANAGER',
218
223
  remedy: {
219
- action: `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`,
224
+ action:
225
+ platform === 'darwin'
226
+ ? `Run fleet-sync on the manager (${id}). Workers are never controlled from another worker; change the manager only by editing manager in fleet.json and moving its singleton services.`
227
+ : `This machine runs ${platform}, and the manager is always a macOS machine. Run fleet-sync on the manager (${id}), or from a worker that can SSH to it.`,
220
228
  },
221
229
  };
222
230
  }
@@ -244,7 +252,7 @@ export async function managerPower(
244
252
  ? 'MANAGER_SLEEPS_ON_POWER'
245
253
  : `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
246
254
  remedy: {
247
- action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Apply the manager power policy once, as an administrator: cd ${workspace} && ${ansibleCommand().join(' ')} -K ansible/macos/manager.yml`,
255
+ action: `Workers lose the CLI proxy and Beszel hub while the manager sleeps. Grant the manager its power settings once, as an administrator; every fleet-sync then applies the policy with no prompt: cd ${workspace} && ${ansibleCommand().join(' ')} -e user=$(id -un) -K ansible/macos/manager-privilege.yml`,
248
256
  },
249
257
  };
250
258
  }
@@ -283,6 +291,43 @@ export async function managerBootRecovery(
283
291
  };
284
292
  }
285
293
 
294
+ /** Whether the one grant manager-privilege.yml installs is in place: this
295
+ * user may run `pmset -c` without a password. Lists, never runs.
296
+ */
297
+ async function powerGranted(run: Runner): Promise<boolean> {
298
+ return (
299
+ await run(['sudo', '-n', '-l', '/usr/bin/pmset', '-c', 'sleep', '0'], {
300
+ timeoutMs: 10_000,
301
+ })
302
+ ).ok;
303
+ }
304
+
305
+ /** On every applying sync: a drifted power policy is applied with
306
+ * manager.yml when the grant is in place, then measured again. Without the
307
+ * grant the check says how to give it, once.
308
+ */
309
+ export async function holdManagerPower(
310
+ run: Runner,
311
+ workspace = '.',
312
+ host: ManagerHost = MACOS_MANAGER,
313
+ { root = PACKAGE_ROOT, scratch }: { root?: string; scratch?: string } = {}
314
+ ): Promise<Check> {
315
+ const before = await managerPower(run, workspace, host);
316
+ if (
317
+ before.status === 'pass' ||
318
+ host.platform !== 'macOS' ||
319
+ !(await powerGranted(run))
320
+ )
321
+ return before;
322
+ await runAnsible(
323
+ run,
324
+ join(root, 'ansible/macos/manager.yml'),
325
+ { input: '{}', timeoutMs: 120_000 },
326
+ scratch
327
+ );
328
+ return managerPower(run, workspace, host);
329
+ }
330
+
286
331
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
287
332
  export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
288
333
  const { vars } = Bun.TOML.parse(
@@ -103,16 +103,26 @@ export async function selected(
103
103
  return outcome(report, planFingerprint, started);
104
104
  }
105
105
 
106
- /** A revision new to the fleet is proven on the first declared worker before
107
- * the rest; repeating one that already converged everywhere needs no canary,
108
- * so every worker converges at once.
106
+ /** A revision new to the fleet is proven on one worker before the rest: the
107
+ * one `--canary` or fleet.json names, else the first declared worker, and
108
+ * the next reachable one when that cannot be reached. Repeating a revision
109
+ * that already converged everywhere needs no canary, so every worker
110
+ * converges at once.
109
111
  */
110
112
  function canary(
111
113
  canonical: FleetDefinition,
112
114
  options: FleetSyncOptions
113
115
  ): { canary?: string } {
114
- const first = canonical.workers[0];
115
- return first && !options.rolledOut ? { canary: first.id } : {};
116
+ if (
117
+ options.canary &&
118
+ !canonical.workers.some((worker) => worker.id === options.canary)
119
+ )
120
+ throw new FleetUsageError(
121
+ '--canary names a worker fleet.json does not declare.'
122
+ );
123
+ const preferred =
124
+ options.canary ?? canonical.canary ?? canonical.workers[0]?.id;
125
+ return preferred && !options.rolledOut ? { canary: preferred } : {};
116
126
  }
117
127
 
118
128
  /** Saves the full JSON report, then renders the short human summary. */
@@ -165,6 +175,7 @@ function deployWithMonitoring(
165
175
  /** The manager's checkout, and the roster an identity is recorded in. */
166
176
  workspace: string;
167
177
  roster: string;
178
+ passwordLogin: boolean;
168
179
  }
169
180
  ): DeployNode {
170
181
  return async (node, revision) => {
@@ -189,7 +200,10 @@ function deployWithMonitoring(
189
200
  const checks = await authorizePrivilege(node, payload.authorizeSudo, run);
190
201
  if (checks.length) return { node: node.id, checks };
191
202
  }
192
- const vault = { interactive: ui.interactiveAuthorization };
203
+ const vault = {
204
+ interactive: ui.interactiveAuthorization,
205
+ passwordLogin: ui.passwordLogin,
206
+ };
193
207
  if (ui.interactiveAuthorization) ui.progress.stop();
194
208
  const before = await authorizeVault(node, vault, run);
195
209
  ui.progress.step(node.id, 'Converging (remote lock, install, verify)');
@@ -242,6 +256,7 @@ function scopeDefinition(canonical: FleetDefinition, ids?: string[]) {
242
256
  function interfaceOptions(options: FleetSyncOptions) {
243
257
  return {
244
258
  progress: syncProgress(!options.json),
259
+ passwordLogin: Boolean(options.vaultPasswordLogin),
245
260
  interactiveAuthorization: Boolean(
246
261
  options.interactiveAuthorization &&
247
262
  !options.json &&
@@ -21,8 +21,11 @@ import type { Check } from '../types';
21
21
  export interface RunRecord {
22
22
  /** Before any machine changes: this revision is the approved one. */
23
23
  approve(): void;
24
- /** Once the result is known. The check says whether the bucket holds it. */
25
- settle(converged: boolean): Promise<Check>;
24
+ /** Once the result is known. The check says whether the bucket holds it.
25
+ * `applied` is the revision each worker that converged now holds: it is
26
+ * kept even when the fleet as a whole is not ready.
27
+ */
28
+ settle(converged: boolean, applied?: Record<string, string>): Promise<Check>;
26
29
  /** For a run that stopped before its result: its approval is still uploaded. */
27
30
  abandon(): Promise<void>;
28
31
  }
@@ -70,12 +73,18 @@ export function runRecord(
70
73
  }
71
74
  claimLease(state.context);
72
75
  },
73
- async settle(converged) {
76
+ async settle(converged, applied = {}) {
74
77
  settled = true;
78
+ const workers = Object.keys(applied).length
79
+ ? { workers: { ...previous?.workers, ...applied } }
80
+ : {};
81
+ if (!converged && workers.workers)
82
+ storeEnrollment({ ...approval, ...workers }, env);
75
83
  if (converged)
76
84
  storeEnrollment(
77
85
  {
78
86
  ...approval,
87
+ ...workers,
79
88
  appliedRevision: revision,
80
89
  ...(previous?.appliedRevision &&
81
90
  previous.appliedRevision !== revision
@@ -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,
@@ -63,6 +64,14 @@ export interface FleetSyncOptions {
63
64
  json?: boolean;
64
65
  nonInteractive?: boolean;
65
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;
66
75
  all?: boolean;
67
76
  schedule?: ScheduleAction;
68
77
  /** `accounts`: only the account-derived configuration, on every node. */
@@ -316,7 +325,7 @@ function applyLocked(
316
325
  { ...request, rolledOut: state?.appliedRevision === revision },
317
326
  settings,
318
327
  run,
319
- (report) => record.settle(report.ok)
328
+ (report) => record.settle(report.ok, appliedRevisions(report))
320
329
  );
321
330
  } finally {
322
331
  await record.abandon();
@@ -356,7 +365,7 @@ async function offManager(
356
365
  if (forwardingDisabled())
357
366
  return actionRequired(
358
367
  'FLEET_NOT_MANAGER',
359
- `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.`,
360
369
  revision ?? null
361
370
  );
362
371
  const runScript = revision
@@ -520,3 +529,14 @@ async function selectedRevision(
520
529
  approved
521
530
  );
522
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
+ }