@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.
- package/README.md +32 -13
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +51 -6
- package/ansible/macos/policy.json +6 -1
- package/ansible/macos/worker-host-key.yml +33 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/ubuntu/baseline.yml +6 -0
- package/ansible/ubuntu/recovery.yml +2 -0
- package/ansible/ubuntu/tailscale.yml +2 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +34 -35
- package/docs/declared.md +24 -24
- package/docs/manager-recovery.md +78 -0
- package/docs/manager-setup.md +27 -0
- package/docs/operations.md +8 -0
- package/docs/runbooks.md +15 -1
- package/docs/state.md +38 -6
- package/docs/topology.md +19 -11
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +36 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +164 -0
- package/src/cli/fleet-manifest.ts +102 -0
- package/src/cli/fleet-node-lifecycle.ts +455 -0
- package/src/cli/fleet-node-seed.ts +151 -0
- package/src/cli/fleet-restore.ts +205 -0
- package/src/cli/fleet-state.ts +57 -5
- package/src/cli/fleet-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +101 -18
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +30 -11
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +54 -5
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +167 -0
- package/src/cli/registry.ts +47 -1
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/platform/manager-host.ts +184 -0
- package/src/repair-catalog.ts +64 -0
- package/src/repair-plan.ts +25 -13
- package/src/secrets.ts +3 -0
- 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-credentials.ts +201 -0
- package/src/worker-manifest.ts +201 -0
- package/src/worker-policy.ts +21 -35
- 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
|
+
}
|
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);
|
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
|
|
115
|
-
|
|
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 = {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|