@crvouga/dev-fleet 0.5.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.
Files changed (56) hide show
  1. package/README.md +4 -10
  2. package/ansible/macos/manager.yml +39 -2
  3. package/ansible/macos/policy.json +6 -1
  4. package/ansible/monitoring/agent.yml +2 -0
  5. package/ansible/ubuntu/baseline.yml +8 -0
  6. package/ansible/ubuntu/recovery.yml +2 -0
  7. package/ansible/ubuntu/tailscale.yml +2 -0
  8. package/ansible/ubuntu/tasks/github.yml +50 -0
  9. package/bootstrap/worker-auth.ts +6 -7
  10. package/bootstrap/worker-github.ts +55 -44
  11. package/docs/authentication.md +4 -1
  12. package/docs/consumer-setup.md +7 -4
  13. package/docs/github.md +107 -0
  14. package/docs/manager-recovery.md +78 -0
  15. package/docs/operations.md +9 -1
  16. package/docs/runbooks.md +15 -1
  17. package/docs/state.md +38 -6
  18. package/docs/topology.md +4 -5
  19. package/docs/ubuntu-sync.md +5 -5
  20. package/package.json +2 -2
  21. package/schema/check-ids.json +51 -3
  22. package/schema/fleet.schema.json +79 -1
  23. package/src/auth-checks.ts +14 -6
  24. package/src/check-owners.ts +163 -0
  25. package/src/cli/fleet-restore.ts +205 -0
  26. package/src/cli/fleet-state.ts +57 -5
  27. package/src/cli/fleet-sync-manager.ts +62 -15
  28. package/src/cli/fleet-sync-state.ts +18 -8
  29. package/src/cli/fleet-sync.ts +32 -3
  30. package/src/cli/github-verify.ts +57 -0
  31. package/src/cli/register-fleet.ts +33 -0
  32. package/src/cli/registry.ts +21 -1
  33. package/src/definition.ts +11 -1
  34. package/src/github/checks.ts +412 -0
  35. package/src/github/converge.ts +62 -0
  36. package/src/github/copilot.ts +54 -0
  37. package/src/github/credential.ts +21 -0
  38. package/src/github/policy.ts +141 -0
  39. package/src/github/sync.ts +123 -0
  40. package/src/github/workflow.ts +223 -0
  41. package/src/node-converge-linux.ts +3 -2
  42. package/src/node-converge-shared.ts +6 -2
  43. package/src/platform/manager-host.ts +184 -0
  44. package/src/repair-catalog.ts +66 -2
  45. package/src/repair-plan.ts +25 -13
  46. package/src/secrets.ts +58 -13
  47. package/src/service-auth.ts +2 -2
  48. package/src/state/check.ts +7 -0
  49. package/src/state/holder.ts +50 -0
  50. package/src/state/litestream.ts +6 -3
  51. package/src/state/migrations/0001-documents.sql +13 -0
  52. package/src/state/migrations/0002-lease.sql +7 -0
  53. package/src/state/store.ts +125 -26
  54. package/src/state/sync.ts +131 -10
  55. package/src/worker-access-checks.ts +66 -16
  56. package/src/worker-readiness.ts +1 -1
@@ -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,11 +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';
17
+ import { convergeGithub } from '../github/converge';
19
18
  import { PACKAGE_ROOT } from '../package-info';
20
19
  import { managerHealth } from '../manager-health';
21
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';
22
27
  import { join } from 'node:path';
23
28
 
24
29
  /** The manager converges itself locally (never over SSH) before any worker:
@@ -36,12 +41,14 @@ export async function convergeManager(
36
41
  ): Promise<ManagerNode> {
37
42
  const started = Date.now();
38
43
  const id = definition.manager.id;
39
- const identity = await managerIdentity(id, run);
44
+ const host = managerHost(definition.manager.os);
45
+ const identity = await managerIdentity(id, run, host);
40
46
  const checks: Check[] = [identity];
41
47
  if (identity.status === 'pass') {
42
48
  // Launchers and scheduled jobs on the manager read the installed copy.
43
49
  if (input.apply) installConfig();
44
- 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));
45
52
  checks.push(await managerDocker(run));
46
53
  // Each enabled IDE: its settings, pinned version and policy.
47
54
  checks.push(
@@ -70,6 +77,14 @@ export async function convergeManager(
70
77
  );
71
78
  if (definition.git)
72
79
  checks.push(...(await gitChecks(definition.git, run)));
80
+ // The same GitHub sign-in as every worker, or only a check of a
81
+ // person's own login when the configuration names one. git asks gh
82
+ // for the token through the helper git.yml declared above.
83
+ checks.push(
84
+ ...(await attempt('github:sync', async () => [
85
+ await convergeGithub(input.workspace, run),
86
+ ]))
87
+ );
73
88
  const providers = await attempt('provider:verification', () =>
74
89
  verifyFleetProviders(id)
75
90
  );
@@ -184,9 +199,12 @@ export async function managerAgentProfiles(
184
199
  }
185
200
 
186
201
  /** fleet-sync runs only on the declared manager. */
