@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,205 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { parseDefinition, type FleetDefinition } from '../definition';
4
+ import { definitionSource } from '../fleet-config';
5
+ import { managerHost } from '../platform/manager-host';
6
+ import { makeReport, renderReport, reportExitCode } from '../report';
7
+ import { runCommand } from '../runner';
8
+ import { connection } from '../state/connection';
9
+ import { managerHolder } from '../state/holder';
10
+ import { stateRoot } from '../state/paths';
11
+ import { pull, standing, type StateContext } from '../state/sync';
12
+ import type { Check, Runner } from '../types';
13
+ import type { CommandOutcome } from './contracts';
14
+
15
+ export interface FleetRestoreOptions {
16
+ dryRun?: boolean;
17
+ json?: boolean;
18
+ }
19
+
20
+ export interface FleetRestoreServices {
21
+ run: Runner;
22
+ env: NodeJS.ProcessEnv;
23
+ home: string;
24
+ holder?: string | null;
25
+ }
26
+
27
+ /** What a replacement manager gets back, in the order it is restored. */
28
+ export const RESTORES = [
29
+ {
30
+ what: 'Fleet state (roster, approved revision, configuration last applied)',
31
+ how: 'Downloaded from the bucket by this command.',
32
+ },
33
+ {
34
+ what: 'Machine policy, toolchain and checkouts',
35
+ how: 'Rebuilt from Git by devfleet fleet-sync.',
36
+ },
37
+ {
38
+ what: 'CLI proxy state (provider logins, keys, Tailscale identity)',
39
+ how: 'Restored from its encrypted snapshots by devfleet cliproxy install (vault secrets provider only).',
40
+ },
41
+ {
42
+ what: 'Monitoring hub',
43
+ how: 'Recreated by devfleet fleet-sync with its accounts and systems; metric history is lost.',
44
+ },
45
+ {
46
+ what: 'T3 ownership record',
47
+ how: 'Adopted again with devfleet t3-apply --import ID.',
48
+ },
49
+ ];
50
+
51
+ const check = (
52
+ id: string,
53
+ ok: boolean,
54
+ [pass, failure, action, missing = 'fail']: [
55
+ string,
56
+ string,
57
+ string,
58
+ ('fail' | 'warn')?,
59
+ ]
60
+ ): Check =>
61
+ ok
62
+ ? { id, status: 'pass', summary: pass }
63
+ : { id, status: missing, summary: failure, remedy: { action } };
64
+
65
+ function definition(services: FleetRestoreServices): FleetDefinition | null {
66
+ const source = definitionSource(services.env, services.home);
67
+ if (source.kind !== 'file' || !existsSync(source.path)) return null;
68
+ try {
69
+ return parseDefinition(JSON.parse(readFileSync(source.path, 'utf8')));
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** Every prerequisite of a manager restore, read-only. */
76
+ async function prerequisites(
77
+ services: FleetRestoreServices,
78
+ context: StateContext
79
+ ): Promise<Check[]> {
80
+ const fleet = definition(services);
81
+ const reached = connection(services.env, services.home);
82
+ const checks: Check[] = [
83
+ check('restore:configuration', fleet !== null, [
84
+ 'The project’s fleet.json is found and valid.',
85
+ 'No valid fleet.json was found.',
86
+ 'Run this from the project that holds your fleet.json, after git clone and npm ci.',
87
+ ]),
88
+ check('restore:bucket', reached.bucket !== null, [
89
+ 'The bucket is named, with credentials for it.',
90
+ 'No bucket is configured.',
91
+ 'Name it under "state" in fleet.json and pass DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY.',
92
+ ]),
93
+ ];
94
+ if (reached.bucket) {
95
+ const stands = await standing(context);
96
+ checks.push(
97
+ check(
98
+ 'restore:state',
99
+ stands === 'bucket-ahead' || stands === 'in-sync',
100
+ [
101
+ 'The bucket answers and holds the fleet state.',
102
+ `The fleet state cannot be restored from the bucket: it stands "${stands}".`,
103
+ 'See the state:replica remedy of devfleet fleet-state; a corrupt replica is restored with --restore --timestamp.',
104
+ ]
105
+ )
106
+ );
107
+ }
108
+ if (fleet) {
109
+ const host = managerHost(fleet.manager.os);
110
+ const name = await host.localHostName(services.run);
111
+ checks.push(
112
+ check('restore:host-name', name === fleet.manager.id, [
113
+ `This machine’s local host name is the manager’s id (${fleet.manager.id}).`,
114
+ `This machine’s local host name is not the manager’s id (${fleet.manager.id}).`,
115
+ host.platform === 'macOS'
116
+ ? `Set it: sudo scutil --set LocalHostName ${fleet.manager.id}`
117
+ : `Set it: sudo hostnamectl set-hostname ${fleet.manager.id}`,
118
+ ])
119
+ );
120
+ }
121
+ const git = await services.run(['git', '--version']);
122
+ checks.push(
123
+ check('restore:git', git.ok, [
124
+ 'Git is installed.',
125
+ 'Git is not installed.',
126
+ 'Install Git; fleet-sync installs everything else.',
127
+ ])
128
+ );
129
+ const tailscale = await services.run(['tailscale', 'status', '--json']);
130
+ checks.push(
131
+ check(
132
+ 'restore:tailscale',
133
+ tailscale.ok && /"BackendState":\s*"Running"/.test(tailscale.stdout),
134
+ [
135
+ 'This machine is signed in to the tailnet.',
136
+ 'This machine is not signed in to the tailnet.',
137
+ 'Sign in to Tailscale and approve the device: that is a person’s.',
138
+ 'warn',
139
+ ]
140
+ )
141
+ );
142
+ const docker = await services.run(['docker', 'version']);
143
+ checks.push(
144
+ check('restore:container-engine', docker.ok, [
145
+ 'The container engine answers.',
146
+ 'The container engine does not answer.',
147
+ 'Install and start Docker Desktop; the proxy and the hub run in it.',
148
+ 'warn',
149
+ ])
150
+ );
151
+ return checks;
152
+ }
153
+
154
+ /** Brings a replacement manager back from the bucket: `--dry-run` verifies
155
+ * every prerequisite and lists what would be restored, changing nothing.
156
+ */
157
+ export async function fleetRestore(
158
+ options: FleetRestoreOptions,
159
+ services: FleetRestoreServices = {
160
+ run: runCommand,
161
+ env: process.env,
162
+ home: homedir(),
163
+ holder: managerHolder(process.env),
164
+ }
165
+ ): Promise<CommandOutcome> {
166
+ const reached = connection(services.env, services.home);
167
+ const context: StateContext = {
168
+ root: stateRoot(services.env, services.home),
169
+ bucket: reached.bucket,
170
+ run: services.run,
171
+ holder: services.holder ?? null,
172
+ };
173
+ const checks = await prerequisites(services, context);
174
+ const ready = !checks.some((entry) => entry.status === 'fail');
175
+ const done: string[] = [];
176
+ if (ready && !options.dryRun) {
177
+ const after = await pull(context);
178
+ checks.push(
179
+ check('restore:applied', after === 'in-sync', [
180
+ 'This machine holds the fleet state from the bucket. Next: devfleet fleet-sync (it may need --take-over while the old manager’s lease runs), then devfleet cliproxy install.',
181
+ `The fleet state was not restored: it stands "${after}".`,
182
+ 'Run devfleet fleet-state for the remedy.',
183
+ ])
184
+ );
185
+ done.push('Restored the fleet state from the bucket.');
186
+ }
187
+ const data = {
188
+ ...makeReport('fleet-restore', [{ node: 'manager', checks }]),
189
+ dryRun: options.dryRun === true,
190
+ restores: RESTORES,
191
+ };
192
+ return {
193
+ exitCode: reportExitCode(data, false),
194
+ data,
195
+ text: [
196
+ ...done,
197
+ renderReport(data),
198
+ '',
199
+ options.dryRun ? 'Would restore, in this order:' : 'Restored in order:',
200
+ ...RESTORES.map(
201
+ (entry, index) => `${index + 1}. ${entry.what}: ${entry.how}`
202
+ ),
203
+ ].join('\n'),
204
+ };
205
+ }
@@ -25,6 +25,7 @@ import {
25
25
  type Connection,
26
26
  } from '../state/connection';
27
27
  import { adoptEarlier, legacyPath, readLegacy } from '../state/legacy';
28
+ import { managerHolder } from '../state/holder';
28
29
  import { bucketPath, databasePath, stateRoot } from '../state/paths';
29
30
  import {
30
31
  DOCUMENTS,
@@ -36,7 +37,11 @@ import {
36
37
  import {
37
38
  mutateState,
38
39
  pending,
40
+ leaseConflict,
41
+ leaseHeldMessage,
39
42
  pull,
43
+ pullAt,
44
+ refresh,
40
45
  push,
41
46
  standing,
42
47
  type Standing,
@@ -58,6 +63,8 @@ export interface FleetStateOptions {
58
63
  push?: boolean;
59
64
  restore?: boolean;
60
65
  yes?: boolean;
66
+ timestamp?: string;
67
+ takeOver?: boolean;
61
68
  json?: boolean;
62
69
  nonInteractive?: boolean;
63
70
  }
@@ -68,6 +75,10 @@ export interface FleetStateServices {
68
75
  home: string;
69
76
  /** Asks for one hidden value on the terminal; null when there is none. */
70
77
  ask: ((question: string) => Promise<string>) | null;
78
+ /** This machine, as the manager lease names it; without one the lease is
79
+ * neither checked nor taken.
80
+ */
81
+ holder?: string | null;
71
82
  }
72
83
 
73
84
  function terminalAsk(
@@ -84,11 +95,16 @@ function terminalAsk(
84
95
  const reach = (services: FleetStateServices): Connection =>
85
96
  connection(services.env, services.home);
86
97
 
87
- function context(services: FleetStateServices): StateContext {
98
+ function context(
99
+ services: FleetStateServices,
100
+ options: FleetStateOptions = {}
101
+ ): StateContext {
88
102
  return {
89
103
  root: stateRoot(services.env, services.home),
90
104
  bucket: reach(services).bucket,
91
105
  run: services.run,
106
+ holder: services.holder ?? null,
107
+ takeOver: options.takeOver === true,
92
108
  };
93
109
  }
94
110
 
@@ -109,6 +125,8 @@ function action(options: FleetStateOptions): Action | 'status' {
109
125
  'Choose one of --init, --import, --export, --example, --push or --restore.'
110
126
  );
111
127
  const settings = ['bucket', 'endpoint', 'region', 'prefix', 'directory'];
128
+ if (options.timestamp !== undefined && chosen[0] !== 'restore')
129
+ throw new FleetUsageError('--timestamp belongs to --restore.');
112
130
  if (
113
131
  chosen[0] !== 'init' &&
114
132
  settings.some((name) => options[name as keyof FleetStateOptions])
@@ -367,8 +385,22 @@ async function init(
367
385
  `Saved the bucket settings to ${bucketPath(services.env, services.home)}.`
368
386
  );
369
387
  }
370
- const target = context(services);
388
+ const target = context(services, options);
371
389
  const had = listDocuments(databasePath(target.root)).length > 0;
390
+ // A replacement connects to restore. While another manager holds the
391
+ // lease this one only takes the bucket's version and writes nothing.
392
+ const fresh = await refresh(target);
393
+ const held = leaseConflict(target);
394
+ if (held) {
395
+ if (!had && fresh.replaced)
396
+ done.push('Restored the fleet state from the bucket.');
397
+ done.push(leaseHeldMessage(held));
398
+ return report(services, {
399
+ standing: fresh.standing,
400
+ done,
401
+ missing: 'warn',
402
+ });
403
+ }
372
404
  const { value, standing: after } = await mutateState(target, (before) => {
373
405
  // A state the bucket already holds is the fleet's; this machine's
374
406
  // earlier files only ever seed an empty one.
@@ -388,7 +420,8 @@ async function init(
388
420
 
389
421
  async function importDefinition(
390
422
  path: string,
391
- services: FleetStateServices
423
+ services: FleetStateServices,
424
+ options: FleetStateOptions
392
425
  ): Promise<CommandOutcome> {
393
426
  let value: unknown;
394
427
  try {
@@ -399,7 +432,7 @@ async function importDefinition(
399
432
  );
400
433
  }
401
434
  const body = JSON.stringify(parseDefinition(value), null, 2) + '\n';
402
- const target = context(services);
435
+ const target = context(services, options);
403
436
  const { standing: after } = await mutateState(target, () => {
404
437
  adoptEarlier(services.env, services.home);
405
438
  return writeDocument(databasePath(target.root), 'definition', body);
@@ -458,6 +491,23 @@ async function restore(
458
491
  throw new FleetUsageError(
459
492
  'No bucket is configured, so there is nothing to restore from. Name it under "state" in fleet.json, or run devfleet fleet-state --init --bucket NAME --endpoint https://HOST.'
460
493
  );
494
+ if (options.timestamp !== undefined) {
495
+ // The bucket's current version is replaced too: --yes is the consent.
496
+ if (!options.yes)
497
+ throw new FleetUsageError(
498
+ '--restore --timestamp replaces the fleet state in the bucket and on this machine with an earlier version. Repeat with --yes.'
499
+ );
500
+ const after = await pullAt(target, options.timestamp);
501
+ return report(services, {
502
+ standing: after,
503
+ done:
504
+ after === 'in-sync'
505
+ ? [
506
+ `This machine and the bucket now hold the fleet state as it was at ${options.timestamp}.`,
507
+ ]
508
+ : [],
509
+ });
510
+ }
461
511
  if (pending(target) && !options.yes)
462
512
  throw new FleetUsageError(
463
513
  'This machine has fleet state the bucket does not hold, and --restore discards it. Upload it with --push, or repeat with --yes to discard it.'
@@ -477,6 +527,7 @@ const DEFAULTS = (options: FleetStateOptions): FleetStateServices => ({
477
527
  env: process.env,
478
528
  home: homedir(),
479
529
  ask: terminalAsk(options),
530
+ holder: managerHolder(process.env),
480
531
  });
481
532
 
482
533
  /** `fleet-state` alone is read-only. Each flag is one explicit action on the
@@ -488,7 +539,8 @@ export async function fleetState(
488
539
  ): Promise<CommandOutcome> {
489
540
  const chosen = action(options);
490
541
  if (chosen === 'init') return init(options, services);
491
- if (chosen === 'import') return importDefinition(options.import!, services);
542
+ if (chosen === 'import')
543
+ return importDefinition(options.import!, services, options);
492
544
  if (chosen === 'export') return exportDefinition(services);
493
545
  if (chosen === 'example') return exampleDefinition();
494
546
  if (chosen === 'restore') return restore(options, services);
@@ -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'],
@@ -1,7 +1,6 @@
1
1
  import { blocking, type Check, type Runner } from '../types';
2
2
  import type { FleetDefinition } from '../definition';
3
3
  import type { ManagerNode } from '../manager-sync';
4
- import { hostId } from '../topology';
5
4
  import { convergeProxy } from '../proxy-converge';
6
5
  import { attempt } from '../node-converge-shared';
7
6
  import { quotaLimited, verifyFleetProviders } from '../agents/fleet-verify';
@@ -14,12 +13,17 @@ import { IDES } from '../ide';
14
13
  import { enabled, managerChecks } from '../ide/orchestrate';
15
14
  import { idesOf } from '../ide/definition';
16
15
  import { convergeGitIdentity, gitChecks } from '../git-identity';
17
- import macos from '../../ansible/macos/policy.json';
18
- import { ansibleCommand } from '../platform/ansible';
16
+ import { ansibleCommand, runAnsible } from '../platform/ansible';
19
17
  import { convergeGithub } from '../github/converge';
20
18
  import { PACKAGE_ROOT } from '../package-info';
21
19
  import { managerHealth } from '../manager-health';
22
20
  import { readFileSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import {
23
+ MACOS_MANAGER,
24
+ managerHost,
25
+ type ManagerHost,
26
+ } from '../platform/manager-host';
23
27
  import { join } from 'node:path';
24
28
 
25
29
  /** The manager converges itself locally (never over SSH) before any worker:
@@ -37,12 +41,18 @@ export async function convergeManager(
37
41
  ): Promise<ManagerNode> {
38
42
  const started = Date.now();
39
43
  const id = definition.manager.id;
40
- const identity = await managerIdentity(id, run);
44
+ const host = managerHost(definition.manager.os);
45
+ const identity = await managerIdentity(id, run, host);
41
46
  const checks: Check[] = [identity];
42
47
  if (identity.status === 'pass') {
43
48
  // Launchers and scheduled jobs on the manager read the installed copy.
44
49
  if (input.apply) installConfig();
45
- checks.push(await managerPower(run, input.workspace));
50
+ checks.push(
51
+ input.apply
52
+ ? await holdManagerPower(run, input.workspace, host)
53
+ : await managerPower(run, input.workspace, host)
54
+ );
55
+ checks.push(await managerBootRecovery(run, input.workspace, host));
46
56
  checks.push(await managerDocker(run));
47
57
  // Each enabled IDE: its settings, pinned version and policy.
48
58
  checks.push(
@@ -193,9 +203,13 @@ export async function managerAgentProfiles(
193
203
  }
194
204
 
195
205
  /** fleet-sync runs only on the declared manager. */
196
- export async function managerIdentity(id: string, run: Runner): Promise<Check> {
197
- const result = await run(['scutil', '--get', 'LocalHostName']);
198
- const local = result.ok ? hostId(result.stdout.trim()) : '';
206
+ export async function managerIdentity(
207
+ id: string,
208
+ run: Runner,
209
+ host: ManagerHost = MACOS_MANAGER,
210
+ platform: NodeJS.Platform = process.platform
211
+ ): Promise<Check> {
212
+ const local = (await host.localHostName(run)) ?? '';
199
213
  return local === id
200
214
  ? {
201
215
  id: 'manager:identity',
@@ -207,7 +221,10 @@ export async function managerIdentity(id: string, run: Runner): Promise<Check> {
207
221
  status: 'fail',
208
222
  summary: 'FLEET_NOT_MANAGER',
209
223
  remedy: {
210
- 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.`,
211
228
  },
212
229
  };
213
230
  }
@@ -218,15 +235,10 @@ export async function managerIdentity(id: string, run: Runner): Promise<Check> {
218
235
  */
219
236
  export async function managerPower(
220
237
  run: Runner,
221
- workspace = '.'
238
+ workspace = '.',
239
+ host: ManagerHost = MACOS_MANAGER
222
240
  ): Promise<Check> {
223
- const result = await run(['pmset', '-g', 'custom']);
224
- const ac = result.ok ? (result.stdout.split('AC Power:')[1] ?? '') : '';
225
- const observed = (key: string) =>
226
- new RegExp(`^\\s*${key}\\s+(\\S+)`, 'm').exec(ac)?.[1];
227
- const drifted = Object.entries(macos.pmset)
228
- .filter(([key, value]) => observed(key) !== value)
229
- .map(([key]) => key);
241
+ const drifted = await host.powerDrift(run);
230
242
  return drifted.length === 0
231
243
  ? {
232
244
  id: 'manager:power',
@@ -240,11 +252,82 @@ export async function managerPower(
240
252
  ? 'MANAGER_SLEEPS_ON_POWER'
241
253
  : `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
242
254
  remedy: {
243
- 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`,
244
256
  },
245
257
  };
246
258
  }
247
259
 
260
+ /** After a power cut the manager comes back by itself: the machine starts
261
+ * again, a user session exists for its per-user services, and the container
262
+ * engine that hosts the proxy and the hub starts with it. A FileVault Mac
263
+ * waits for a person; that is reported as a warning with its consequence.
264
+ */
265
+ export async function managerBootRecovery(
266
+ run: Runner,
267
+ workspace = '.',
268
+ host: ManagerHost = MACOS_MANAGER,
269
+ home = homedir()
270
+ ): Promise<Check> {
271
+ const items = await host.bootRecovery(run, home);
272
+ const failing = items.filter((entry) => entry.status === 'fail');
273
+ const warning = items.filter((entry) => entry.status === 'warn');
274
+ if (!failing.length && !warning.length)
275
+ return {
276
+ id: 'manager:boot-recovery',
277
+ status: 'pass',
278
+ summary: 'The manager comes back by itself after a power cut or reboot.',
279
+ };
280
+ const named = failing.length ? failing : warning;
281
+ return {
282
+ id: 'manager:boot-recovery',
283
+ status: failing.length ? 'fail' : 'warn',
284
+ summary: `MANAGER_BOOT_RECOVERY_INCOMPLETE ${named.map((entry) => entry.item).join(' ')}`,
285
+ remedy: {
286
+ action: [
287
+ ...[...failing, ...warning].map((entry) => entry.detail),
288
+ host.bootRemedy(workspace),
289
+ ].join(' '),
290
+ },
291
+ };
292
+ }
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
+
248
331
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
249
332
  export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
250
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 &&
@@ -4,7 +4,14 @@ import { storeEnrollment } from '../state/enrollment';
4
4
  import { adoptEarlier } from '../state/legacy';
5
5
  import { databasePath, stateRoot } from '../state/paths';
6
6
  import { writeDocument } from '../state/store';
7
- import { pending, push, type Standing, type StateContext } from '../state/sync';
7
+ import {
8
+ claimLease,
9
+ refuseHeldLease,
10
+ pending,
11
+ push,
12
+ type Standing,
13
+ type StateContext,
14
+ } from '../state/sync';
8
15
  import type { Check } from '../types';
9
16
 
10
17
  /** What one fleet-sync run records in the fleet state: the revision it was
@@ -14,8 +21,11 @@ import type { Check } from '../types';
14
21
  export interface RunRecord {
15
22
  /** Before any machine changes: this revision is the approved one. */
16
23
  approve(): void;
17
- /** Once the result is known. The check says whether the bucket holds it. */
18
- 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>;
19
29
  /** For a run that stopped before its result: its approval is still uploaded. */
20
30
  abandon(): Promise<void>;
21
31
  }
@@ -51,21 +61,30 @@ export function runRecord(
51
61
  : Promise.resolve(state.standing);
52
62
  return {
53
63
  approve: () => {
64
+ refuseHeldLease(state.context);
54
65
  storeEnrollment(approval, env);
55
- if (state.definition === undefined) return;
56
- adoptEarlier(env);
57
- writeDocument(
58
- databasePath(stateRoot(env)),
59
- 'definition',
60
- state.definition
61
- );
66
+ if (state.definition !== undefined) {
67
+ adoptEarlier(env);
68
+ writeDocument(
69
+ databasePath(stateRoot(env)),
70
+ 'definition',
71
+ state.definition
72
+ );
73
+ }
74
+ claimLease(state.context);
62
75
  },
63
- async settle(converged) {
76
+ async settle(converged, applied = {}) {
64
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);
65
83
  if (converged)
66
84
  storeEnrollment(
67
85
  {
68
86
  ...approval,
87
+ ...workers,
69
88
  appliedRevision: revision,
70
89
  ...(previous?.appliedRevision &&
71
90
  previous.appliedRevision !== revision