@crvouga/dev-fleet 0.7.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 +28 -3
- package/ansible/macos/manager-privilege.yml +37 -0
- package/ansible/macos/manager.yml +13 -5
- package/ansible/macos/worker-host-key.yml +33 -0
- package/bootstrap/worker-seed.yaml +61 -0
- package/docs/authentication.md +2 -0
- package/docs/consumer-setup.md +32 -31
- package/docs/declared.md +24 -24
- package/docs/manager-setup.md +27 -0
- package/docs/runbooks.md +15 -1
- package/docs/topology.md +15 -6
- package/docs/ubuntu-sync.md +21 -15
- package/package.json +2 -2
- package/schema/check-ids.json +32 -0
- package/schema/fleet.schema.json +22 -2
- package/src/check-owners.ts +1 -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-sync-forward.ts +2 -0
- package/src/cli/fleet-sync-manager.ts +50 -5
- package/src/cli/fleet-sync-remote.ts +21 -6
- package/src/cli/fleet-sync-state.ts +12 -3
- package/src/cli/fleet-sync-vault.ts +75 -26
- package/src/cli/fleet-sync.ts +22 -2
- package/src/cli/fleet-validate.ts +239 -0
- package/src/cli/register-fleet.ts +136 -0
- package/src/cli/registry.ts +33 -0
- package/src/definition.ts +444 -237
- package/src/machine/state.ts +33 -1
- package/src/manager-sync.ts +24 -4
- package/src/secrets.ts +3 -0
- 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,151 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { FleetDefinition } from '../definition';
|
|
5
|
+
import { ubuntuRelease } from '../platform/ubuntu-release';
|
|
6
|
+
import { FleetUsageError } from '../errors';
|
|
7
|
+
import { fleet, secret } from '../fleet-config';
|
|
8
|
+
import type { SecretsProvider } from '../secrets';
|
|
9
|
+
import type { Check } from '../types';
|
|
10
|
+
import {
|
|
11
|
+
generateHostKey,
|
|
12
|
+
renderSeed,
|
|
13
|
+
seedUserData,
|
|
14
|
+
tailscalePins,
|
|
15
|
+
} from '../worker-seed';
|
|
16
|
+
import type { CommandOutcome } from './contracts';
|
|
17
|
+
import {
|
|
18
|
+
keyName,
|
|
19
|
+
lifecycleServices,
|
|
20
|
+
type NodeLifecycleServices,
|
|
21
|
+
} from './fleet-node-lifecycle';
|
|
22
|
+
|
|
23
|
+
export interface NodeSeedOptions {
|
|
24
|
+
id?: string;
|
|
25
|
+
output?: string;
|
|
26
|
+
publicKey?: string;
|
|
27
|
+
autoinstall?: boolean;
|
|
28
|
+
apply?: boolean;
|
|
29
|
+
yes?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NodeSeedServices extends Pick<
|
|
33
|
+
NodeLifecycleServices,
|
|
34
|
+
'run' | 'playbook' | 'knownHosts'
|
|
35
|
+
> {
|
|
36
|
+
definition: () => FleetDefinition;
|
|
37
|
+
secret: SecretsProvider;
|
|
38
|
+
home: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function seedServices(roster: string): NodeSeedServices {
|
|
42
|
+
const { run, playbook, knownHosts } = lifecycleServices(roster);
|
|
43
|
+
return {
|
|
44
|
+
run,
|
|
45
|
+
playbook,
|
|
46
|
+
knownHosts,
|
|
47
|
+
definition: () => fleet,
|
|
48
|
+
secret,
|
|
49
|
+
home: homedir(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Writes the first-boot seed of a declared worker to a private file: the
|
|
54
|
+
* seed carries a single-use auth key, so it is never printed. With --apply
|
|
55
|
+
* --yes the manager pins the host key the seed installs, so the new machine
|
|
56
|
+
* is trusted without a first-use step.
|
|
57
|
+
*/
|
|
58
|
+
export async function fleetNodeSeed(
|
|
59
|
+
options: NodeSeedOptions,
|
|
60
|
+
services: NodeSeedServices
|
|
61
|
+
): Promise<CommandOutcome> {
|
|
62
|
+
const worker = services
|
|
63
|
+
.definition()
|
|
64
|
+
.workers.find((entry) => entry.id === options.id);
|
|
65
|
+
if (!worker)
|
|
66
|
+
throw new FleetUsageError('--id must name a worker fleet.json declares.');
|
|
67
|
+
if (!options.output)
|
|
68
|
+
throw new FleetUsageError(
|
|
69
|
+
'--output PATH is required: the seed holds a single-use auth key and is never printed.'
|
|
70
|
+
);
|
|
71
|
+
const keyFile =
|
|
72
|
+
options.publicKey ?? join(services.home, '.ssh/id_ed25519.pub');
|
|
73
|
+
const managerKey = await readFile(keyFile, 'utf8').catch(() => {
|
|
74
|
+
throw new FleetUsageError(
|
|
75
|
+
`No manager public key at ${keyFile}; name one with --public-key PATH.`
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
const authKey = await services.secret('tailnet-auth-key');
|
|
79
|
+
if (!authKey)
|
|
80
|
+
throw new FleetUsageError(
|
|
81
|
+
'The secrets provider holds no tailnet-auth-key. Store a short-lived, single-use, tagged Tailscale auth key there first.'
|
|
82
|
+
);
|
|
83
|
+
const release = ubuntuRelease(services.definition().ubuntu.release);
|
|
84
|
+
if (!release)
|
|
85
|
+
throw new FleetUsageError(
|
|
86
|
+
'fleet.json names an Ubuntu release the support matrix does not list.'
|
|
87
|
+
);
|
|
88
|
+
const hostKey = await generateHostKey(worker.id, services.run);
|
|
89
|
+
const seed = renderSeed(
|
|
90
|
+
await seedUserData({
|
|
91
|
+
worker,
|
|
92
|
+
managerKey,
|
|
93
|
+
authKey,
|
|
94
|
+
hostKey,
|
|
95
|
+
tailscale: await tailscalePins(),
|
|
96
|
+
codename: release.codename,
|
|
97
|
+
}),
|
|
98
|
+
Boolean(options.autoinstall)
|
|
99
|
+
);
|
|
100
|
+
await writeFile(options.output, seed, { mode: 0o600 });
|
|
101
|
+
const host = await keyName(worker, services.run);
|
|
102
|
+
const publicKey = hostKey.public.split(' ').slice(0, 2).join(' ');
|
|
103
|
+
const pin: Check =
|
|
104
|
+
options.apply && options.yes
|
|
105
|
+
? (await services.playbook('macos/worker-host-key.yml', {
|
|
106
|
+
host,
|
|
107
|
+
pin: 'present',
|
|
108
|
+
key: publicKey,
|
|
109
|
+
known_hosts: services.knownHosts,
|
|
110
|
+
}))
|
|
111
|
+
? {
|
|
112
|
+
id: 'ssh:host-key',
|
|
113
|
+
status: 'pass',
|
|
114
|
+
summary: `The manager trusts the seed's host key for ${host}.`,
|
|
115
|
+
}
|
|
116
|
+
: {
|
|
117
|
+
id: 'ssh:host-key',
|
|
118
|
+
status: 'fail',
|
|
119
|
+
summary: 'SSH_HOST_KEY_NOT_CHANGED',
|
|
120
|
+
remedy: {
|
|
121
|
+
action:
|
|
122
|
+
'The pinned Ansible could not change known_hosts; run the command again.',
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
: {
|
|
126
|
+
id: 'ssh:host-key',
|
|
127
|
+
status: 'skip',
|
|
128
|
+
summary: `Planned: pin the seed's host key for ${host}; run again with --apply --yes.`,
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
exitCode: pin.status === 'fail' ? 1 : 0,
|
|
132
|
+
data: {
|
|
133
|
+
version: 1,
|
|
134
|
+
command: 'fleet-node-seed',
|
|
135
|
+
ok: pin.status !== 'fail',
|
|
136
|
+
worker: worker.id,
|
|
137
|
+
output: options.output,
|
|
138
|
+
format: options.autoinstall ? 'autoinstall' : 'cloud-init',
|
|
139
|
+
hostKey: publicKey,
|
|
140
|
+
checks: [pin],
|
|
141
|
+
},
|
|
142
|
+
text: [
|
|
143
|
+
`Wrote the ${options.autoinstall ? 'autoinstall' : 'cloud-init'} seed of ${worker.id} to ${options.output} (private; it holds a single-use auth key).`,
|
|
144
|
+
`Host key: ${publicKey}`,
|
|
145
|
+
`${pin.status.toUpperCase()} ${pin.id}: ${pin.summary}`,
|
|
146
|
+
'Boot a stock Ubuntu image with it; once the machine is on the tailnet, add it with fleet-node-add and run devfleet fleet-sync --node ' +
|
|
147
|
+
worker.id +
|
|
148
|
+
'.',
|
|
149
|
+
].join('\n'),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -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'],
|
|
@@ -13,7 +13,7 @@ import { IDES } from '../ide';
|
|
|
13
13
|
import { enabled, managerChecks } from '../ide/orchestrate';
|
|
14
14
|
import { idesOf } from '../ide/definition';
|
|
15
15
|
import { convergeGitIdentity, gitChecks } from '../git-identity';
|
|
16
|
-
import { ansibleCommand } from '../platform/ansible';
|
|
16
|
+
import { ansibleCommand, runAnsible } from '../platform/ansible';
|
|
17
17
|
import { convergeGithub } from '../github/converge';
|
|
18
18
|
import { PACKAGE_ROOT } from '../package-info';
|
|
19
19
|
import { managerHealth } from '../manager-health';
|
|
@@ -47,7 +47,11 @@ export async function convergeManager(
|
|
|
47
47
|
if (identity.status === 'pass') {
|
|
48
48
|
// Launchers and scheduled jobs on the manager read the installed copy.
|
|
49
49
|
if (input.apply) installConfig();
|
|
50
|
-
checks.push(
|
|
50
|
+
checks.push(
|
|
51
|
+
input.apply
|
|
52
|
+
? await holdManagerPower(run, input.workspace, host)
|
|
53
|
+
: await managerPower(run, input.workspace, host)
|
|
54
|
+
);
|
|
51
55
|
checks.push(await managerBootRecovery(run, input.workspace, host));
|
|
52
56
|
checks.push(await managerDocker(run));
|
|
53
57
|
// Each enabled IDE: its settings, pinned version and policy.
|
|
@@ -202,7 +206,8 @@ export async function managerAgentProfiles(
|
|
|
202
206
|
export async function managerIdentity(
|
|
203
207
|
id: string,
|
|
204
208
|
run: Runner,
|
|
205
|
-
host: ManagerHost = MACOS_MANAGER
|
|
209
|
+
host: ManagerHost = MACOS_MANAGER,
|
|
210
|
+
platform: NodeJS.Platform = process.platform
|
|
206
211
|
): Promise<Check> {
|
|
207
212
|
const local = (await host.localHostName(run)) ?? '';
|
|
208
213
|
return local === id
|
|
@@ -216,7 +221,10 @@ export async function managerIdentity(
|
|
|
216
221
|
status: 'fail',
|
|
217
222
|
summary: 'FLEET_NOT_MANAGER',
|
|
218
223
|
remedy: {
|
|
219
|
-
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.`,
|
|
220
228
|
},
|
|
221
229
|
};
|
|
222
230
|
}
|
|
@@ -244,7 +252,7 @@ export async function managerPower(
|
|
|
244
252
|
? 'MANAGER_SLEEPS_ON_POWER'
|
|
245
253
|
: `MANAGER_POWER_POLICY_DRIFT ${drifted.join(' ')}`,
|
|
246
254
|
remedy: {
|
|
247
|
-
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`,
|
|
248
256
|
},
|
|
249
257
|
};
|
|
250
258
|
}
|
|
@@ -283,6 +291,43 @@ export async function managerBootRecovery(
|
|
|
283
291
|
};
|
|
284
292
|
}
|
|
285
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
|
+
|
|
286
331
|
/** The Docker Desktop release `mise.toml` pins, as the engine names itself. */
|
|
287
332
|
export function pinnedDockerDesktop(root = PACKAGE_ROOT): string {
|
|
288
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 &&
|
|
@@ -21,8 +21,11 @@ import type { Check } from '../types';
|
|
|
21
21
|
export interface RunRecord {
|
|
22
22
|
/** Before any machine changes: this revision is the approved one. */
|
|
23
23
|
approve(): void;
|
|
24
|
-
/** Once the result is known. The check says whether the bucket holds it.
|
|
25
|
-
|
|
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>;
|
|
26
29
|
/** For a run that stopped before its result: its approval is still uploaded. */
|
|
27
30
|
abandon(): Promise<void>;
|
|
28
31
|
}
|
|
@@ -70,12 +73,18 @@ export function runRecord(
|
|
|
70
73
|
}
|
|
71
74
|
claimLease(state.context);
|
|
72
75
|
},
|
|
73
|
-
async settle(converged) {
|
|
76
|
+
async settle(converged, applied = {}) {
|
|
74
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);
|
|
75
83
|
if (converged)
|
|
76
84
|
storeEnrollment(
|
|
77
85
|
{
|
|
78
86
|
...approval,
|
|
87
|
+
...workers,
|
|
79
88
|
appliedRevision: revision,
|
|
80
89
|
...(previous?.appliedRevision &&
|
|
81
90
|
previous.appliedRevision !== revision
|
|
@@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { fleet as definition } from '../fleet-config';
|
|
3
3
|
import { sshArgs, shellQuote } from '../ssh';
|
|
4
4
|
import { resolveVaultConfig, usesVault } from '../vault-config';
|
|
5
|
+
import { workerTokenLocation } from '../worker-credentials';
|
|
5
6
|
import type { Check, FleetNode, Runner } from '../types';
|
|
6
7
|
|
|
7
8
|
const TOOL_PATH =
|
|
@@ -15,7 +16,11 @@ const terminal: TerminalRunner = (args) =>
|
|
|
15
16
|
|
|
16
17
|
type VaultState = 'authenticated' | 'unauthenticated' | 'unavailable';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
+
/** The field of the fleet's shared document that held a worker's token
|
|
20
|
+
* before each worker had a document of its own (src/worker-credentials.ts).
|
|
21
|
+
* Read only when the worker's own document holds none, and reported: every
|
|
22
|
+
* worker that can read the shared document can read that field.
|
|
23
|
+
*/
|
|
19
24
|
export function workerTokenField(node: string): string {
|
|
20
25
|
return `FLEET_WORKER_TOKEN_${node.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
21
26
|
}
|
|
@@ -49,7 +54,13 @@ export async function authorizeManager(
|
|
|
49
54
|
*/
|
|
50
55
|
export async function authorizeVault(
|
|
51
56
|
node: FleetNode,
|
|
52
|
-
options: {
|
|
57
|
+
options: {
|
|
58
|
+
interactive: boolean;
|
|
59
|
+
/** Opt-in: the fleet's shared Vault user, typed on the worker's own
|
|
60
|
+
* terminal, when no token of its own was issued.
|
|
61
|
+
*/
|
|
62
|
+
passwordLogin?: boolean;
|
|
63
|
+
},
|
|
53
64
|
run: Runner,
|
|
54
65
|
attach: TerminalRunner = terminal
|
|
55
66
|
): Promise<Check[]> {
|
|
@@ -59,8 +70,10 @@ export async function authorizeVault(
|
|
|
59
70
|
if (before === 'authenticated') return [pass(node)];
|
|
60
71
|
// Fresh node: the locked toolchain (including vault) is not installed yet.
|
|
61
72
|
if (before === 'unavailable') return [];
|
|
62
|
-
|
|
63
|
-
if (
|
|
73
|
+
const delivered = await deliverWorkerToken(node, run);
|
|
74
|
+
if (delivered === 'own') return [pass(node)];
|
|
75
|
+
if (delivered === 'shared') return [pass(node), shared(node)];
|
|
76
|
+
if (!options.interactive || !options.passwordLogin) return [required(node)];
|
|
64
77
|
process.stderr.write(
|
|
65
78
|
`[${node.id}] No issued worker token found. Enter the Vault password to log this machine in.\n`
|
|
66
79
|
);
|
|
@@ -72,30 +85,54 @@ export async function authorizeVault(
|
|
|
72
85
|
];
|
|
73
86
|
}
|
|
74
87
|
|
|
88
|
+
/** Delivers the worker's token, from its own document or else from the
|
|
89
|
+
* field it used to have in the shared one; says which, or that none was.
|
|
90
|
+
*/
|
|
75
91
|
async function deliverWorkerToken(
|
|
76
92
|
node: FleetNode,
|
|
77
93
|
run: Runner
|
|
78
|
-
): Promise<
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
): Promise<'own' | 'shared' | null> {
|
|
95
|
+
const vault = resolveVaultConfig();
|
|
96
|
+
const own = workerTokenLocation(node.id, vault);
|
|
97
|
+
const sources = [
|
|
98
|
+
{ from: 'own' as const, ...own },
|
|
99
|
+
{
|
|
100
|
+
from: 'shared' as const,
|
|
101
|
+
mount: vault.mount,
|
|
102
|
+
path: `${vault.project}/${vault.config}`,
|
|
103
|
+
field: workerTokenField(node.id),
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
const secret = await run(
|
|
108
|
+
[
|
|
109
|
+
'vault',
|
|
110
|
+
'kv',
|
|
111
|
+
'get',
|
|
112
|
+
`-address=${vault.addr}`,
|
|
113
|
+
`-mount=${source.mount}`,
|
|
114
|
+
`-field=${source.field}`,
|
|
115
|
+
source.path,
|
|
116
|
+
],
|
|
117
|
+
{ timeoutMs: 20_000 }
|
|
118
|
+
);
|
|
119
|
+
const token = secret.ok ? secret.stdout.trim() : '';
|
|
120
|
+
if (!/^[\x21-\x7e]{16,4096}$/.test(token)) continue;
|
|
121
|
+
const delivered = await run(
|
|
122
|
+
remote(node, [
|
|
123
|
+
'vault',
|
|
124
|
+
'login',
|
|
125
|
+
`-address=${vault.addr}`,
|
|
126
|
+
'-no-print',
|
|
127
|
+
'-',
|
|
128
|
+
]),
|
|
129
|
+
{ input: token + '\n', timeoutMs: 30_000 }
|
|
130
|
+
);
|
|
131
|
+
return delivered.ok && (await vaultState(node, run)) === 'authenticated'
|
|
132
|
+
? source.from
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
99
136
|
}
|
|
100
137
|
|
|
101
138
|
async function vaultState(node: FleetNode, run: Runner): Promise<VaultState> {
|
|
@@ -153,6 +190,18 @@ function pass(node: FleetNode): Check {
|
|
|
153
190
|
};
|
|
154
191
|
}
|
|
155
192
|
|
|
193
|
+
function shared(node: FleetNode): Check {
|
|
194
|
+
const own = workerTokenLocation(node.id);
|
|
195
|
+
return {
|
|
196
|
+
id: 'auth:vault-isolation',
|
|
197
|
+
status: 'warn',
|
|
198
|
+
summary: 'VAULT_WORKER_TOKEN_SHARED',
|
|
199
|
+
remedy: {
|
|
200
|
+
action: `${node.id}'s token was read from the fleet's shared document, which every worker can read. Issue it a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes (kept at ${own.mount}/${own.path}), then remove ${workerTokenField(node.id)} from the shared document.`,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
156
205
|
function required(node: FleetNode): Check {
|
|
157
206
|
const { addr } = resolveVaultConfig();
|
|
158
207
|
return {
|
|
@@ -160,7 +209,7 @@ function required(node: FleetNode): Check {
|
|
|
160
209
|
status: 'fail',
|
|
161
210
|
summary: 'VAULT_LOGIN_REQUIRED',
|
|
162
211
|
remedy: {
|
|
163
|
-
action: `
|
|
212
|
+
action: `Issue ${node.id} a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes. Opt-in fallback: devfleet fleet-sync --vault-password-login in a terminal, or on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
|
|
164
213
|
},
|
|
165
214
|
};
|
|
166
215
|
}
|
package/src/cli/fleet-sync.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ManagerReport } from '../manager-sync';
|
|
1
2
|
import { localCheckoutRevision } from '../machine/local-source';
|
|
2
3
|
import {
|
|
3
4
|
definitionSource,
|
|
@@ -63,6 +64,14 @@ export interface FleetSyncOptions {
|
|
|
63
64
|
json?: boolean;
|
|
64
65
|
nonInteractive?: boolean;
|
|
65
66
|
node?: string[];
|
|
67
|
+
/** The worker to prove a revision new to the fleet on first, when it is
|
|
68
|
+
* reachable; overrides `canary` in fleet.json.
|
|
69
|
+
*/
|
|
70
|
+
canary?: string;
|
|
71
|
+
/** Opt-in: a worker without a token of its own may be logged in with the
|
|
72
|
+
* fleet's shared Vault user, typed on its own terminal.
|
|
73
|
+
*/
|
|
74
|
+
vaultPasswordLogin?: boolean;
|
|
66
75
|
all?: boolean;
|
|
67
76
|
schedule?: ScheduleAction;
|
|
68
77
|
/** `accounts`: only the account-derived configuration, on every node. */
|
|
@@ -316,7 +325,7 @@ function applyLocked(
|
|
|
316
325
|
{ ...request, rolledOut: state?.appliedRevision === revision },
|
|
317
326
|
settings,
|
|
318
327
|
run,
|
|
319
|
-
(report) => record.settle(report.ok)
|
|
328
|
+
(report) => record.settle(report.ok, appliedRevisions(report))
|
|
320
329
|
);
|
|
321
330
|
} finally {
|
|
322
331
|
await record.abandon();
|
|
@@ -356,7 +365,7 @@ async function offManager(
|
|
|
356
365
|
if (forwardingDisabled())
|
|
357
366
|
return actionRequired(
|
|
358
367
|
'FLEET_NOT_MANAGER',
|
|
359
|
-
`fleet-sync runs only on the manager (${definition.manager.id}); nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
368
|
+
`fleet-sync runs only on the manager (${definition.manager.id}), which is always a macOS machine; nothing was changed. Run it there, or from a worker that can SSH to the manager.`,
|
|
360
369
|
revision ?? null
|
|
361
370
|
);
|
|
362
371
|
const runScript = revision
|
|
@@ -520,3 +529,14 @@ async function selectedRevision(
|
|
|
520
529
|
approved
|
|
521
530
|
);
|
|
522
531
|
}
|
|
532
|
+
|
|
533
|
+
/** The revision each worker holds after this run, for the fleet state: a
|
|
534
|
+
* worker that converged keeps its progress even when the fleet is not ready.
|
|
535
|
+
*/
|
|
536
|
+
function appliedRevisions(report: ManagerReport): Record<string, string> {
|
|
537
|
+
return Object.fromEntries(
|
|
538
|
+
report.nodes
|
|
539
|
+
.filter((node) => node.role !== 'manager' && node.appliedRevision)
|
|
540
|
+
.map((node) => [node.node, node.appliedRevision!])
|
|
541
|
+
);
|
|
542
|
+
}
|