@crvouga/dev-fleet 0.7.0 → 0.9.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 +88 -7
- 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 +83 -0
- package/ansible/macos/manager.yml +164 -9
- 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/macos/worker-host-key.yml +33 -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 +65 -0
- 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 +21 -11
- package/docs/consumer-setup.md +74 -58
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +34 -0
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +112 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +82 -12
- package/docs/ubuntu-sync.md +98 -42
- 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 +1991 -162
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +349 -10
- 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 +62 -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 +108 -0
- package/src/cli/fleet-node-lifecycle.ts +704 -0
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +167 -0
- 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 +6 -0
- package/src/cli/fleet-sync-manager.ts +123 -8
- 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 +172 -15
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +31 -10
- package/src/cli/fleet-sync-vault.ts +152 -26
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +172 -21
- package/src/cli/fleet-validate.ts +277 -0
- 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 +251 -4
- package/src/cli/registry.ts +47 -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 +762 -232
- 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 +70 -16
- 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 +89 -8
- 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 +7 -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-capacity.ts +31 -0
- package/src/worker-credentials.ts +201 -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 +224 -0
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +38 -36
- 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 +143 -0
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseConfig } from '../config';
|
|
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';
|
|
19
|
+
import { FleetUsageError } from '../errors';
|
|
20
|
+
import { fleet, secret } from '../fleet-config';
|
|
21
|
+
import {
|
|
22
|
+
deviceLeftToPerson,
|
|
23
|
+
tailnetCleanup,
|
|
24
|
+
type TailnetCleanup,
|
|
25
|
+
} from '../tailnet-cleanup';
|
|
26
|
+
import type { Enrollment } from '../machine/state';
|
|
27
|
+
import { PACKAGE_ROOT } from '../package-info';
|
|
28
|
+
import { runAnsible } from '../platform/ansible';
|
|
29
|
+
import { makeReport, renderReport } from '../report';
|
|
30
|
+
import { runCommand } from '../runner';
|
|
31
|
+
import { loadEnrollment, storeEnrollment } from '../state/enrollment';
|
|
32
|
+
import { STATE_ROSTER, writeRosterText } from '../state/roster';
|
|
33
|
+
import { mutateState, stateContext, type StateContext } from '../state/sync';
|
|
34
|
+
import { discoverTailnet, type TailnetComputer } from '../tailscale';
|
|
35
|
+
import type { Check, FleetConfig, FleetNode, Runner } from '../types';
|
|
36
|
+
import { usesVault } from '../vault-config';
|
|
37
|
+
import { managerT3, type ManagerT3 } from '../t3/peer-link';
|
|
38
|
+
import { pairAndRecord } from '../t3/pairing';
|
|
39
|
+
import {
|
|
40
|
+
issueWorkerToken,
|
|
41
|
+
revokeWorkerToken,
|
|
42
|
+
workerTokenLocation,
|
|
43
|
+
} from '../worker-credentials';
|
|
44
|
+
import type { CommandOutcome } from './contracts';
|
|
45
|
+
import { readRoster } from './roster';
|
|
46
|
+
|
|
47
|
+
export interface NodeLifecycleOptions {
|
|
48
|
+
id?: string;
|
|
49
|
+
/** The replacement's SSH host key, `TYPE BASE64`, verified out of band or
|
|
50
|
+
* written by the seed that created it.
|
|
51
|
+
*/
|
|
52
|
+
hostKey?: string;
|
|
53
|
+
apply?: boolean;
|
|
54
|
+
yes?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface NodeLifecycleServices {
|
|
58
|
+
run: Runner;
|
|
59
|
+
/** The roster: the fleet state's, or a file `--config` names. */
|
|
60
|
+
roster: string;
|
|
61
|
+
definition: () => FleetDefinition;
|
|
62
|
+
vault: () => boolean;
|
|
63
|
+
/** The manager's known_hosts file. */
|
|
64
|
+
knownHosts: string;
|
|
65
|
+
state: () => StateContext;
|
|
66
|
+
/** Applies a playbook of the package with these variables. */
|
|
67
|
+
playbook: (name: string, variables: object) => Promise<boolean>;
|
|
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;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type { TailnetCleanup };
|
|
84
|
+
|
|
85
|
+
export function lifecycleServices(roster: string): NodeLifecycleServices {
|
|
86
|
+
return {
|
|
87
|
+
run: runCommand,
|
|
88
|
+
roster,
|
|
89
|
+
definition: () => fleet,
|
|
90
|
+
vault: usesVault,
|
|
91
|
+
knownHosts: join(homedir(), '.ssh/known_hosts'),
|
|
92
|
+
state: () => stateContext(runCommand),
|
|
93
|
+
playbook: async (name, variables) =>
|
|
94
|
+
(
|
|
95
|
+
await runAnsible(runCommand, join(PACKAGE_ROOT, 'ansible', name), {
|
|
96
|
+
input: JSON.stringify(variables),
|
|
97
|
+
timeoutMs: 300_000,
|
|
98
|
+
})
|
|
99
|
+
).ok,
|
|
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),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function declared(
|
|
111
|
+
definition: FleetDefinition,
|
|
112
|
+
id: string | undefined
|
|
113
|
+
): MachineDefinition {
|
|
114
|
+
const worker = definition.workers.find((entry) => entry.id === id);
|
|
115
|
+
if (!worker)
|
|
116
|
+
throw new FleetUsageError(
|
|
117
|
+
'--id must name a worker fleet.json declares. To retire a worker, retire it first and then remove it from fleet.json.'
|
|
118
|
+
);
|
|
119
|
+
return worker;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The name ssh checks the host key of: the HostName the alias resolves to,
|
|
123
|
+
* as `[host]:port` when the port is not 22.
|
|
124
|
+
*/
|
|
125
|
+
export async function keyName(
|
|
126
|
+
worker: MachineDefinition,
|
|
127
|
+
run: Runner
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
// A worker reached from declared data is pinned under its fleet ID.
|
|
130
|
+
if (!worker.ssh) return hostKeyAlias(worker.id);
|
|
131
|
+
const resolved = await run(['ssh', '-G', worker.ssh], { timeoutMs: 10_000 });
|
|
132
|
+
const option = (name: string) =>
|
|
133
|
+
resolved.ok
|
|
134
|
+
? new RegExp(`^${name} (\\S+)$`, 'm').exec(resolved.stdout)?.[1]
|
|
135
|
+
: undefined;
|
|
136
|
+
const host =
|
|
137
|
+
option('hostname') ?? worker.network?.tailscaleName ?? worker.ssh;
|
|
138
|
+
const port = option('port') ?? String(worker.network?.sshPort ?? 22);
|
|
139
|
+
return port === '22' ? host : `[${host}]:${port}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Changes the roster in the fleet state (or the file `--config` names). */
|
|
143
|
+
async function changeRoster(
|
|
144
|
+
services: NodeLifecycleServices,
|
|
145
|
+
change: (roster: FleetConfig | null) => FleetConfig | null
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const save = async () => {
|
|
148
|
+
const next = change(await readRoster(services.roster));
|
|
149
|
+
if (next)
|
|
150
|
+
await writeRosterText(
|
|
151
|
+
services.roster,
|
|
152
|
+
`${JSON.stringify(parseConfig(next), null, 2)}\n`
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
if (services.roster === STATE_ROSTER)
|
|
156
|
+
await mutateState(services.state(), save);
|
|
157
|
+
else await save();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function outcome(
|
|
161
|
+
command: string,
|
|
162
|
+
id: string,
|
|
163
|
+
checks: Check[],
|
|
164
|
+
applied: boolean
|
|
165
|
+
): CommandOutcome {
|
|
166
|
+
const report = { ...makeReport(command, [{ node: id, checks }]), applied };
|
|
167
|
+
return {
|
|
168
|
+
exitCode: checks.some((check) => check.status === 'fail') ? 1 : 0,
|
|
169
|
+
data: report,
|
|
170
|
+
text: renderReport(report),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const planned = (id: string, summary: string): Check => ({
|
|
175
|
+
id,
|
|
176
|
+
status: 'skip',
|
|
177
|
+
summary: `Planned: ${summary}`,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const preview = (options: NodeLifecycleOptions): boolean =>
|
|
181
|
+
!(options.apply && options.yes);
|
|
182
|
+
|
|
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.
|
|
188
|
+
*/
|
|
189
|
+
export async function fleetNodeRetire(
|
|
190
|
+
options: NodeLifecycleOptions,
|
|
191
|
+
services: NodeLifecycleServices
|
|
192
|
+
): Promise<CommandOutcome> {
|
|
193
|
+
const worker = declared(services.definition(), options.id);
|
|
194
|
+
const roster = await readRoster(services.roster);
|
|
195
|
+
const entry = roster?.nodes.find((node) => node.id === worker.id);
|
|
196
|
+
if (entry && roster!.nodes.length === 1)
|
|
197
|
+
throw new FleetUsageError(
|
|
198
|
+
'The roster must keep at least one worker. Add the replacement with fleet-node-add first.'
|
|
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> {
|
|
212
|
+
const host = await keyName(worker, services.run);
|
|
213
|
+
if (!apply)
|
|
214
|
+
return outcome(
|
|
215
|
+
'fleet-node-retire',
|
|
216
|
+
worker.id,
|
|
217
|
+
planRetirement(worker, entry, host, services.vault()),
|
|
218
|
+
false
|
|
219
|
+
);
|
|
220
|
+
const checks: Check[] = [];
|
|
221
|
+
if (services.vault())
|
|
222
|
+
checks.push(await revokeWorkerToken(worker.id, services.run));
|
|
223
|
+
checks.push(await pinHostKey(worker, host, null, services));
|
|
224
|
+
checks.push(
|
|
225
|
+
await onHub(services, (request) => removeSystem(request, worker.id))
|
|
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));
|
|
231
|
+
await changeRoster(services, (current) =>
|
|
232
|
+
current && current.nodes.some((node) => node.id === worker.id)
|
|
233
|
+
? {
|
|
234
|
+
...current,
|
|
235
|
+
nodes: current.nodes.filter((node) => node.id !== worker.id),
|
|
236
|
+
}
|
|
237
|
+
: null
|
|
238
|
+
);
|
|
239
|
+
checks.push({
|
|
240
|
+
id: 'enrollment:connection',
|
|
241
|
+
status: 'pass',
|
|
242
|
+
summary: `${worker.id} is not in the roster.`,
|
|
243
|
+
});
|
|
244
|
+
// Nothing of the worker stays in the fleet state but its tombstone.
|
|
245
|
+
await changeWorkers(services.state(), () => ({ [worker.id]: null }));
|
|
246
|
+
checks.push(await tombstone(worker.id, services));
|
|
247
|
+
return outcome('fleet-node-retire', worker.id, checks, true);
|
|
248
|
+
}
|
|
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
|
+
|
|
399
|
+
async function tombstone(
|
|
400
|
+
id: string,
|
|
401
|
+
services: NodeLifecycleServices
|
|
402
|
+
): Promise<Check> {
|
|
403
|
+
const record = async (): Promise<Enrollment | null> => {
|
|
404
|
+
const enrollment = await loadEnrollment(null);
|
|
405
|
+
if (!enrollment) return null;
|
|
406
|
+
const { [id]: _applied, ...workers } = enrollment.workers ?? {};
|
|
407
|
+
const retired = {
|
|
408
|
+
...enrollment,
|
|
409
|
+
workers,
|
|
410
|
+
retired: {
|
|
411
|
+
...enrollment.retired,
|
|
412
|
+
[id]: enrollment.retired?.[id] ?? services.now().toISOString(),
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
storeEnrollment(retired);
|
|
416
|
+
return retired;
|
|
417
|
+
};
|
|
418
|
+
const stored = (await mutateState(services.state(), record)).value;
|
|
419
|
+
return stored
|
|
420
|
+
? {
|
|
421
|
+
id: 'state:retired',
|
|
422
|
+
status: 'pass',
|
|
423
|
+
summary: `The fleet state records that ${id} was retired at ${stored.retired![id]}.`,
|
|
424
|
+
}
|
|
425
|
+
: {
|
|
426
|
+
id: 'state:retired',
|
|
427
|
+
status: 'skip',
|
|
428
|
+
summary:
|
|
429
|
+
'No revision was ever applied to this fleet, so there is nothing to record.',
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Puts a fresh machine in place of a worker under the same id: verifies the
|
|
434
|
+
* new device on the tailnet, pins its host key, binds the roster entry to the
|
|
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.
|
|
443
|
+
*/
|
|
444
|
+
export async function fleetNodeReplace(
|
|
445
|
+
options: NodeLifecycleOptions,
|
|
446
|
+
services: NodeLifecycleServices
|
|
447
|
+
): Promise<CommandOutcome> {
|
|
448
|
+
const worker = declared(services.definition(), options.id);
|
|
449
|
+
const entry = await replaceable(worker, options, services);
|
|
450
|
+
const device = await newDevice(worker, services.run);
|
|
451
|
+
if ('check' in device)
|
|
452
|
+
return outcome('fleet-node-replace', worker.id, [device.check], false);
|
|
453
|
+
const replaced = entry.tailscaleId !== device.computer.id;
|
|
454
|
+
const plan: Replacement = {
|
|
455
|
+
worker,
|
|
456
|
+
node: { ...entry, tailscaleId: device.computer.id },
|
|
457
|
+
device: device.computer.id,
|
|
458
|
+
host: await keyName(worker, services.run),
|
|
459
|
+
hostKey: options.hostKey,
|
|
460
|
+
replaced,
|
|
461
|
+
needsToken: false,
|
|
462
|
+
...(replaced && entry.tailscaleId ? { previous: entry.tailscaleId } : {}),
|
|
463
|
+
...(replaced && entry.t3EnvironmentId
|
|
464
|
+
? { environment: entry.t3EnvironmentId }
|
|
465
|
+
: {}),
|
|
466
|
+
};
|
|
467
|
+
plan.needsToken =
|
|
468
|
+
services.vault() &&
|
|
469
|
+
(plan.replaced || !(await ownToken(worker.id, services.run)));
|
|
470
|
+
const identity: Check = {
|
|
471
|
+
id: 'tailscale:identity',
|
|
472
|
+
status: 'pass',
|
|
473
|
+
summary: `${worker.id} is the online device ${plan.device}${plan.replaced ? `, replacing ${entry.tailscaleId ?? 'none'}` : ''}.`,
|
|
474
|
+
};
|
|
475
|
+
if (preview(options))
|
|
476
|
+
return outcome(
|
|
477
|
+
'fleet-node-replace',
|
|
478
|
+
worker.id,
|
|
479
|
+
[identity, ...planReplacement(plan)],
|
|
480
|
+
false
|
|
481
|
+
);
|
|
482
|
+
return outcome(
|
|
483
|
+
'fleet-node-replace',
|
|
484
|
+
worker.id,
|
|
485
|
+
[identity, ...(await carryOut(plan, services))],
|
|
486
|
+
true
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
interface Replacement {
|
|
491
|
+
worker: MachineDefinition;
|
|
492
|
+
/** The roster entry, bound to the new device. */
|
|
493
|
+
node: FleetNode;
|
|
494
|
+
device: string;
|
|
495
|
+
host: string;
|
|
496
|
+
hostKey: string | undefined;
|
|
497
|
+
replaced: boolean;
|
|
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;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function replaceable(
|
|
506
|
+
worker: MachineDefinition,
|
|
507
|
+
options: NodeLifecycleOptions,
|
|
508
|
+
services: NodeLifecycleServices
|
|
509
|
+
) {
|
|
510
|
+
if (options.hostKey !== undefined && !HOST_KEY.test(options.hostKey))
|
|
511
|
+
throw new FleetUsageError(
|
|
512
|
+
'--host-key expects one public key, "TYPE BASE64", as ssh-keyscan or the seed prints it.'
|
|
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
|
+
);
|
|
518
|
+
const roster = await readRoster(services.roster);
|
|
519
|
+
const entry = roster?.nodes.find((node) => node.id === worker.id);
|
|
520
|
+
if (!entry)
|
|
521
|
+
throw new FleetUsageError(
|
|
522
|
+
`${worker.id} is not in the roster. Add it with fleet-node-add first.`
|
|
523
|
+
);
|
|
524
|
+
return entry;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function planReplacement(plan: Replacement): Check[] {
|
|
528
|
+
const steps: Check[] = [];
|
|
529
|
+
if (plan.hostKey)
|
|
530
|
+
steps.push(planned('ssh:host-key', `pin the host key of ${plan.host}`));
|
|
531
|
+
if (plan.replaced)
|
|
532
|
+
steps.push(
|
|
533
|
+
planned(
|
|
534
|
+
'enrollment:connection',
|
|
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`
|
|
540
|
+
)
|
|
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
|
+
);
|
|
557
|
+
if (plan.needsToken)
|
|
558
|
+
steps.push(
|
|
559
|
+
planned('auth:worker-token', `issue ${plan.worker.id} a token of its own`)
|
|
560
|
+
);
|
|
561
|
+
return steps;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function carryOut(
|
|
565
|
+
plan: Replacement,
|
|
566
|
+
services: NodeLifecycleServices
|
|
567
|
+
): Promise<Check[]> {
|
|
568
|
+
const { worker } = plan;
|
|
569
|
+
const steps: Check[] = [];
|
|
570
|
+
if (plan.hostKey)
|
|
571
|
+
steps.push(await pinHostKey(worker, plan.host, plan.hostKey, services));
|
|
572
|
+
if (plan.replaced) {
|
|
573
|
+
await changeRoster(services, (current) =>
|
|
574
|
+
current
|
|
575
|
+
? {
|
|
576
|
+
...current,
|
|
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
|
+
}),
|
|
584
|
+
}
|
|
585
|
+
: null
|
|
586
|
+
);
|
|
587
|
+
steps.push({
|
|
588
|
+
id: 'enrollment:connection',
|
|
589
|
+
status: 'pass',
|
|
590
|
+
summary: `The roster binds ${worker.id} to the device ${plan.device}.`,
|
|
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
|
+
);
|
|
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));
|
|
607
|
+
if (plan.needsToken) {
|
|
608
|
+
const revoked = await revokeWorkerToken(worker.id, services.run);
|
|
609
|
+
steps.push(
|
|
610
|
+
revoked.status === 'fail'
|
|
611
|
+
? revoked
|
|
612
|
+
: await issueWorkerToken(worker.id, services.run)
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
steps.push({
|
|
616
|
+
id: 'rollout:next',
|
|
617
|
+
status: 'pass',
|
|
618
|
+
summary: `Run devfleet fleet-sync --node ${worker.id} to converge the new machine.`,
|
|
619
|
+
});
|
|
620
|
+
return steps;
|
|
621
|
+
}
|
|
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
|
+
|
|
640
|
+
async function ownToken(id: string, run: Runner): Promise<boolean> {
|
|
641
|
+
const location = workerTokenLocation(id);
|
|
642
|
+
return (
|
|
643
|
+
await run(
|
|
644
|
+
[
|
|
645
|
+
'vault',
|
|
646
|
+
'kv',
|
|
647
|
+
'get',
|
|
648
|
+
`-mount=${location.mount}`,
|
|
649
|
+
'-field=accessor',
|
|
650
|
+
location.path,
|
|
651
|
+
],
|
|
652
|
+
{ timeoutMs: 20_000 }
|
|
653
|
+
)
|
|
654
|
+
).ok;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** The one online computer on the tailnet that carries the worker's name. */
|
|
658
|
+
async function newDevice(
|
|
659
|
+
worker: MachineDefinition,
|
|
660
|
+
run: Runner
|
|
661
|
+
): Promise<{ computer: TailnetComputer } | { check: Check }> {
|
|
662
|
+
const tailnet = await discoverTailnet(run);
|
|
663
|
+
const name = (
|
|
664
|
+
worker.network?.tailscaleName ??
|
|
665
|
+
worker.ssh ??
|
|
666
|
+
''
|
|
667
|
+
).toLowerCase();
|
|
668
|
+
const short = name.split('.')[0]!;
|
|
669
|
+
const found = tailnet.computers.filter(
|
|
670
|
+
(computer) =>
|
|
671
|
+
!computer.self &&
|
|
672
|
+
computer.online &&
|
|
673
|
+
[computer.dns.toLowerCase(), computer.name.toLowerCase()].some(
|
|
674
|
+
(candidate) => candidate === name || candidate.split('.')[0] === short
|
|
675
|
+
)
|
|
676
|
+
);
|
|
677
|
+
if (found.length === 1) return { computer: found[0]! };
|
|
678
|
+
return {
|
|
679
|
+
check: {
|
|
680
|
+
id: 'tailscale:identity',
|
|
681
|
+
status: 'fail',
|
|
682
|
+
summary: found.length
|
|
683
|
+
? 'TAILSCALE_IDENTITY_CONFLICT'
|
|
684
|
+
: 'TAILSCALE_NODE_MISSING',
|
|
685
|
+
remedy: {
|
|
686
|
+
action: found.length
|
|
687
|
+
? `More than one online device is named like ${worker.id}. Remove the old device from the tailnet, then run the command again.`
|
|
688
|
+
: `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.`,
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function hostKeyFailure(): Check {
|
|
695
|
+
return {
|
|
696
|
+
id: 'ssh:host-key',
|
|
697
|
+
status: 'fail',
|
|
698
|
+
summary: 'SSH_HOST_KEY_NOT_CHANGED',
|
|
699
|
+
remedy: {
|
|
700
|
+
action:
|
|
701
|
+
'The pinned Ansible could not change known_hosts on this manager. Run devfleet fleet-validate --ansible, then this command again.',
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|