@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.
- package/README.md +4 -10
- package/ansible/macos/manager.yml +39 -2
- package/ansible/macos/policy.json +6 -1
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +8 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/ansible/ubuntu/tasks/github.yml +50 -0
- package/bootstrap/worker-auth.ts +6 -7
- package/bootstrap/worker-github.ts +55 -44
- package/docs/authentication.md +4 -1
- package/docs/consumer-setup.md +7 -4
- package/docs/github.md +107 -0
- package/docs/manager-recovery.md +78 -0
- package/docs/operations.md +9 -1
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +4 -5
- package/docs/ubuntu-sync.md +5 -5
- package/package.json +2 -2
- package/schema/check-ids.json +51 -3
- package/schema/fleet.schema.json +79 -1
- package/src/auth-checks.ts +14 -6
- package/src/check-owners.ts +163 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-manager.ts +62 -15
- package/src/cli/fleet-sync-state.ts +18 -8
- package/src/cli/fleet-sync.ts +32 -3
- package/src/cli/github-verify.ts +57 -0
- package/src/cli/register-fleet.ts +33 -0
- package/src/cli/registry.ts +21 -1
- package/src/definition.ts +11 -1
- package/src/github/checks.ts +412 -0
- package/src/github/converge.ts +62 -0
- package/src/github/copilot.ts +54 -0
- package/src/github/credential.ts +21 -0
- package/src/github/policy.ts +141 -0
- package/src/github/sync.ts +123 -0
- package/src/github/workflow.ts +223 -0
- package/src/node-converge-linux.ts +3 -2
- package/src/node-converge-shared.ts +6 -2
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +66 -2
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +58 -13
- package/src/service-auth.ts +2 -2
- package/src/state/check.ts +7 -0
- package/src/state/holder.ts +50 -0
- package/src/state/litestream.ts +6 -3
- package/src/state/migrations/0001-documents.sql +13 -0
- package/src/state/migrations/0002-lease.sql +7 -0
- package/src/state/store.ts +125 -26
- package/src/state/sync.ts +131 -10
- package/src/worker-access-checks.ts +66 -16
- 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
|
+
}
|
package/src/cli/fleet-state.ts
CHANGED
|
@@ -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(
|
|
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')
|
|
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
|
|
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(
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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 {
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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;
|
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
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
|
+
}
|