@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,102 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { FleetUsageError } from '../errors';
|
|
3
|
+
import { runCommand } from '../runner';
|
|
4
|
+
import type { Runner } from '../types';
|
|
5
|
+
import {
|
|
6
|
+
declaredDifferences,
|
|
7
|
+
diffManifests,
|
|
8
|
+
manifestCheck,
|
|
9
|
+
manifestDigest,
|
|
10
|
+
measureManifest,
|
|
11
|
+
ubuntuPolicy,
|
|
12
|
+
type WorkerManifest,
|
|
13
|
+
} from '../worker-manifest';
|
|
14
|
+
import type { CommandOutcome } from './contracts';
|
|
15
|
+
import { readRoster } from './roster';
|
|
16
|
+
|
|
17
|
+
export interface FleetManifestOptions {
|
|
18
|
+
id?: string;
|
|
19
|
+
/** A manifest from a fresh build of the same revision (fleet-manifest
|
|
20
|
+
* --json of that build) to compare the worker with.
|
|
21
|
+
*/
|
|
22
|
+
reference?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface FleetManifestServices {
|
|
26
|
+
run: Runner;
|
|
27
|
+
roster: string;
|
|
28
|
+
root?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const manifestServices = (roster: string): FleetManifestServices => ({
|
|
32
|
+
run: runCommand,
|
|
33
|
+
roster,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function reference(path: string): Promise<WorkerManifest> {
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(await readFile(path, 'utf8')) as {
|
|
39
|
+
manifest?: WorkerManifest;
|
|
40
|
+
} & Partial<WorkerManifest>;
|
|
41
|
+
const manifest = value.manifest ?? (value as WorkerManifest);
|
|
42
|
+
if (manifest.version !== 1 || !manifest.packages) throw new Error();
|
|
43
|
+
return manifest;
|
|
44
|
+
} catch {
|
|
45
|
+
throw new FleetUsageError(
|
|
46
|
+
'--reference must name a manifest written by devfleet fleet-manifest --json.'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Read-only: measures what an enrolled worker is made of, and the check
|
|
52
|
+
* worker:manifest against the declared pins and, with --reference, a fresh
|
|
53
|
+
* build of the same revision.
|
|
54
|
+
*/
|
|
55
|
+
export async function fleetManifest(
|
|
56
|
+
options: FleetManifestOptions,
|
|
57
|
+
services: FleetManifestServices
|
|
58
|
+
): Promise<CommandOutcome> {
|
|
59
|
+
const roster = await readRoster(services.roster);
|
|
60
|
+
const node = roster?.nodes.find((entry) => entry.id === options.id);
|
|
61
|
+
if (!node) throw new FleetUsageError('--id must name an enrolled worker.');
|
|
62
|
+
const fresh = options.reference
|
|
63
|
+
? await reference(options.reference)
|
|
64
|
+
: undefined;
|
|
65
|
+
const policy = await ubuntuPolicy(services.root);
|
|
66
|
+
const measured = await measureManifest(node, services.run, policy);
|
|
67
|
+
if ('check' in measured)
|
|
68
|
+
return {
|
|
69
|
+
exitCode: 1,
|
|
70
|
+
data: {
|
|
71
|
+
version: 1,
|
|
72
|
+
command: 'fleet-manifest',
|
|
73
|
+
ok: false,
|
|
74
|
+
node: node.id,
|
|
75
|
+
checks: [measured.check],
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const { manifest } = measured;
|
|
79
|
+
const differences = [
|
|
80
|
+
...declaredDifferences(manifest, policy),
|
|
81
|
+
...(fresh ? diffManifests(fresh, manifest) : []),
|
|
82
|
+
];
|
|
83
|
+
const check = manifestCheck(differences);
|
|
84
|
+
const digest = manifestDigest(manifest);
|
|
85
|
+
return {
|
|
86
|
+
exitCode: check.status === 'fail' ? 1 : 0,
|
|
87
|
+
data: {
|
|
88
|
+
version: 1,
|
|
89
|
+
command: 'fleet-manifest',
|
|
90
|
+
ok: check.status !== 'fail',
|
|
91
|
+
node: node.id,
|
|
92
|
+
digest,
|
|
93
|
+
manifest,
|
|
94
|
+
differences,
|
|
95
|
+
checks: [check],
|
|
96
|
+
},
|
|
97
|
+
text: [
|
|
98
|
+
`${node.id}: ${Object.keys(manifest.packages).length} packages, ${Object.keys(manifest.tools).length} tools, digest ${digest}`,
|
|
99
|
+
`${check.status.toUpperCase()} ${check.id}: ${check.summary}`,
|
|
100
|
+
].join('\n'),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseConfig } from '../config';
|
|
4
|
+
import type { FleetDefinition, MachineDefinition } from '../definition';
|
|
5
|
+
import { FleetUsageError } from '../errors';
|
|
6
|
+
import { fleet } from '../fleet-config';
|
|
7
|
+
import type { Enrollment } from '../machine/state';
|
|
8
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
9
|
+
import { runAnsible } from '../platform/ansible';
|
|
10
|
+
import { makeReport, renderReport } from '../report';
|
|
11
|
+
import { runCommand } from '../runner';
|
|
12
|
+
import { loadEnrollment, storeEnrollment } from '../state/enrollment';
|
|
13
|
+
import { STATE_ROSTER, writeRosterText } from '../state/roster';
|
|
14
|
+
import { mutateState, stateContext, type StateContext } from '../state/sync';
|
|
15
|
+
import { discoverTailnet, type TailnetComputer } from '../tailscale';
|
|
16
|
+
import type { Check, FleetConfig, Runner } from '../types';
|
|
17
|
+
import { usesVault } from '../vault-config';
|
|
18
|
+
import {
|
|
19
|
+
issueWorkerToken,
|
|
20
|
+
revokeWorkerToken,
|
|
21
|
+
workerTokenLocation,
|
|
22
|
+
} from '../worker-credentials';
|
|
23
|
+
import type { CommandOutcome } from './contracts';
|
|
24
|
+
import { readRoster } from './roster';
|
|
25
|
+
|
|
26
|
+
export interface NodeLifecycleOptions {
|
|
27
|
+
id?: string;
|
|
28
|
+
/** The replacement's SSH host key, `TYPE BASE64`, verified out of band or
|
|
29
|
+
* written by the seed that created it.
|
|
30
|
+
*/
|
|
31
|
+
hostKey?: string;
|
|
32
|
+
apply?: boolean;
|
|
33
|
+
yes?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface NodeLifecycleServices {
|
|
37
|
+
run: Runner;
|
|
38
|
+
/** The roster: the fleet state's, or a file `--config` names. */
|
|
39
|
+
roster: string;
|
|
40
|
+
definition: () => FleetDefinition;
|
|
41
|
+
vault: () => boolean;
|
|
42
|
+
/** The manager's known_hosts file. */
|
|
43
|
+
knownHosts: string;
|
|
44
|
+
state: () => StateContext;
|
|
45
|
+
/** Applies a playbook of the package with these variables. */
|
|
46
|
+
playbook: (name: string, variables: object) => Promise<boolean>;
|
|
47
|
+
now: () => Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function lifecycleServices(roster: string): NodeLifecycleServices {
|
|
51
|
+
return {
|
|
52
|
+
run: runCommand,
|
|
53
|
+
roster,
|
|
54
|
+
definition: () => fleet,
|
|
55
|
+
vault: usesVault,
|
|
56
|
+
knownHosts: join(homedir(), '.ssh/known_hosts'),
|
|
57
|
+
state: () => stateContext(runCommand),
|
|
58
|
+
playbook: async (name, variables) =>
|
|
59
|
+
(
|
|
60
|
+
await runAnsible(runCommand, join(PACKAGE_ROOT, 'ansible', name), {
|
|
61
|
+
input: JSON.stringify(variables),
|
|
62
|
+
timeoutMs: 300_000,
|
|
63
|
+
})
|
|
64
|
+
).ok,
|
|
65
|
+
now: () => new Date(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function declared(
|
|
70
|
+
definition: FleetDefinition,
|
|
71
|
+
id: string | undefined
|
|
72
|
+
): MachineDefinition {
|
|
73
|
+
const worker = definition.workers.find((entry) => entry.id === id);
|
|
74
|
+
if (!worker)
|
|
75
|
+
throw new FleetUsageError(
|
|
76
|
+
'--id must name a worker fleet.json declares. To retire a worker, retire it first and then remove it from fleet.json.'
|
|
77
|
+
);
|
|
78
|
+
return worker;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The name ssh checks the host key of: the HostName the alias resolves to,
|
|
82
|
+
* as `[host]:port` when the port is not 22.
|
|
83
|
+
*/
|
|
84
|
+
export async function keyName(
|
|
85
|
+
worker: MachineDefinition,
|
|
86
|
+
run: Runner
|
|
87
|
+
): Promise<string> {
|
|
88
|
+
const resolved = await run(['ssh', '-G', worker.ssh], { timeoutMs: 10_000 });
|
|
89
|
+
const option = (name: string) =>
|
|
90
|
+
resolved.ok
|
|
91
|
+
? new RegExp(`^${name} (\\S+)$`, 'm').exec(resolved.stdout)?.[1]
|
|
92
|
+
: undefined;
|
|
93
|
+
const host =
|
|
94
|
+
option('hostname') ?? worker.network?.tailscaleName ?? worker.ssh;
|
|
95
|
+
const port = option('port') ?? String(worker.network?.sshPort ?? 22);
|
|
96
|
+
return port === '22' ? host : `[${host}]:${port}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Changes the roster in the fleet state (or the file `--config` names). */
|
|
100
|
+
async function changeRoster(
|
|
101
|
+
services: NodeLifecycleServices,
|
|
102
|
+
change: (roster: FleetConfig | null) => FleetConfig | null
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
const save = async () => {
|
|
105
|
+
const next = change(await readRoster(services.roster));
|
|
106
|
+
if (next)
|
|
107
|
+
await writeRosterText(
|
|
108
|
+
services.roster,
|
|
109
|
+
`${JSON.stringify(parseConfig(next), null, 2)}\n`
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
if (services.roster === STATE_ROSTER)
|
|
113
|
+
await mutateState(services.state(), save);
|
|
114
|
+
else await save();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function outcome(
|
|
118
|
+
command: string,
|
|
119
|
+
id: string,
|
|
120
|
+
checks: Check[],
|
|
121
|
+
applied: boolean
|
|
122
|
+
): CommandOutcome {
|
|
123
|
+
const report = { ...makeReport(command, [{ node: id, checks }]), applied };
|
|
124
|
+
return {
|
|
125
|
+
exitCode: checks.some((check) => check.status === 'fail') ? 1 : 0,
|
|
126
|
+
data: report,
|
|
127
|
+
text: renderReport(report),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const planned = (id: string, summary: string): Check => ({
|
|
132
|
+
id,
|
|
133
|
+
status: 'skip',
|
|
134
|
+
summary: `Planned: ${summary}`,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const preview = (options: NodeLifecycleOptions): boolean =>
|
|
138
|
+
!(options.apply && options.yes);
|
|
139
|
+
|
|
140
|
+
/** Takes a worker out of the fleet for good: revokes its Vault token, forgets
|
|
141
|
+
* its pinned host key, removes its roster entry and leaves a tombstone in the
|
|
142
|
+
* fleet state. Preview until --apply --yes. Running it again changes nothing.
|
|
143
|
+
*/
|
|
144
|
+
export async function fleetNodeRetire(
|
|
145
|
+
options: NodeLifecycleOptions,
|
|
146
|
+
services: NodeLifecycleServices
|
|
147
|
+
): Promise<CommandOutcome> {
|
|
148
|
+
const worker = declared(services.definition(), options.id);
|
|
149
|
+
const roster = await readRoster(services.roster);
|
|
150
|
+
const enrolled = roster?.nodes.some((node) => node.id === worker.id) ?? false;
|
|
151
|
+
if (enrolled && roster!.nodes.length === 1)
|
|
152
|
+
throw new FleetUsageError(
|
|
153
|
+
'The roster must keep at least one worker. Add the replacement with fleet-node-add first.'
|
|
154
|
+
);
|
|
155
|
+
const host = await keyName(worker, services.run);
|
|
156
|
+
if (preview(options))
|
|
157
|
+
return outcome(
|
|
158
|
+
'fleet-node-retire',
|
|
159
|
+
worker.id,
|
|
160
|
+
[
|
|
161
|
+
...(services.vault()
|
|
162
|
+
? [planned('auth:worker-token', `revoke ${worker.id}'s Vault token`)]
|
|
163
|
+
: []),
|
|
164
|
+
planned('ssh:host-key', `forget the host key of ${host}`),
|
|
165
|
+
planned(
|
|
166
|
+
'enrollment:connection',
|
|
167
|
+
enrolled
|
|
168
|
+
? `remove ${worker.id} from the roster`
|
|
169
|
+
: `${worker.id} is not in the roster`
|
|
170
|
+
),
|
|
171
|
+
planned('state:retired', `record that ${worker.id} is retired`),
|
|
172
|
+
],
|
|
173
|
+
false
|
|
174
|
+
);
|
|
175
|
+
const checks: Check[] = [];
|
|
176
|
+
if (services.vault())
|
|
177
|
+
checks.push(await revokeWorkerToken(worker.id, services.run));
|
|
178
|
+
checks.push(
|
|
179
|
+
(await services.playbook('macos/worker-host-key.yml', {
|
|
180
|
+
host,
|
|
181
|
+
pin: 'absent',
|
|
182
|
+
known_hosts: services.knownHosts,
|
|
183
|
+
}))
|
|
184
|
+
? {
|
|
185
|
+
id: 'ssh:host-key',
|
|
186
|
+
status: 'pass',
|
|
187
|
+
summary: `The manager no longer trusts any host key of ${host}.`,
|
|
188
|
+
}
|
|
189
|
+
: hostKeyFailure()
|
|
190
|
+
);
|
|
191
|
+
await changeRoster(services, (current) =>
|
|
192
|
+
current && current.nodes.some((node) => node.id === worker.id)
|
|
193
|
+
? {
|
|
194
|
+
...current,
|
|
195
|
+
nodes: current.nodes.filter((node) => node.id !== worker.id),
|
|
196
|
+
}
|
|
197
|
+
: null
|
|
198
|
+
);
|
|
199
|
+
checks.push({
|
|
200
|
+
id: 'enrollment:connection',
|
|
201
|
+
status: 'pass',
|
|
202
|
+
summary: `${worker.id} is not in the roster.`,
|
|
203
|
+
});
|
|
204
|
+
checks.push(await tombstone(worker.id, services));
|
|
205
|
+
return outcome('fleet-node-retire', worker.id, checks, true);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function tombstone(
|
|
209
|
+
id: string,
|
|
210
|
+
services: NodeLifecycleServices
|
|
211
|
+
): Promise<Check> {
|
|
212
|
+
const record = async (): Promise<Enrollment | null> => {
|
|
213
|
+
const enrollment = await loadEnrollment(null);
|
|
214
|
+
if (!enrollment) return null;
|
|
215
|
+
const { [id]: _applied, ...workers } = enrollment.workers ?? {};
|
|
216
|
+
const retired = {
|
|
217
|
+
...enrollment,
|
|
218
|
+
workers,
|
|
219
|
+
retired: {
|
|
220
|
+
...enrollment.retired,
|
|
221
|
+
[id]: enrollment.retired?.[id] ?? services.now().toISOString(),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
storeEnrollment(retired);
|
|
225
|
+
return retired;
|
|
226
|
+
};
|
|
227
|
+
const stored = (await mutateState(services.state(), record)).value;
|
|
228
|
+
return stored
|
|
229
|
+
? {
|
|
230
|
+
id: 'state:retired',
|
|
231
|
+
status: 'pass',
|
|
232
|
+
summary: `The fleet state records that ${id} was retired at ${stored.retired![id]}.`,
|
|
233
|
+
}
|
|
234
|
+
: {
|
|
235
|
+
id: 'state:retired',
|
|
236
|
+
status: 'skip',
|
|
237
|
+
summary:
|
|
238
|
+
'No revision was ever applied to this fleet, so there is nothing to record.',
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Puts a fresh machine in place of a worker under the same id: verifies the
|
|
243
|
+
* new device on the tailnet, pins its host key, binds the roster entry to the
|
|
244
|
+
* new device and issues it a token of its own (revoking the old one). Preview
|
|
245
|
+
* until --apply --yes; then `fleet-sync --node ID` brings it to ready. When
|
|
246
|
+
* nothing changed (the same device, the same key, a token of its own) it
|
|
247
|
+
* changes nothing.
|
|
248
|
+
*/
|
|
249
|
+
export async function fleetNodeReplace(
|
|
250
|
+
options: NodeLifecycleOptions,
|
|
251
|
+
services: NodeLifecycleServices
|
|
252
|
+
): Promise<CommandOutcome> {
|
|
253
|
+
const worker = declared(services.definition(), options.id);
|
|
254
|
+
const entry = await replaceable(worker, options, services);
|
|
255
|
+
const device = await newDevice(worker, services.run);
|
|
256
|
+
if ('check' in device)
|
|
257
|
+
return outcome('fleet-node-replace', worker.id, [device.check], false);
|
|
258
|
+
const plan: Replacement = {
|
|
259
|
+
worker,
|
|
260
|
+
device: device.computer.id,
|
|
261
|
+
host: await keyName(worker, services.run),
|
|
262
|
+
hostKey: options.hostKey,
|
|
263
|
+
replaced: entry.tailscaleId !== device.computer.id,
|
|
264
|
+
needsToken: false,
|
|
265
|
+
};
|
|
266
|
+
plan.needsToken =
|
|
267
|
+
services.vault() &&
|
|
268
|
+
(plan.replaced || !(await ownToken(worker.id, services.run)));
|
|
269
|
+
const identity: Check = {
|
|
270
|
+
id: 'tailscale:identity',
|
|
271
|
+
status: 'pass',
|
|
272
|
+
summary: `${worker.id} is the online device ${plan.device}${plan.replaced ? `, replacing ${entry.tailscaleId ?? 'none'}` : ''}.`,
|
|
273
|
+
};
|
|
274
|
+
if (preview(options))
|
|
275
|
+
return outcome(
|
|
276
|
+
'fleet-node-replace',
|
|
277
|
+
worker.id,
|
|
278
|
+
[identity, ...planReplacement(plan)],
|
|
279
|
+
false
|
|
280
|
+
);
|
|
281
|
+
return outcome(
|
|
282
|
+
'fleet-node-replace',
|
|
283
|
+
worker.id,
|
|
284
|
+
[identity, ...(await carryOut(plan, services))],
|
|
285
|
+
true
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
interface Replacement {
|
|
290
|
+
worker: MachineDefinition;
|
|
291
|
+
device: string;
|
|
292
|
+
host: string;
|
|
293
|
+
hostKey: string | undefined;
|
|
294
|
+
replaced: boolean;
|
|
295
|
+
needsToken: boolean;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function replaceable(
|
|
299
|
+
worker: MachineDefinition,
|
|
300
|
+
options: NodeLifecycleOptions,
|
|
301
|
+
services: NodeLifecycleServices
|
|
302
|
+
) {
|
|
303
|
+
if (
|
|
304
|
+
options.hostKey !== undefined &&
|
|
305
|
+
!/^(ssh-ed25519|ecdsa-sha2-nistp256|ssh-rsa) [A-Za-z0-9+/=]{16,}$/.test(
|
|
306
|
+
options.hostKey
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
throw new FleetUsageError(
|
|
310
|
+
'--host-key expects one public key, "TYPE BASE64", as ssh-keyscan or the seed prints it.'
|
|
311
|
+
);
|
|
312
|
+
const roster = await readRoster(services.roster);
|
|
313
|
+
const entry = roster?.nodes.find((node) => node.id === worker.id);
|
|
314
|
+
if (!entry)
|
|
315
|
+
throw new FleetUsageError(
|
|
316
|
+
`${worker.id} is not in the roster. Add it with fleet-node-add first.`
|
|
317
|
+
);
|
|
318
|
+
return entry;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function planReplacement(plan: Replacement): Check[] {
|
|
322
|
+
const steps: Check[] = [];
|
|
323
|
+
if (plan.hostKey)
|
|
324
|
+
steps.push(planned('ssh:host-key', `pin the host key of ${plan.host}`));
|
|
325
|
+
if (plan.replaced)
|
|
326
|
+
steps.push(
|
|
327
|
+
planned(
|
|
328
|
+
'enrollment:connection',
|
|
329
|
+
`bind ${plan.worker.id} to ${plan.device}`
|
|
330
|
+
)
|
|
331
|
+
);
|
|
332
|
+
if (plan.needsToken)
|
|
333
|
+
steps.push(
|
|
334
|
+
planned('auth:worker-token', `issue ${plan.worker.id} a token of its own`)
|
|
335
|
+
);
|
|
336
|
+
return steps;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function carryOut(
|
|
340
|
+
plan: Replacement,
|
|
341
|
+
services: NodeLifecycleServices
|
|
342
|
+
): Promise<Check[]> {
|
|
343
|
+
const { worker } = plan;
|
|
344
|
+
const steps: Check[] = [];
|
|
345
|
+
if (plan.hostKey)
|
|
346
|
+
steps.push(
|
|
347
|
+
(await services.playbook('macos/worker-host-key.yml', {
|
|
348
|
+
host: plan.host,
|
|
349
|
+
pin: 'present',
|
|
350
|
+
key: plan.hostKey,
|
|
351
|
+
known_hosts: services.knownHosts,
|
|
352
|
+
}))
|
|
353
|
+
? {
|
|
354
|
+
id: 'ssh:host-key',
|
|
355
|
+
status: 'pass',
|
|
356
|
+
summary: `The manager trusts exactly the given host key of ${plan.host}.`,
|
|
357
|
+
}
|
|
358
|
+
: hostKeyFailure()
|
|
359
|
+
);
|
|
360
|
+
if (plan.replaced) {
|
|
361
|
+
await changeRoster(services, (current) =>
|
|
362
|
+
current
|
|
363
|
+
? {
|
|
364
|
+
...current,
|
|
365
|
+
nodes: current.nodes.map((node) =>
|
|
366
|
+
node.id === worker.id
|
|
367
|
+
? { ...node, tailscaleId: plan.device }
|
|
368
|
+
: node
|
|
369
|
+
),
|
|
370
|
+
}
|
|
371
|
+
: null
|
|
372
|
+
);
|
|
373
|
+
steps.push({
|
|
374
|
+
id: 'enrollment:connection',
|
|
375
|
+
status: 'pass',
|
|
376
|
+
summary: `The roster binds ${worker.id} to the device ${plan.device}.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (plan.needsToken) {
|
|
380
|
+
const revoked = await revokeWorkerToken(worker.id, services.run);
|
|
381
|
+
steps.push(
|
|
382
|
+
revoked.status === 'fail'
|
|
383
|
+
? revoked
|
|
384
|
+
: await issueWorkerToken(worker.id, services.run)
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
steps.push({
|
|
388
|
+
id: 'rollout:next',
|
|
389
|
+
status: 'pass',
|
|
390
|
+
summary: `Run devfleet fleet-sync --node ${worker.id} to converge the new machine.`,
|
|
391
|
+
});
|
|
392
|
+
return steps;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function ownToken(id: string, run: Runner): Promise<boolean> {
|
|
396
|
+
const location = workerTokenLocation(id);
|
|
397
|
+
return (
|
|
398
|
+
await run(
|
|
399
|
+
[
|
|
400
|
+
'vault',
|
|
401
|
+
'kv',
|
|
402
|
+
'get',
|
|
403
|
+
`-mount=${location.mount}`,
|
|
404
|
+
'-field=accessor',
|
|
405
|
+
location.path,
|
|
406
|
+
],
|
|
407
|
+
{ timeoutMs: 20_000 }
|
|
408
|
+
)
|
|
409
|
+
).ok;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The one online computer on the tailnet that carries the worker's name. */
|
|
413
|
+
async function newDevice(
|
|
414
|
+
worker: MachineDefinition,
|
|
415
|
+
run: Runner
|
|
416
|
+
): Promise<{ computer: TailnetComputer } | { check: Check }> {
|
|
417
|
+
const tailnet = await discoverTailnet(run);
|
|
418
|
+
const name = (worker.network?.tailscaleName ?? worker.ssh).toLowerCase();
|
|
419
|
+
const short = name.split('.')[0]!;
|
|
420
|
+
const found = tailnet.computers.filter(
|
|
421
|
+
(computer) =>
|
|
422
|
+
!computer.self &&
|
|
423
|
+
computer.online &&
|
|
424
|
+
[computer.dns.toLowerCase(), computer.name.toLowerCase()].some(
|
|
425
|
+
(candidate) => candidate === name || candidate.split('.')[0] === short
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
if (found.length === 1) return { computer: found[0]! };
|
|
429
|
+
return {
|
|
430
|
+
check: {
|
|
431
|
+
id: 'tailscale:identity',
|
|
432
|
+
status: 'fail',
|
|
433
|
+
summary: found.length
|
|
434
|
+
? 'TAILSCALE_IDENTITY_CONFLICT'
|
|
435
|
+
: 'TAILSCALE_NODE_MISSING',
|
|
436
|
+
remedy: {
|
|
437
|
+
action: found.length
|
|
438
|
+
? `More than one online device is named like ${worker.id}. Remove the old device from the tailnet, then run the command again.`
|
|
439
|
+
: `No online device is named like ${worker.id}. Start the new machine, join it to the tailnet under that name, then run the command again.`,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function hostKeyFailure(): Check {
|
|
446
|
+
return {
|
|
447
|
+
id: 'ssh:host-key',
|
|
448
|
+
status: 'fail',
|
|
449
|
+
summary: 'SSH_HOST_KEY_NOT_CHANGED',
|
|
450
|
+
remedy: {
|
|
451
|
+
action:
|
|
452
|
+
'The pinned Ansible could not change known_hosts on this manager. Run devfleet fleet-validate --ansible, then this command again.',
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
@@ -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
|
+
}
|