@crvouga/dev-fleet 0.6.0 → 0.7.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,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);
@@ -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
16
  import { ansibleCommand } 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,14 @@ 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(await managerPower(run, input.workspace, host));
51
+ checks.push(await managerBootRecovery(run, input.workspace, host));
46
52
  checks.push(await managerDocker(run));
47
53
  // Each enabled IDE: its settings, pinned version and policy.
48
54
  checks.push(
@@ -193,9 +199,12 @@ export async function managerAgentProfiles(
193
199
  }
194
200
 
195
201
  /** 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()) : '';
202
+ export async function managerIdentity(
203
+ id: string,
204
+ run: Runner,
205
+ host: ManagerHost = MACOS_MANAGER
206
+ ): Promise<Check> {
207
+ const local = (await host.localHostName(run)) ?? '';
199
208
  return local === id
200
209
  ? {
201
210
  id: 'manager:identity',
@@ -218,15 +227,10 @@ export async function managerIdentity(id: string, run: Runner): Promise<Check> {
218
227
  */
219
228
  export async function managerPower(
220
229
  run: Runner,
221
- workspace = '.'
230
+ workspace = '.',
231
+ host: ManagerHost = MACOS_MANAGER
222
232
  ): 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);
233
+ const drifted = await host.powerDrift(run);
230
234
  return drifted.length === 0
231
235
  ? {
232
236
  id: 'manager:power',
@@ -245,6 +249,40 @@ export async function managerPower(
245
249
  };
246
250
  }
247
251
 
252
+ /** After a power cut the manager comes back by itself: the machine starts
253
+ * again, a user session exists for its per-user services, and the container
254
+ * engine that hosts the proxy and the hub starts with it. A FileVault Mac
255
+ * waits for a person; that is reported as a warning with its consequence.
256
+ */
257
+ export async function managerBootRecovery(
258
+ run: Runner,
259
+ workspace = '.',
260
+ host: ManagerHost = MACOS_MANAGER,
261
+ home = homedir()
262
+ ): Promise<Check> {
263
+ const items = await host.bootRecovery(run, home);
264
+ const failing = items.filter((entry) => entry.status === 'fail');
265
+ const warning = items.filter((entry) => entry.status === 'warn');
266
+ if (!failing.length && !warning.length)
267
+ return {
268
+ id: 'manager:boot-recovery',
269
+ status: 'pass',
270
+ summary: 'The manager comes back by itself after a power cut or reboot.',
271
+ };
272
+ const named = failing.length ? failing : warning;
273
+ return {
274
+ id: 'manager:boot-recovery',
275
+ status: failing.length ? 'fail' : 'warn',
276
+ summary: `MANAGER_BOOT_RECOVERY_INCOMPLETE ${named.map((entry) => entry.item).join(' ')}`,
277
+ remedy: {
278
+ action: [
279
+ ...[...failing, ...warning].map((entry) => entry.detail),
280
+ host.bootRemedy(workspace),
281
+ ].join(' '),
282
+ },
283
+ };
284
+ }
285
+
248
286
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
249
287
  export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