187
- export async function managerIdentity(id: string, run: Runner): Promise<Check> {
188
- const result = await run(['scutil', '--get', 'LocalHostName']);
189
- 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)) ?? '';
190
208
  return local === id
191
209
  ? {
192
210
  id: 'manager:identity',
@@ -209,15 +227,10 @@ export async function managerIdentity(id: string, run: Runner): Promise<Check> {
209
227
  */
210
228
  export async function managerPower(
211
229
  run: Runner,
212
- workspace = '.'
230
+ workspace = '.',
231
+ host: ManagerHost = MACOS_MANAGER
213
232
  ): Promise<Check> {
214
- const result = await run(['pmset', '-g', 'custom']);
215
- const ac = result.ok ? (result.stdout.split('AC Power:')[1] ?? '') : '';
216
- const observed = (key: string) =>
217
- new RegExp(`^\\s*${key}\\s+(\\S+)`, 'm').exec(ac)?.[1];
218
- const drifted = Object.entries(macos.pmset)
219
- .filter(([key, value]) => observed(key) !== value)
220
- .map(([key]) => key);
233
+ const drifted = await host.powerDrift(run);
221
234
  return drifted.length === 0
222
235
  ? {
223
236
  id: 'manager:power',
@@ -236,6 +249,40 @@ export async function managerPower(
236
249
  };
237
250
  }
238
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
+
239
286
  /** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
240
287
  export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
241
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' }
@@ -0,0 +1,57 @@
1
+ import type { Command } from 'commander';
2
+ import { hostname } from 'node:os';
3
+ import { FleetUsageError } from '../errors';
4
+ import { fleet } from '../fleet-config';
5
+ import { githubPolicy } from '../github/policy';
6
+ import { verifyGithubWorkflow } from '../github/workflow';
7
+ import { runCommand } from '../runner';
8
+ import { hostId } from '../topology';
9
+ import type { Runner } from '../types';
10
+ import type { CommandOutcome } from './contracts';
11
+
12
+ /** Runs the GitHub workflow an agent depends on from this machine against
13
+ * the declared sandbox repository, and cleans up after itself.
14
+ */
15
+ export async function githubVerify(
16
+ run: Runner = runCommand,
17
+ node = hostId(hostname())
18
+ ): Promise<CommandOutcome> {
19
+ const sandbox = githubPolicy(fleet).sandbox;
20
+ if (!sandbox)
21
+ throw new FleetUsageError(
22
+ 'No sandbox repository. Declare github.sandbox ("owner/name") in fleet.json: a repository the fleet login may branch, push to and open pull requests on.'
23
+ );
24
+ const result = await verifyGithubWorkflow({ run, sandbox, node });
25
+ const ok = result.check.status === 'pass';
26
+ return {
27
+ exitCode: ok ? 0 : 1,
28
+ data: {
29
+ version: 1,
30
+ command: 'github-verify',
31
+ ok,
32
+ node,
33
+ checks: [result.check],
34
+ ...(result.failed ? { failedStep: result.failed } : {}),
35
+ leftBehind: result.leftBehind,
36
+ },
37
+ text: `${result.check.id} ${result.check.status}: ${result.check.summary}`,
38
+ };
39
+ }
40
+
41
+ export function registerGithubVerify(
42
+ program: Command,
43
+ complete: (outcome: CommandOutcome) => void
44
+ ): void {
45
+ program
46
+ .command('fleet-github-verify')
47
+ .description(
48
+ 'Prove this machine can clone, push, open a pull request, read checks and comment on GitHub'
49
+ )
50
+ .addHelpText(
51
+ 'after',
52
+ '\nWorks on the sandbox repository named by github.sandbox in fleet.json: a uniquely named branch and a draft pull request, both closed and deleted again by the same run, whatever failed.'
53
+ )
54
+ .action(async () => {
55
+ complete(await githubVerify());
56
+ });
57
+ }