@crvouga/dev-fleet 0.8.0 → 0.9.1
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 +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capability-checks.ts +55 -16
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { FleetUsageError } from '../errors';
|
|
3
|
+
import { fleet } from '../fleet-config';
|
|
4
|
+
import { declaredFor, workerFeatures } from '../worker-features';
|
|
3
5
|
import { runCommand } from '../runner';
|
|
4
6
|
import type { Runner } from '../types';
|
|
5
7
|
import {
|
|
@@ -62,7 +64,11 @@ export async function fleetManifest(
|
|
|
62
64
|
const fresh = options.reference
|
|
63
65
|
? await reference(options.reference)
|
|
64
66
|
: undefined;
|
|
65
|
-
|
|
67
|
+
// A worker is measured against what its own features install.
|
|
68
|
+
const policy = declaredFor(
|
|
69
|
+
await ubuntuPolicy(services.root),
|
|
70
|
+
workerFeatures(fleet, node.id)
|
|
71
|
+
);
|
|
66
72
|
const measured = await measureManifest(node, services.run, policy);
|
|
67
73
|
if ('check' in measured)
|
|
68
74
|
return {
|
|
@@ -75,9 +81,9 @@ export async function fleetManifest(
|
|
|
75
81
|
checks: [measured.check],
|
|
76
82
|
},
|
|
77
83
|
};
|
|
78
|
-
const { manifest } = measured;
|
|
84
|
+
const { manifest, release } = measured;
|
|
79
85
|
const differences = [
|
|
80
|
-
...declaredDifferences(manifest, policy),
|
|
86
|
+
...declaredDifferences(manifest, policy, release),
|
|
81
87
|
...(fresh ? diffManifests(fresh, manifest) : []),
|
|
82
88
|
];
|
|
83
89
|
const check = manifestCheck(differences);
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { parseConfig } from '../config';
|
|
4
|
-
import
|
|
4
|
+
import {
|
|
5
|
+
HOST_KEY,
|
|
6
|
+
type FleetDefinition,
|
|
7
|
+
type MachineDefinition,
|
|
8
|
+
} from '../definition';
|
|
9
|
+
import { hubAccess } from '../monitoring/hub-access';
|
|
10
|
+
import type { HubRequest } from '../monitoring/enroll';
|
|
11
|
+
import { moveSystem, removeSystem } from '../monitoring/systems';
|
|
12
|
+
import {
|
|
13
|
+
declaredConnection,
|
|
14
|
+
hostKeyAlias,
|
|
15
|
+
renderKnownHosts,
|
|
16
|
+
} from '../ssh-connection';
|
|
17
|
+
import { changeWorkers, readPins } from '../state/workers';
|
|
18
|
+
import { hubUrl } from '../topology';
|
|
5
19
|
import { FleetUsageError } from '../errors';
|
|
6
|
-
import { fleet } from '../fleet-config';
|
|
20
|
+
import { fleet, secret } from '../fleet-config';
|
|
21
|
+
import {
|
|
22
|
+
deviceLeftToPerson,
|
|
23
|
+
tailnetCleanup,
|
|
24
|
+
type TailnetCleanup,
|
|
25
|
+
} from '../tailnet-cleanup';
|
|
7
26
|
import type { Enrollment } from '../machine/state';
|
|
8
27
|
import { PACKAGE_ROOT } from '../package-info';
|
|
9
28
|
import { runAnsible } from '../platform/ansible';
|
|
@@ -13,8 +32,10 @@ import { loadEnrollment, storeEnrollment } from '../state/enrollment';
|
|
|
13
32
|
import { STATE_ROSTER, writeRosterText } from '../state/roster';
|
|
14
33
|
import { mutateState, stateContext, type StateContext } from '../state/sync';
|
|
15
34
|
import { discoverTailnet, type TailnetComputer } from '../tailscale';
|
|
16
|
-
import type { Check, FleetConfig, Runner } from '../types';
|
|
35
|
+
import type { Check, FleetConfig, FleetNode, Runner } from '../types';
|
|
17
36
|
import { usesVault } from '../vault-config';
|
|
37
|
+
import { managerT3, type ManagerT3 } from '../t3/peer-link';
|
|
38
|
+
import { pairAndRecord } from '../t3/pairing';
|
|
18
39
|
import {
|
|
19
40
|
issueWorkerToken,
|
|
20
41
|
revokeWorkerToken,
|
|
@@ -45,8 +66,22 @@ export interface NodeLifecycleServices {
|
|
|
45
66
|
/** Applies a playbook of the package with these variables. */
|
|
46
67
|
playbook: (name: string, variables: object) => Promise<boolean>;
|
|
47
68
|
now: () => Date;
|
|
69
|
+
/** The monitoring hub's API, signed in as the fleet account. Absent, the
|
|
70
|
+
* hub is left as it is and the step says so.
|
|
71
|
+
*/
|
|
72
|
+
monitoring?: () => Promise<HubRequest>;
|
|
73
|
+
/** Removes a worker's old device from the tailnet, so a new machine gets
|
|
74
|
+
* the declared name: the control plane fleet.json declares, asked with the
|
|
75
|
+
* `tailnet-admin` secret. Absent, no device is removed and the step is
|
|
76
|
+
* reported as skipped, with what a person does instead.
|
|
77
|
+
*/
|
|
78
|
+
tailnet?: TailnetCleanup;
|
|
79
|
+
/** The manager's own T3, which pairs with a replacement's environment. */
|
|
80
|
+
t3: () => ManagerT3;
|
|
48
81
|
}
|
|
49
82
|
|
|
83
|
+
export type { TailnetCleanup };
|
|
84
|
+
|
|
50
85
|
export function lifecycleServices(roster: string): NodeLifecycleServices {
|
|
51
86
|
return {
|
|
52
87
|
run: runCommand,
|
|
@@ -63,6 +98,12 @@ export function lifecycleServices(roster: string): NodeLifecycleServices {
|
|
|
63
98
|
})
|
|
64
99
|
).ok,
|
|
65
100
|
now: () => new Date(),
|
|
101
|
+
monitoring: async () => (await hubAccess(hubUrl(fleet))).request,
|
|
102
|
+
// Only a fleet that declares a tailnet control plane removes devices.
|
|
103
|
+
...(fleet.tailnet
|
|
104
|
+
? { tailnet: tailnetCleanup(fleet.tailnet, secret) }
|
|
105
|
+
: {}),
|
|
106
|
+
t3: () => managerT3(runCommand),
|
|
66
107
|
};
|
|
67
108
|
}
|
|
68
109
|
|
|
@@ -85,6 +126,8 @@ export async function keyName(
|
|
|
85
126
|
worker: MachineDefinition,
|
|
86
127
|
run: Runner
|
|
87
128
|
): Promise<string> {
|
|
129
|
+
// A worker reached from declared data is pinned under its fleet ID.
|
|
130
|
+
if (!worker.ssh) return hostKeyAlias(worker.id);
|
|
88
131
|
const resolved = await run(['ssh', '-G', worker.ssh], { timeoutMs: 10_000 });
|
|
89
132
|
const option = (name: string) =>
|
|
90
133
|
resolved.ok
|
|
@@ -137,9 +180,11 @@ const planned = (id: string, summary: string): Check => ({
|
|
|
137
180
|
const preview = (options: NodeLifecycleOptions): boolean =>
|
|
138
181
|
!(options.apply && options.yes);
|
|
139
182
|
|
|
140
|
-
/** Takes a worker out of the fleet for good: revokes its Vault token,
|
|
141
|
-
* its pinned host key,
|
|
142
|
-
*
|
|
183
|
+
/** Takes a worker out of the fleet for good: revokes its Vault token,
|
|
184
|
+
* forgets its pinned host key, deletes its monitoring system, removes the
|
|
185
|
+
* manager's T3 link to it and its tailnet device (when the tailnet control is
|
|
186
|
+
* wired), removes its roster entry and leaves a tombstone in the fleet state.
|
|
187
|
+
* Preview until --apply --yes. Running it again changes nothing.
|
|
143
188
|
*/
|
|
144
189
|
export async function fleetNodeRetire(
|
|
145
190
|
options: NodeLifecycleOptions,
|
|
@@ -147,47 +192,42 @@ export async function fleetNodeRetire(
|
|
|
147
192
|
): Promise<CommandOutcome> {
|
|
148
193
|
const worker = declared(services.definition(), options.id);
|
|
149
194
|
const roster = await readRoster(services.roster);
|
|
150
|
-
const
|
|
151
|
-
if (
|
|
195
|
+
const entry = roster?.nodes.find((node) => node.id === worker.id);
|
|
196
|
+
if (entry && roster!.nodes.length === 1)
|
|
152
197
|
throw new FleetUsageError(
|
|
153
198
|
'The roster must keep at least one worker. Add the replacement with fleet-node-add first.'
|
|
154
199
|
);
|
|
200
|
+
return retireWorker(worker, entry, services, !preview(options));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Retires one worker; with `apply` false it only plans. Shared with the
|
|
204
|
+
* retirement of a worker whose record disappeared (fleet-sync).
|
|
205
|
+
*/
|
|
206
|
+
export async function retireWorker(
|
|
207
|
+
worker: MachineDefinition,
|
|
208
|
+
entry: FleetNode | undefined,
|
|
209
|
+
services: NodeLifecycleServices,
|
|
210
|
+
apply: boolean
|
|
211
|
+
): Promise<CommandOutcome> {
|
|
155
212
|
const host = await keyName(worker, services.run);
|
|
156
|
-
if (
|
|
213
|
+
if (!apply)
|
|
157
214
|
return outcome(
|
|
158
215
|
'fleet-node-retire',
|
|
159
216
|
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
|
-
],
|
|
217
|
+
planRetirement(worker, entry, host, services.vault()),
|
|
173
218
|
false
|
|
174
219
|
);
|
|
175
220
|
const checks: Check[] = [];
|
|
176
221
|
if (services.vault())
|
|
177
222
|
checks.push(await revokeWorkerToken(worker.id, services.run));
|
|
223
|
+
checks.push(await pinHostKey(worker, host, null, services));
|
|
178
224
|
checks.push(
|
|
179
|
-
|
|
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()
|
|
225
|
+
await onHub(services, (request) => removeSystem(request, worker.id))
|
|
190
226
|
);
|
|
227
|
+
if (entry?.t3EnvironmentId)
|
|
228
|
+
checks.push(await unlinkT3(entry.t3EnvironmentId, services.run));
|
|
229
|
+
if (entry?.tailscaleId)
|
|
230
|
+
checks.push(await removeDevice(worker, entry.tailscaleId, services));
|
|
191
231
|
await changeRoster(services, (current) =>
|
|
192
232
|
current && current.nodes.some((node) => node.id === worker.id)
|
|
193
233
|
? {
|
|
@@ -201,10 +241,161 @@ export async function fleetNodeRetire(
|
|
|
201
241
|
status: 'pass',
|
|
202
242
|
summary: `${worker.id} is not in the roster.`,
|
|
203
243
|
});
|
|
244
|
+
// Nothing of the worker stays in the fleet state but its tombstone.
|
|
245
|
+
await changeWorkers(services.state(), () => ({ [worker.id]: null }));
|
|
204
246
|
checks.push(await tombstone(worker.id, services));
|
|
205
247
|
return outcome('fleet-node-retire', worker.id, checks, true);
|
|
206
248
|
}
|
|
207
249
|
|
|
250
|
+
function planRetirement(
|
|
251
|
+
worker: MachineDefinition,
|
|
252
|
+
entry: FleetNode | undefined,
|
|
253
|
+
host: string,
|
|
254
|
+
vault: boolean
|
|
255
|
+
): Check[] {
|
|
256
|
+
return [
|
|
257
|
+
...(vault
|
|
258
|
+
? [planned('auth:worker-token', `revoke ${worker.id}'s Vault token`)]
|
|
259
|
+
: []),
|
|
260
|
+
planned('ssh:host-key', `forget the host key of ${host}`),
|
|
261
|
+
planned('beszel:system', `delete the monitoring system ${worker.id}`),
|
|
262
|
+
...(entry?.t3EnvironmentId
|
|
263
|
+
? [
|
|
264
|
+
planned(
|
|
265
|
+
't3:link',
|
|
266
|
+
`remove the manager's T3 link to ${entry.t3EnvironmentId}`
|
|
267
|
+
),
|
|
268
|
+
]
|
|
269
|
+
: []),
|
|
270
|
+
...(entry?.tailscaleId
|
|
271
|
+
? [
|
|
272
|
+
planned(
|
|
273
|
+
'tailscale:device',
|
|
274
|
+
`remove the tailnet device ${entry.tailscaleId}`
|
|
275
|
+
),
|
|
276
|
+
]
|
|
277
|
+
: []),
|
|
278
|
+
planned(
|
|
279
|
+
'enrollment:connection',
|
|
280
|
+
entry
|
|
281
|
+
? `remove ${worker.id} from the roster`
|
|
282
|
+
: `${worker.id} is not in the roster`
|
|
283
|
+
),
|
|
284
|
+
planned('state:retired', `record that ${worker.id} is retired`),
|
|
285
|
+
];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Pins `key` as a worker's host key, or forgets it (null). A worker reached
|
|
289
|
+
* from declared data is pinned in the fleet state, and the fleet-owned
|
|
290
|
+
* known_hosts is rendered again; one reached through the operator's own SSH
|
|
291
|
+
* alias is pinned in their known_hosts by the pinned Ansible.
|
|
292
|
+
*/
|
|
293
|
+
export async function pinHostKey(
|
|
294
|
+
worker: MachineDefinition,
|
|
295
|
+
host: string,
|
|
296
|
+
key: string | null,
|
|
297
|
+
services: Pick<
|
|
298
|
+
NodeLifecycleServices,
|
|
299
|
+
'state' | 'playbook' | 'knownHosts' | 'now' | 'definition'
|
|
300
|
+
>
|
|
301
|
+
): Promise<Check> {
|
|
302
|
+
if (!declaredConnection(worker)) {
|
|
303
|
+
const pinned = await services.playbook('macos/worker-host-key.yml', {
|
|
304
|
+
host,
|
|
305
|
+
...(key ? { pin: 'present', key } : { pin: 'absent' }),
|
|
306
|
+
known_hosts: services.knownHosts,
|
|
307
|
+
});
|
|
308
|
+
if (!pinned) return hostKeyFailure();
|
|
309
|
+
return {
|
|
310
|
+
id: 'ssh:host-key',
|
|
311
|
+
status: 'pass',
|
|
312
|
+
summary: key
|
|
313
|
+
? `The manager trusts exactly the given host key of ${host}.`
|
|
314
|
+
: `The manager no longer trusts any host key of ${host}.`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const at = services.now().toISOString();
|
|
318
|
+
await changeWorkers(services.state(), (current) => {
|
|
319
|
+
const {
|
|
320
|
+
hostKey: _key,
|
|
321
|
+
hostKeyPinnedAt: _at,
|
|
322
|
+
...rest
|
|
323
|
+
} = current[worker.id] ?? {};
|
|
324
|
+
return {
|
|
325
|
+
[worker.id]: key ? { ...rest, hostKey: key, hostKeyPinnedAt: at } : rest,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
renderKnownHosts(services.definition(), process.env, readPins());
|
|
329
|
+
return {
|
|
330
|
+
id: 'ssh:host-key',
|
|
331
|
+
status: 'pass',
|
|
332
|
+
summary: key
|
|
333
|
+
? `The fleet state pins exactly the given host key of ${worker.id}.`
|
|
334
|
+
: `The fleet state pins no host key of ${worker.id}.`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One change on the monitoring hub. A hub that cannot be reached is a
|
|
339
|
+
* warning, never a reason to stop.
|
|
340
|
+
*/
|
|
341
|
+
async function onHub(
|
|
342
|
+
services: NodeLifecycleServices,
|
|
343
|
+
change: (request: HubRequest) => Promise<Check>
|
|
344
|
+
): Promise<Check> {
|
|
345
|
+
if (!services.monitoring)
|
|
346
|
+
return {
|
|
347
|
+
id: 'beszel:system',
|
|
348
|
+
status: 'skip',
|
|
349
|
+
summary: 'The monitoring hub was not asked: there is no hub access here.',
|
|
350
|
+
};
|
|
351
|
+
try {
|
|
352
|
+
return await change(await services.monitoring());
|
|
353
|
+
} catch (error) {
|
|
354
|
+
return {
|
|
355
|
+
id: 'beszel:system',
|
|
356
|
+
status: 'warn',
|
|
357
|
+
summary:
|
|
358
|
+
error instanceof Error && /^BESZEL_[A-Z_]+$/.test(error.message)
|
|
359
|
+
? error.message
|
|
360
|
+
: 'BESZEL_API_UNAVAILABLE',
|
|
361
|
+
remedy: {
|
|
362
|
+
action:
|
|
363
|
+
'The hub was not changed. Run fleet-sync on the manager so the hub answers, then run this command again; fleet-status --workers lists systems that match no declared worker.',
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Forgets the manager's T3 link to an environment that is gone. */
|
|
370
|
+
async function unlinkT3(environment: string, run: Runner): Promise<Check> {
|
|
371
|
+
const result = await run(['t3', 'environment', 'unlink', environment], {
|
|
372
|
+
timeoutMs: 30_000,
|
|
373
|
+
});
|
|
374
|
+
return result.ok
|
|
375
|
+
? {
|
|
376
|
+
id: 't3:link',
|
|
377
|
+
status: 'pass',
|
|
378
|
+
summary: `The manager no longer links to the T3 environment ${environment}.`,
|
|
379
|
+
}
|
|
380
|
+
: {
|
|
381
|
+
id: 't3:link',
|
|
382
|
+
status: 'warn',
|
|
383
|
+
summary: 'T3_LINK_UNLINK_FAILED',
|
|
384
|
+
remedy: {
|
|
385
|
+
action: `Remove the link to ${environment} in the manager's T3 (Settings, Connections), then run the command again.`,
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function removeDevice(
|
|
391
|
+
worker: MachineDefinition,
|
|
392
|
+
device: string,
|
|
393
|
+
services: NodeLifecycleServices
|
|
394
|
+
): Promise<Check> {
|
|
395
|
+
if (services.tailnet) return services.tailnet.removeDevice(worker, device);
|
|
396
|
+
return deviceLeftToPerson(worker, device);
|
|
397
|
+
}
|
|
398
|
+
|
|
208
399
|
async function tombstone(
|
|
209
400
|
id: string,
|
|
210
401
|
services: NodeLifecycleServices
|
|
@@ -241,10 +432,14 @@ async function tombstone(
|
|
|
241
432
|
|
|
242
433
|
/** Puts a fresh machine in place of a worker under the same id: verifies the
|
|
243
434
|
* new device on the tailnet, pins its host key, binds the roster entry to the
|
|
244
|
-
* new device
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
435
|
+
* new device, points its monitoring system at it, removes the manager's T3
|
|
436
|
+
* link to the old environment, pairs the manager's T3 with the new one (now,
|
|
437
|
+
* or in the next fleet-sync once it runs), removes the old tailnet device
|
|
438
|
+
* (when the tailnet control is wired) and issues it a token of its own
|
|
439
|
+
* (revoking the old one).
|
|
440
|
+
* Preview until --apply --yes; then `fleet-sync --node ID` brings it to
|
|
441
|
+
* ready. When nothing changed (the same device, the same key, a token of its
|
|
442
|
+
* own) it changes nothing.
|
|
248
443
|
*/
|
|
249
444
|
export async function fleetNodeReplace(
|
|
250
445
|
options: NodeLifecycleOptions,
|
|
@@ -255,13 +450,19 @@ export async function fleetNodeReplace(
|
|
|
255
450
|
const device = await newDevice(worker, services.run);
|
|
256
451
|
if ('check' in device)
|
|
257
452
|
return outcome('fleet-node-replace', worker.id, [device.check], false);
|
|
453
|
+
const replaced = entry.tailscaleId !== device.computer.id;
|
|
258
454
|
const plan: Replacement = {
|
|
259
455
|
worker,
|
|
456
|
+
node: { ...entry, tailscaleId: device.computer.id },
|
|
260
457
|
device: device.computer.id,
|
|
261
458
|
host: await keyName(worker, services.run),
|
|
262
459
|
hostKey: options.hostKey,
|
|
263
|
-
replaced
|
|
460
|
+
replaced,
|
|
264
461
|
needsToken: false,
|
|
462
|
+
...(replaced && entry.tailscaleId ? { previous: entry.tailscaleId } : {}),
|
|
463
|
+
...(replaced && entry.t3EnvironmentId
|
|
464
|
+
? { environment: entry.t3EnvironmentId }
|
|
465
|
+
: {}),
|
|
265
466
|
};
|
|
266
467
|
plan.needsToken =
|
|
267
468
|
services.vault() &&
|
|
@@ -288,11 +489,17 @@ export async function fleetNodeReplace(
|
|
|
288
489
|
|
|
289
490
|
interface Replacement {
|
|
290
491
|
worker: MachineDefinition;
|
|
492
|
+
/** The roster entry, bound to the new device. */
|
|
493
|
+
node: FleetNode;
|
|
291
494
|
device: string;
|
|
292
495
|
host: string;
|
|
293
496
|
hostKey: string | undefined;
|
|
294
497
|
replaced: boolean;
|
|
295
498
|
needsToken: boolean;
|
|
499
|
+
/** The device the worker ran as before, when it changed. */
|
|
500
|
+
previous?: string;
|
|
501
|
+
/** The T3 environment the manager linked to before, when it changed. */
|
|
502
|
+
environment?: string;
|
|
296
503
|
}
|
|
297
504
|
|
|
298
505
|
async function replaceable(
|
|
@@ -300,15 +507,14 @@ async function replaceable(
|
|
|
300
507
|
options: NodeLifecycleOptions,
|
|
301
508
|
services: NodeLifecycleServices
|
|
302
509
|
) {
|
|
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
|
-
)
|
|
510
|
+
if (options.hostKey !== undefined && !HOST_KEY.test(options.hostKey))
|
|
309
511
|
throw new FleetUsageError(
|
|
310
512
|
'--host-key expects one public key, "TYPE BASE64", as ssh-keyscan or the seed prints it.'
|
|
311
513
|
);
|
|
514
|
+
if (options.hostKey !== undefined && worker.network?.hostKey)
|
|
515
|
+
throw new FleetUsageError(
|
|
516
|
+
`${worker.id}'s host key is declared by its worker record. Change hostKey in the record instead; it is pinned on the next command.`
|
|
517
|
+
);
|
|
312
518
|
const roster = await readRoster(services.roster);
|
|
313
519
|
const entry = roster?.nodes.find((node) => node.id === worker.id);
|
|
314
520
|
if (!entry)
|
|
@@ -327,8 +533,27 @@ function planReplacement(plan: Replacement): Check[] {
|
|
|
327
533
|
planned(
|
|
328
534
|
'enrollment:connection',
|
|
329
535
|
`bind ${plan.worker.id} to ${plan.device}`
|
|
536
|
+
),
|
|
537
|
+
planned(
|
|
538
|
+
'beszel:system',
|
|
539
|
+
`point the monitoring system ${plan.worker.id} at the new machine`
|
|
330
540
|
)
|
|
331
541
|
);
|
|
542
|
+
if (plan.environment)
|
|
543
|
+
steps.push(
|
|
544
|
+
planned('t3:link', `remove the manager's T3 link to ${plan.environment}`)
|
|
545
|
+
);
|
|
546
|
+
if (plan.replaced)
|
|
547
|
+
steps.push(
|
|
548
|
+
planned(
|
|
549
|
+
't3:link',
|
|
550
|
+
`pair the manager's T3 with the new T3 environment of ${plan.worker.id}`
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
if (plan.previous)
|
|
554
|
+
steps.push(
|
|
555
|
+
planned('tailscale:device', `remove the tailnet device ${plan.previous}`)
|
|
556
|
+
);
|
|
332
557
|
if (plan.needsToken)
|
|
333
558
|
steps.push(
|
|
334
559
|
planned('auth:worker-token', `issue ${plan.worker.id} a token of its own`)
|
|
@@ -343,30 +568,19 @@ async function carryOut(
|
|
|
343
568
|
const { worker } = plan;
|
|
344
569
|
const steps: Check[] = [];
|
|
345
570
|
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
|
-
);
|
|
571
|
+
steps.push(await pinHostKey(worker, plan.host, plan.hostKey, services));
|
|
360
572
|
if (plan.replaced) {
|
|
361
573
|
await changeRoster(services, (current) =>
|
|
362
574
|
current
|
|
363
575
|
? {
|
|
364
576
|
...current,
|
|
365
|
-
nodes: current.nodes.map((node) =>
|
|
366
|
-
node.id
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
577
|
+
nodes: current.nodes.map((node) => {
|
|
578
|
+
if (node.id !== worker.id) return node;
|
|
579
|
+
// The old environment's identity goes with the old machine:
|
|
580
|
+
// the next sync records the new one once it is linked.
|
|
581
|
+
const { t3EnvironmentId: _old, ...rest } = node;
|
|
582
|
+
return { ...rest, tailscaleId: plan.device };
|
|
583
|
+
}),
|
|
370
584
|
}
|
|
371
585
|
: null
|
|
372
586
|
);
|
|
@@ -375,7 +589,21 @@ async function carryOut(
|
|
|
375
589
|
status: 'pass',
|
|
376
590
|
summary: `The roster binds ${worker.id} to the device ${plan.device}.`,
|
|
377
591
|
});
|
|
592
|
+
steps.push(
|
|
593
|
+
await onHub(services, (request) =>
|
|
594
|
+
moveSystem(
|
|
595
|
+
request,
|
|
596
|
+
worker.id,
|
|
597
|
+
worker.network?.tailscaleName ?? plan.host
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
);
|
|
378
601
|
}
|
|
602
|
+
if (plan.environment)
|
|
603
|
+
steps.push(await unlinkT3(plan.environment, services.run));
|
|
604
|
+
if (plan.replaced) steps.push(await pairReplacement(plan.node, services));
|
|
605
|
+
if (plan.previous)
|
|
606
|
+
steps.push(await removeDevice(worker, plan.previous, services));
|
|
379
607
|
if (plan.needsToken) {
|
|
380
608
|
const revoked = await revokeWorkerToken(worker.id, services.run);
|
|
381
609
|
steps.push(
|
|
@@ -392,6 +620,23 @@ async function carryOut(
|
|
|
392
620
|
return steps;
|
|
393
621
|
}
|
|
394
622
|
|
|
623
|
+
/** Pairs the manager's T3 with a replacement's environment and records it
|
|
624
|
+
* in the roster. A machine whose T3 does not run yet (or refuses) is left to
|
|
625
|
+
* the next fleet-sync, which pairs it once it runs.
|
|
626
|
+
*/
|
|
627
|
+
function pairReplacement(
|
|
628
|
+
node: FleetNode,
|
|
629
|
+
services: NodeLifecycleServices
|
|
630
|
+
): Promise<Check> {
|
|
631
|
+
return pairAndRecord(node, {
|
|
632
|
+
roster: services.roster,
|
|
633
|
+
run: services.run,
|
|
634
|
+
manager: services.t3(),
|
|
635
|
+
context: services.state,
|
|
636
|
+
now: services.now,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
395
640
|
async function ownToken(id: string, run: Runner): Promise<boolean> {
|
|
396
641
|
const location = workerTokenLocation(id);
|
|
397
642
|
return (
|
|
@@ -415,7 +660,11 @@ async function newDevice(
|
|
|
415
660
|
run: Runner
|
|
416
661
|
): Promise<{ computer: TailnetComputer } | { check: Check }> {
|
|
417
662
|
const tailnet = await discoverTailnet(run);
|
|
418
|
-
const name = (
|
|
663
|
+
const name = (
|
|
664
|
+
worker.network?.tailscaleName ??
|
|
665
|
+
worker.ssh ??
|
|
666
|
+
''
|
|
667
|
+
).toLowerCase();
|
|
419
668
|
const short = name.split('.')[0]!;
|
|
420
669
|
const found = tailnet.computers.filter(
|
|
421
670
|
(computer) =>
|