250
288
  const { vars } = Bun.TOML.parse(
@@ -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
@@ -51,14 +58,17 @@ export function runRecord(
51
58
  : Promise.resolve(state.standing);
52
59
  return {
53
60
  approve: () => {
61
+ refuseHeldLease(state.context);
54
62
  storeEnrollment(approval, env);
55
- if (state.definition === undefined) return;
56
- adoptEarlier(env);
57
- writeDocument(
58
- databasePath(stateRoot(env)),
59
- 'definition',
60
- state.definition
61
- );
63
+ if (state.definition !== undefined) {
64
+ adoptEarlier(env);
65
+ writeDocument(
66
+ databasePath(stateRoot(env)),
67
+ 'definition',
68
+ state.definition
69
+ );
70
+ }
71
+ claimLease(state.context);
62
72
  },
63
73
  async settle(converged) {
64
74
  settled = true;
@@ -31,6 +31,8 @@ import {
31
31
  DIVERGED_REMEDY,
32
32
  mutateState,
33
33
  refresh,
34
+ leaseConflict,
35
+ leaseHeldMessage,
34
36
  stateContext,
35
37
  type Standing,
36
38
  } from '../state/sync';
@@ -72,6 +74,8 @@ export interface FleetSyncOptions {
72
74
  /** Internal: set for interactive terminal runs (sudo enrollment prompts). */
73
75
  interactiveAuthorization?: boolean;
74
76
  expectedPlanFingerprint?: string;
77
+ /** Take the manager lease from another manager before it expired. */
78
+ takeOver?: boolean;
75
79
  }
76
80
  export function machineRoot(): string {
77
81
  const root = join(homedir(), '.local/share/personal/fleet/manager');
@@ -140,6 +144,31 @@ export interface ManagerSync {
140
144
  restart: () => Promise<CommandOutcome>;
141
145
  }
142
146
 
147
+ /** Why the fleet state stops this run before any machine or the state is
148
+ * changed, if it does.
149
+ */
150
+ function stateRefusal(
151
+ standing: Standing,
152
+ options: FleetSyncOptions,
153
+ run: Runner,
154
+ revision: string
155
+ ): CommandOutcome | null {
156
+ if (standing === 'diverged')
157
+ return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
158
+ // One manager at a time: another one's lease stops this run.
159
+ const held = leaseConflict({
160
+ ...stateContext(run),
161
+ takeOver: options.takeOver === true,
162
+ });
163
+ return held
164
+ ? actionRequired(
165
+ 'FLEET_MANAGER_LEASE_HELD',
166
+ leaseHeldMessage(held),
167
+ revision
168
+ )
169
+ : null;
170
+ }
171
+
143
172
  /** fleet-sync on the machine already known to be the manager. */
144
173
  export async function managerSync(
145
174
  options: FleetSyncOptions,
@@ -177,8 +206,8 @@ export async function managerSync(
177
206
  // version of the configuration, the roster and the approval, not from a
178
207
  // stale local copy. Refreshing never uploads.
179
208
  const fresh = await refresh(stateContext(run));
180
- if (fresh.standing === 'diverged')
181
- return actionRequired('FLEET_STATE_DIVERGED', DIVERGED_REMEDY, revision);
209
+ const refused = stateRefusal(fresh.standing, options, run, revision);
210
+ if (refused) return refused;
182
211
  if (fresh.replaced) {
183
212
  // Everything read so far came from the stale copy, the manager's own
184
213
  // identity included.
@@ -274,7 +303,7 @@ function applyLocked(
274
303
  ): Promise<CommandOutcome> {
275
304
  const { settings, run, revision } = context;
276
305
  const record = runRecord(state, revision, {
277
- context: stateContext(run),
306
+ context: { ...stateContext(run), takeOver: request.takeOver === true },
278
307
  standing: context.standing,
279
308
  ...(definitionSource().kind === 'file'
280
309
  ? { definition: JSON.stringify(canonicalDefinition, null, 2) + '\n' }
@@ -12,6 +12,7 @@ import { fleetReport, type FleetReportOptions } from './fleet-report';
12
12
  import { fleetWatch, watchServices } from './fleet-watch';
13
13
  import type { NodeReport } from '../types';
14
14
  import { registerGithubVerify } from './github-verify';
15
+ import { fleetRestore, type FleetRestoreOptions } from './fleet-restore';
15
16
 
16
17
  type Complete = (outcome: CommandOutcome) => void;
17
18
 
@@ -199,6 +200,10 @@ function registerSync({ program, defaults, complete }: Registration) {
199
200
  'Accepted for compatibility; fleet-sync applies by default (schedule writes still need --apply --yes)'
200
201
  )
201
202
  .option('--yes', 'Accepted for compatibility with --apply')
203
+ .option(
204
+ '--take-over',
205
+ 'Make this machine the manager while another one still holds the manager lease'
206
+ )
202
207
  .option(
203
208
  '--schedule <action>',
204
209
  'Manager-only schedule: enable, pause, resume, disable, status'
@@ -277,6 +282,14 @@ function registerState({ program, complete }: Registration) {
277
282
  'Replace this machine’s copy with the bucket’s version'
278
283
  )
279
284
  .option('--yes', 'With --restore: discard changes the bucket does not hold')
285
+ .option(
286
+ '--timestamp <time>',
287
+ 'With --restore: the version the bucket held at this time, uploaded again as the current one (a damaged replica)'
288
+ )
289
+ .option(
290
+ '--take-over',
291
+ 'With --init or --import: change the fleet state while another manager still holds the manager lease'
292
+ )
280
293
  .addHelpText(
281
294
  'after',
282
295
  '\nRead-only without a flag. The bucket is the one named under "state" in fleet.json. Every command reads its credentials from DEVFLEET_BUCKET_ACCESS_KEY_ID and DEVFLEET_BUCKET_SECRET_ACCESS_KEY; --init also asks on the terminal. They are never taken from arguments.'
@@ -332,6 +345,23 @@ function registerWatch({ program, defaults, complete }: Registration) {
332
345
  });
333
346
  }
334
347
 
348
+ function registerRestore({ program, complete }: Registration) {
349
+ program
350
+ .command('fleet-restore')
351
+ .description(
352
+ 'Bring a replacement manager back from the bucket (docs/manager-recovery.md)'
353
+ )
354
+ .option(
355
+ '--dry-run',
356
+ 'Verify every prerequisite and list what would be restored; change nothing'
357
+ )
358
+ .action(async (_options, command: Command) => {
359
+ complete(
360
+ await fleetRestore(command.optsWithGlobals<FleetRestoreOptions>())
361
+ );
362
+ });
363
+ }
364
+
335
365
  export function registerFleet(
336
366
  program: Command,
337
367
  defaults: Settings,
@@ -348,6 +378,7 @@ export function registerFleet(
348
378
  registerState(registration);
349
379
  registerReport(registration);
350
380
  registerWatch(registration);
381
+ registerRestore(registration);
351
382
  registerRepair(registration);
352
383
  registerNodes(registration);
353
384
  registerAccounts(registration);
@@ -93,7 +93,7 @@ export const COMMANDS: CommandSpec[] = [
93
93
  [
94
94
  'fleet-state --restore',
95
95
  'Replace the local fleet state with the bucket’s',
96
- 'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold.',
96
+ 'Discard this manager’s copy of the fleet state and download the bucket’s version. Needs --yes when this machine has changes the bucket does not hold. --timestamp TIME --yes restores the version the bucket held then and uploads it again: the remedy for a corrupt replica.',
97
97
  ['state', 'bucket', 'restore', 'recover', 'disposable'],
98
98
  true,
99
99
  ],
@@ -103,6 +103,19 @@ export const COMMANDS: CommandSpec[] = [
103
103
  'Read-only: success rate, mean duration, the most frequent failing check IDs and how long each machine was not ready, from the history of fleet-sync runs kept in the fleet state. --since 30d (default), 12h or 90m.',
104
104
  ['history', 'reliability', 'report', 'failures', 'sync'],
105
105
  ],
106
+ [
107
+ 'fleet-restore --dry-run',
108
+ 'Check that a replacement manager can be restored',
109
+ 'Read-only: verifies every prerequisite of a manager restore (fleet.json, the bucket and the state in it, the local host name, Git, Tailscale, the container engine) and lists what would be restored, in order. docs/manager-recovery.md is the runbook.',
110
+ ['manager', 'restore', 'replace', 'lost', 'recovery', 'runbook'],
111
+ ],
112
+ [
113
+ 'fleet-restore',
114
+ 'Restore a replacement manager from the bucket',
115
+ 'Runs the same checks, then takes the fleet state from the bucket. fleet-sync rebuilds the rest.',
116
+ ['manager', 'restore', 'replace', 'lost', 'recovery'],
117
+ true,
118
+ ],
106
119
  [
107
120
  'fleet-node-sync',
108
121
  'Sync this node',