@crvouga/dev-fleet 0.8.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 +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-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
package/src/remote-bootstrap.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { record } from './config';
|
|
|
24
24
|
import { installedDefinition, type FleetDefinition } from './definition';
|
|
25
25
|
import { cleanCheck, validCheck } from './protocol';
|
|
26
26
|
import { UBUNTU_RELEASES } from './platform/ubuntu-release';
|
|
27
|
+
import { T3_RESTART_MODES } from './platform/t3-service';
|
|
27
28
|
|
|
28
29
|
export interface BootstrapPayload {
|
|
29
30
|
revision: string;
|
|
@@ -33,7 +34,12 @@ export interface BootstrapPayload {
|
|
|
33
34
|
authorizeSudo?: string;
|
|
34
35
|
/** The fleet configuration every node keeps at ~/.config/dev-fleet/fleet.json. */
|
|
35
36
|
config?: string;
|
|
37
|
+
/** `fleet-sync --restart-busy`: a worker's T3 restarts for a new release or
|
|
38
|
+
* policy even while agent turns run. By default it waits for idle.
|
|
39
|
+
*/
|
|
40
|
+
restartBusy?: boolean;
|
|
36
41
|
}
|
|
42
|
+
|
|
37
43
|
/** No manager tool installation; Git/source preparation is part of apply approval. */
|
|
38
44
|
export async function prepareBootstrap(
|
|
39
45
|
root: string,
|
|
@@ -107,16 +113,21 @@ async function bundleRevision(
|
|
|
107
113
|
await unlink(path).catch(() => undefined);
|
|
108
114
|
}
|
|
109
115
|
}
|
|
116
|
+
/** `tailnetKey` is a single-use enrolment key for a worker not on the tailnet
|
|
117
|
+
* yet. It travels on the session's stdin, after the configuration, and only
|
|
118
|
+
* to a bootstrap script that reads it: never as an argument.
|
|
119
|
+
*/
|
|
110
120
|
export async function bootstrapRemote(
|
|
111
121
|
node: FleetNode,
|
|
112
122
|
payload: BootstrapPayload,
|
|
113
123
|
run: Runner,
|
|
114
|
-
|
|
124
|
+
options: Follow & { tailnetKey?: string } = {}
|
|
115
125
|
): Promise<NodeReport> {
|
|
126
|
+
const { tailnetKey, ...follow } = options;
|
|
116
127
|
const clock = follow.clock ?? realClock;
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
const transfer = cached
|
|
128
|
+
const held = await cachedRevision(node, payload.revision, run, clock);
|
|
129
|
+
if ('id' in held) return { node: node.id, checks: [held] };
|
|
130
|
+
const transfer = held.cached
|
|
120
131
|
? { mode: 'cached', bundle: '' }
|
|
121
132
|
: { mode: 'transfer', bundle: payload.bundle };
|
|
122
133
|
const command = [
|
|
@@ -129,14 +140,22 @@ export async function bootstrapRemote(
|
|
|
129
140
|
node.id,
|
|
130
141
|
transfer.mode,
|
|
131
142
|
UBUNTU_RELEASES.map((entry) => entry.release).join(' '),
|
|
143
|
+
T3_RESTART_MODES[payload.restartBusy ? 1 : 0],
|
|
132
144
|
]
|
|
133
145
|
.map(shellQuote)
|
|
134
146
|
.join(' ');
|
|
135
147
|
const checks = await bootstrapSessions(
|
|
136
148
|
node,
|
|
137
149
|
payload,
|
|
138
|
-
{
|
|
139
|
-
|
|
150
|
+
{
|
|
151
|
+
command,
|
|
152
|
+
input: bootstrapInput(
|
|
153
|
+
payload.config,
|
|
154
|
+
transfer.bundle,
|
|
155
|
+
readsTailnetKey(payload.script) ? (tailnetKey ?? '') : null
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
{ run, follow: { ...follow, clock, earlier: held.earlier } }
|
|
140
159
|
);
|
|
141
160
|
return { node: node.id, checks };
|
|
142
161
|
}
|
|
@@ -198,23 +217,37 @@ function locked(checks: Check[]): boolean {
|
|
|
198
217
|
|
|
199
218
|
/** What the bootstrap reads on stdin: one line with the fleet configuration
|
|
200
219
|
* (installed under the remote lock, so overlapping runs never both write it),
|
|
201
|
-
*
|
|
220
|
+
* one line with the tailnet enrolment key (empty when there is none; absent
|
|
221
|
+
* for a script of an earlier revision, which reads no such line), then the
|
|
222
|
+
* bundle when the worker lacks the revision.
|
|
202
223
|
*/
|
|
203
224
|
export function bootstrapInput(
|
|
204
225
|
config: string | undefined,
|
|
205
|
-
bundle: string
|
|
226
|
+
bundle: string,
|
|
227
|
+
tailnetKey: string | null = null
|
|
206
228
|
): string {
|
|
207
229
|
const line = config ? Buffer.from(config).toString('base64') : '';
|
|
208
|
-
return
|
|
230
|
+
return tailnetKey === null
|
|
231
|
+
? `${line}\n${bundle}`
|
|
232
|
+
: `${line}\n${tailnetKey}\n${bundle}`;
|
|
209
233
|
}
|
|
210
234
|
|
|
235
|
+
/** A bootstrap script reads the key line since the revision that added it. */
|
|
236
|
+
export function readsTailnetKey(script: string): boolean {
|
|
237
|
+
return script.includes('read -r tailnet_key');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Whether the worker holds a clean checkout of the revision, and when the
|
|
241
|
+
* operation its record says had finished there started: that one ended
|
|
242
|
+
* before this run, so its result is never taken for this run's.
|
|
243
|
+
*/
|
|
211
244
|
async function cachedRevision(
|
|
212
245
|
node: FleetNode,
|
|
213
246
|
revision: string,
|
|
214
247
|
run: Runner,
|
|
215
248
|
clock: Clock
|
|
216
|
-
): Promise<boolean | Check> {
|
|
217
|
-
const command = `
|
|
249
|
+
): Promise<{ cached: boolean; earlier: number | null } | Check> {
|
|
250
|
+
const command = `r="$HOME/.local/share/personal/fleet"; sed -n 's/^{.*"started":\\([0-9][0-9]*\\),.*"finished":[0-9].*$/finished=\\1/p' "$r/remote-operation.json" 2>/dev/null | head -n 1; d="$r/revisions/${revision}"; test ! -L "$d" && test -d "$d/.git" && test "$(git -C "$d" rev-parse HEAD 2>/dev/null)" = '${revision}' && test "$(git -C "$d" rev-parse --show-toplevel 2>/dev/null)" = "$d" && clean=$(git -C "$d" status --porcelain 2>/dev/null) && test -z "$clean" && printf ready`;
|
|
218
251
|
const result = await retrying(
|
|
219
252
|
() => run([...sshArgs(node), command], { timeoutMs: 20_000 }),
|
|
220
253
|
clock
|
|
@@ -223,7 +256,12 @@ async function cachedRevision(
|
|
|
223
256
|
const failure = sshFailure(result);
|
|
224
257
|
if (result.reason !== 'exit' || failure.id !== 'ssh:probe') return failure;
|
|
225
258
|
}
|
|
226
|
-
|
|
259
|
+
const lines = result.stdout.split('\n');
|
|
260
|
+
const finished = /^finished=(\d{1,12})$/.exec(lines[0] ?? '');
|
|
261
|
+
return {
|
|
262
|
+
cached: result.ok && lines.at(-1) === 'ready',
|
|
263
|
+
earlier: finished ? Number(finished[1]) : null,
|
|
264
|
+
};
|
|
227
265
|
}
|
|
228
266
|
|
|
229
267
|
function parseEvidence(result: {
|
package/src/remote-operation.ts
CHANGED
|
@@ -148,16 +148,30 @@ function followable(operation: Operation, revision: string): boolean {
|
|
|
148
148
|
);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
/** The result of a finished operation of this revision, unless it is the
|
|
152
|
+
* operation that had already finished before this run opened a session: a
|
|
153
|
+
* re-sync of the same revision finds the record of the last run still there,
|
|
154
|
+
* and its result is not this run's.
|
|
155
|
+
*/
|
|
156
|
+
function finishedWith(
|
|
157
|
+
operation: Operation,
|
|
158
|
+
revision: string,
|
|
159
|
+
earlier: number | null | undefined
|
|
160
|
+
): Check[] | null {
|
|
152
161
|
return !operation.held &&
|
|
153
162
|
operation.finished &&
|
|
154
|
-
operation.revision === revision
|
|
163
|
+
operation.revision === revision &&
|
|
164
|
+
!(typeof earlier === 'number' && operation.started === earlier)
|
|
155
165
|
? operation.result
|
|
156
166
|
: null;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export interface Follow {
|
|
160
170
|
clock?: Clock;
|
|
171
|
+
/** When the operation that had finished on the worker before this run
|
|
172
|
+
* began had started there (the worker's clock, in seconds), when known.
|
|
173
|
+
*/
|
|
174
|
+
earlier?: number | null;
|
|
161
175
|
/** Told the phase and how long the operation has run, at every look. */
|
|
162
176
|
progress?: (phase: string) => void;
|
|
163
177
|
}
|
|
@@ -176,7 +190,7 @@ export async function followOperation(
|
|
|
176
190
|
const deadline = clock.now() + FOLLOW_AT_MOST_MS;
|
|
177
191
|
let operation = await inspectOperation(node, run, clock);
|
|
178
192
|
if (!operation) return null;
|
|
179
|
-
const done = finishedWith(operation, revision);
|
|
193
|
+
const done = finishedWith(operation, revision, options.earlier);
|
|
180
194
|
if (done) return [attached(operation, 'finished'), ...done];
|
|
181
195
|
if (!followable(operation, revision)) return null;
|
|
182
196
|
let last: Operation = operation;
|
|
@@ -186,7 +200,7 @@ export async function followOperation(
|
|
|
186
200
|
operation = await inspectOperation(node, run, clock);
|
|
187
201
|
if (!operation) continue;
|
|
188
202
|
last = operation;
|
|
189
|
-
const result = finishedWith(operation, revision);
|
|
203
|
+
const result = finishedWith(operation, revision, options.earlier);
|
|
190
204
|
if (result) return [attached(operation, 'followed'), ...result];
|
|
191
205
|
if (!followable(operation, revision)) return null;
|
|
192
206
|
}
|
package/src/repair-plan.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkOwner } from './check-owners';
|
|
2
|
-
import { AUTOMATED_REPAIRS, recipe } from './repair-catalog';
|
|
2
|
+
import { AUTOMATED_REPAIRS, REPAIR_RECIPES, recipe } from './repair-catalog';
|
|
3
3
|
import type { RepairRecipe } from './repair-catalog';
|
|
4
4
|
import type { Check, FleetNode, NodeReport, Fingerprint } from './types';
|
|
5
5
|
|
|
@@ -18,22 +18,33 @@ export interface RepairStep extends RepairRecipe {
|
|
|
18
18
|
manual?: { check: string; reason: string }[];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
const UNOWNED =
|
|
22
|
+
'No repair recipe is registered for this check: follow its remedy.';
|
|
23
|
+
|
|
24
|
+
/** What repairs a check: the recipe its owner names, or why only a person
|
|
25
|
+
* can. An `auth:` check is repaired by the login recipe of its name; one
|
|
26
|
+
* with no such recipe is a person's, for the reason `login:*` gives. */
|
|
27
|
+
export function checkRepair(
|
|
28
|
+
id: string
|
|
29
|
+
): { recipe: string } | { manual: string } {
|
|
30
|
+
const owner = checkOwner(id);
|
|
31
|
+
if (!owner?.repair) return { manual: owner?.manual ?? UNOWNED };
|
|
32
|
+
if (owner.repair !== 'login:*') return { recipe: owner.repair };
|
|
33
|
+
const login = `login:${id.slice('auth:'.length)}`;
|
|
34
|
+
return REPAIR_RECIPES.some((entry) => entry.id === login)
|
|
35
|
+
? { recipe: login }
|
|
36
|
+
: { manual: checkOwner(login)?.manual ?? UNOWNED };
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
export function repairId(check: Check): string {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
return owner.repair === 'login:*'
|
|
25
|
-
? `login:${check.id.slice('auth:'.length)}`
|
|
26
|
-
: owner.repair;
|
|
40
|
+
const repair = checkRepair(check.id);
|
|
41
|
+
return 'recipe' in repair ? repair.recipe : 'operator';
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
/** Why a check is left to a person, or null when a recipe repairs it. */
|
|
30
45
|
export function manualReason(check: Check): string | null {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
return (
|
|
34
|
-
owner?.manual ??
|
|
35
|
-
'No repair recipe is registered for this check: follow its remedy.'
|
|
36
|
-
);
|
|
46
|
+
const repair = checkRepair(check.id);
|
|
47
|
+
return 'manual' in repair ? repair.manual : null;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
/** Every nonpassing check is accounted for; unknown checks remain operator work. */
|
package/src/report.ts
CHANGED
|
@@ -6,6 +6,7 @@ export function makeReport(command: string, nodes: NodeReport[]): Report {
|
|
|
6
6
|
warn: 0,
|
|
7
7
|
fail: 0,
|
|
8
8
|
skip: 0,
|
|
9
|
+
'not-applicable': 0,
|
|
9
10
|
};
|
|
10
11
|
for (const node of nodes)
|
|
11
12
|
for (const check of node.checks) summary[check.status] += 1;
|
|
@@ -46,7 +47,7 @@ export function renderReport(report: Report): string {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
lines.push(
|
|
49
|
-
`\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip`
|
|
50
|
+
`\n${report.summary.pass} pass, ${report.summary.warn} warn, ${report.summary.fail} fail, ${report.summary.skip} skip${report.summary['not-applicable'] ? `, ${report.summary['not-applicable']} not applicable` : ''}`
|
|
50
51
|
);
|
|
51
52
|
return lines.join('\n');
|
|
52
53
|
}
|
package/src/schedule-check.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
import macos from '../ansible/macos/policy.json';
|
|
2
|
+
import type { ManagerDefinition } from './definition';
|
|
1
3
|
import type { History } from './state/history';
|
|
2
4
|
import type { Check } from './types';
|
|
3
5
|
|
|
4
|
-
/** How often the manager's schedule runs fleet-sync. */
|
|
5
|
-
export const SCHEDULE_INTERVAL_SECONDS =
|
|
6
|
+
/** How often the manager's schedule runs fleet-sync by default. */
|
|
7
|
+
export const SCHEDULE_INTERVAL_SECONDS = macos.fleetSync.intervalMinutes * 60;
|
|
8
|
+
|
|
9
|
+
/** How often the manager's scheduled jobs run: `manager.schedule` in
|
|
10
|
+
* fleet.json, or the defaults of `ansible/macos/policy.json`.
|
|
11
|
+
*/
|
|
12
|
+
export function scheduleIntervals(
|
|
13
|
+
manager: Pick<ManagerDefinition, 'schedule'>
|
|
14
|
+
): {
|
|
15
|
+
syncSeconds: number;
|
|
16
|
+
watchMinutes: number;
|
|
17
|
+
} {
|
|
18
|
+
return {
|
|
19
|
+
syncSeconds:
|
|
20
|
+
(manager.schedule?.syncMinutes ?? macos.fleetSync.intervalMinutes) * 60,
|
|
21
|
+
watchMinutes:
|
|
22
|
+
manager.schedule?.watchMinutes ?? macos.fleetWatch.intervalMinutes,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
6
25
|
|
|
7
26
|
/** `manager:schedule`: a schedule that is on must have succeeded within two
|
|
8
27
|
* of its intervals. A schedule that was never turned on is no check at all.
|
package/src/secrets.ts
CHANGED
|
@@ -29,6 +29,10 @@ export const OPTIONAL_SECRET_NAMES = [
|
|
|
29
29
|
// A short-lived, single-use, tagged Tailscale auth key a new worker joins
|
|
30
30
|
// the tailnet with (fleet-node-seed).
|
|
31
31
|
'tailnet-auth-key',
|
|
32
|
+
// An API credential of the tailnet's control plane (tailnet in fleet.json)
|
|
33
|
+
// that creates one single-use key per new worker. It stays on the manager:
|
|
34
|
+
// no worker ever receives it.
|
|
35
|
+
'tailnet-admin',
|
|
32
36
|
] as const;
|
|
33
37
|
export type SecretName =
|
|
34
38
|
(typeof SECRET_NAMES)[number] | (typeof OPTIONAL_SECRET_NAMES)[number];
|
package/src/service-auth.ts
CHANGED
|
@@ -43,68 +43,75 @@ export async function codingAuth(
|
|
|
43
43
|
];
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** CLIs with no sign-in of their own to check, and why.
|
|
47
|
-
*
|
|
46
|
+
/** Registered agent CLIs with no sign-in of their own to check, and why.
|
|
47
|
+
* Each is `not-applicable` on the proxy route and says where its access is
|
|
48
|
+
* proven instead; none is a silent skip.
|
|
48
49
|
*/
|
|
49
50
|
export const PROXY_ROUTED: Record<string, string> = {
|
|
50
51
|
gemini:
|
|
51
52
|
'gemini signs in through the CLI proxy and has no T3 launcher; provider:gemini:inference proves its route from this node.',
|
|
52
53
|
opencode:
|
|
53
54
|
'opencode signs in through the CLI proxy; agent:muse:session and the per-account agent:opencode sessions prove it from this node.',
|
|
55
|
+
grok: 'grok reads the proxy key from its environment and has no grok login; agent:grok:session proves it from this node.',
|
|
56
|
+
antigravity:
|
|
57
|
+
'antigravity signs in through the CLI proxy; agent:antigravity:session proves it from this node.',
|
|
54
58
|
};
|
|
55
59
|
|
|
60
|
+
/** The services a computer signs in to, each checked as `auth:<id>`. */
|
|
61
|
+
const SERVICES: Service[] = [
|
|
62
|
+
{
|
|
63
|
+
id: 'railway',
|
|
64
|
+
command: ['railway', 'whoami', '--json'],
|
|
65
|
+
action: 'Run railway login on this computer.',
|
|
66
|
+
valid: (result) => typeof json(result)?.['id'] === 'string',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'vercel',
|
|
70
|
+
command: ['vercel', 'whoami'],
|
|
71
|
+
action: 'Run vercel login on this computer.',
|
|
72
|
+
valid: (result) =>
|
|
73
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'cloudflare',
|
|
77
|
+
command: ['wrangler', 'whoami', '--json'],
|
|
78
|
+
action: 'Run wrangler login on this computer.',
|
|
79
|
+
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: 'aws',
|
|
83
|
+
command: [
|
|
84
|
+
'aws',
|
|
85
|
+
'sts',
|
|
86
|
+
'get-caller-identity',
|
|
87
|
+
'--output',
|
|
88
|
+
'json',
|
|
89
|
+
'--no-cli-pager',
|
|
90
|
+
],
|
|
91
|
+
action:
|
|
92
|
+
'Select and authenticate the intended AWS profile on this computer.',
|
|
93
|
+
valid: (result) =>
|
|
94
|
+
typeof json(result)?.['Account'] === 'string' &&
|
|
95
|
+
typeof json(result)?.['Arn'] === 'string',
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: 'npm',
|
|
99
|
+
command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
|
|
100
|
+
action: 'Run npm login only if this computer should publish packages.',
|
|
101
|
+
valid: (result) =>
|
|
102
|
+
/^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
export const SERVICE_IDS = SERVICES.map((service) => service.id);
|
|
106
|
+
|
|
56
107
|
export async function serviceAuth(run: Runner): Promise<Check[]> {
|
|
57
|
-
const services: Service[] = [
|
|
58
|
-
{
|
|
59
|
-
id: 'railway',
|
|
60
|
-
command: ['railway', 'whoami', '--json'],
|
|
61
|
-
action: 'Run railway login on this computer.',
|
|
62
|
-
valid: (result) => typeof json(result)?.['id'] === 'string',
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
id: 'vercel',
|
|
66
|
-
command: ['vercel', 'whoami'],
|
|
67
|
-
action: 'Run vercel login on this computer.',
|
|
68
|
-
valid: (result) =>
|
|
69
|
-
/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(result.stdout.trim()),
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
id: 'cloudflare',
|
|
73
|
-
command: ['wrangler', 'whoami', '--json'],
|
|
74
|
-
action: 'Run wrangler login on this computer.',
|
|
75
|
-
valid: (result) => json(result)?.['loggedIn'] === true,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: 'aws',
|
|
79
|
-
command: [
|
|
80
|
-
'aws',
|
|
81
|
-
'sts',
|
|
82
|
-
'get-caller-identity',
|
|
83
|
-
'--output',
|
|
84
|
-
'json',
|
|
85
|
-
'--no-cli-pager',
|
|
86
|
-
],
|
|
87
|
-
action:
|
|
88
|
-
'Select and authenticate the intended AWS profile on this computer.',
|
|
89
|
-
valid: (result) =>
|
|
90
|
-
typeof json(result)?.['Account'] === 'string' &&
|
|
91
|
-
typeof json(result)?.['Arn'] === 'string',
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
id: 'npm',
|
|
95
|
-
command: ['npm', 'whoami', '--registry=https://registry.npmjs.org/'],
|
|
96
|
-
action: 'Run npm login only if this computer should publish packages.',
|
|
97
|
-
valid: (result) =>
|
|
98
|
-
/^[a-z0-9][a-z0-9._-]{0,213}$/.test(result.stdout.trim()),
|
|
99
|
-
},
|
|
100
|
-
];
|
|
101
108
|
const checks: Check[] = [];
|
|
102
|
-
for (const service of
|
|
109
|
+
for (const service of SERVICES) checks.push(await serviceCheck(service, run));
|
|
103
110
|
checks.push(await copilotCheck(run));
|
|
104
111
|
for (const [id, reason] of Object.entries(PROXY_ROUTED))
|
|
105
112
|
checks.push({
|
|
106
113
|
id: `auth:${id}`,
|
|
107
|
-
status: '
|
|
114
|
+
status: 'not-applicable',
|
|
108
115
|
required: false,
|
|
109
116
|
summary: `AUTH_NOT_SUPPORTED_ON_ROUTE: ${reason}`,
|
|
110
117
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { dirname, join } from 'node:path';
|
|
10
|
+
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
11
|
+
import { stateRoot } from './state/paths';
|
|
12
|
+
|
|
13
|
+
/** How the manager reaches a worker, written once: every direct SSH session
|
|
14
|
+
* (src/ssh.ts) and every Ansible run over SSH (fleet-validate's inventory,
|
|
15
|
+
* with ansible/ansible.cfg) takes its options from here, so the two cannot
|
|
16
|
+
* drift. Host keys are checked strictly and never learned on first use. A
|
|
17
|
+
* few attempts and keepalives let a short network blip pass without ending
|
|
18
|
+
* a long operation.
|
|
19
|
+
*/
|
|
20
|
+
export const SSH_TRANSPORT: readonly (readonly [string, string])[] = [
|
|
21
|
+
['BatchMode', 'yes'],
|
|
22
|
+
['StrictHostKeyChecking', 'yes'],
|
|
23
|
+
['UpdateHostKeys', 'no'],
|
|
24
|
+
['ConnectTimeout', '10'],
|
|
25
|
+
['ConnectionAttempts', '3'],
|
|
26
|
+
['ServerAliveInterval', '15'],
|
|
27
|
+
['ServerAliveCountMax', '4'],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** A direct session also forwards nothing and shares no connection. Ansible
|
|
31
|
+
* multiplexes its own (ControlPersist in ansible/ansible.cfg).
|
|
32
|
+
*/
|
|
33
|
+
export const SSH_SESSION: readonly (readonly [string, string])[] = [
|
|
34
|
+
['ClearAllForwardings', 'yes'],
|
|
35
|
+
['PermitLocalCommand', 'no'],
|
|
36
|
+
['ControlMaster', 'no'],
|
|
37
|
+
['ControlPath', 'none'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** The name a worker's host key is pinned under, whatever its address. */
|
|
41
|
+
export const hostKeyAlias = (id: string): string => `devfleet-${id}`;
|
|
42
|
+
|
|
43
|
+
/** The fleet-owned known_hosts file: a cache rendered from the pinned keys
|
|
44
|
+
* (declared in a worker record, or in the fleet state). The operator's own
|
|
45
|
+
* ~/.ssh files are neither read for it nor changed.
|
|
46
|
+
*/
|
|
47
|
+
export function knownHostsPath(env = process.env, home = homedir()): string {
|
|
48
|
+
return join(dirname(stateRoot(env, home)), 'ssh', 'known_hosts');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A worker without an SSH alias is reached from its declared network
|
|
52
|
+
* block: its tailnet name, user and port.
|
|
53
|
+
*/
|
|
54
|
+
export function declaredConnection(
|
|
55
|
+
worker: MachineDefinition | undefined
|
|
56
|
+
):
|
|
57
|
+
| (MachineDefinition & { network: NonNullable<MachineDefinition['network']> })
|
|
58
|
+
| null {
|
|
59
|
+
return worker && worker.ssh === undefined && worker.network
|
|
60
|
+
? (worker as MachineDefinition & {
|
|
61
|
+
network: NonNullable<MachineDefinition['network']>;
|
|
62
|
+
})
|
|
63
|
+
: null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Host keys pinned in the fleet state, by worker ID. Only the devfleet CLI
|
|
67
|
+
* reads the state (it registers the reader); launchers and the worker probe
|
|
68
|
+
* run under Node, which cannot open the state database.
|
|
69
|
+
*/
|
|
70
|
+
type PinReader = () => Record<string, string>;
|
|
71
|
+
let pinReader: PinReader | null = null;
|
|
72
|
+
export function readPinsWith(reader: PinReader | null): void {
|
|
73
|
+
pinReader = reader;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The key each worker reached from declared data is pinned to: the one its
|
|
77
|
+
* record or fleet.json declares, else the one the fleet state pins.
|
|
78
|
+
*/
|
|
79
|
+
export function pinnedKeys(
|
|
80
|
+
definition: FleetDefinition,
|
|
81
|
+
pins: Record<string, string> = pinReader?.() ?? {}
|
|
82
|
+
): Record<string, string> {
|
|
83
|
+
return Object.fromEntries(
|
|
84
|
+
definition.workers.flatMap((worker) => {
|
|
85
|
+
const declared = declaredConnection(worker);
|
|
86
|
+
const key = declared?.network.hostKey ?? pins[worker.id];
|
|
87
|
+
return declared && key ? [[worker.id, key]] : [];
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function knownHostsText(keys: Record<string, string>): string {
|
|
93
|
+
return [
|
|
94
|
+
'# Managed by dev-fleet: rendered from the fleet state and worker records. Do not edit.',
|
|
95
|
+
...Object.entries(keys)
|
|
96
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
97
|
+
.map(([id, key]) => `${hostKeyAlias(id)} ${key}`),
|
|
98
|
+
'',
|
|
99
|
+
].join('\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Writes the fleet-owned known_hosts when it differs; returns its path. */
|
|
103
|
+
export function renderKnownHosts(
|
|
104
|
+
definition: FleetDefinition,
|
|
105
|
+
env = process.env,
|
|
106
|
+
pins?: Record<string, string>
|
|
107
|
+
): string {
|
|
108
|
+
const path = knownHostsPath(env);
|
|
109
|
+
const text = knownHostsText(pinnedKeys(definition, pins));
|
|
110
|
+
if (existsSync(path) && readFileSync(path, 'utf8') === text) return path;
|
|
111
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
112
|
+
writeFileSync(`${path}.new`, text, { mode: 0o600 });
|
|
113
|
+
renameSync(`${path}.new`, path);
|
|
114
|
+
return path;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const option = ([name, value]: readonly [string, string]): string =>
|
|
118
|
+
`${name}=${value}`;
|
|
119
|
+
|
|
120
|
+
/** The options that reach one worker from declared data. */
|
|
121
|
+
export function connectionOptions(
|
|
122
|
+
worker: MachineDefinition & {
|
|
123
|
+
network: NonNullable<MachineDefinition['network']>;
|
|
124
|
+
},
|
|
125
|
+
knownHosts: string
|
|
126
|
+
): [string, string][] {
|
|
127
|
+
return [
|
|
128
|
+
['HostKeyAlias', hostKeyAlias(worker.id)],
|
|
129
|
+
['UserKnownHostsFile', knownHosts],
|
|
130
|
+
['GlobalKnownHostsFile', '/dev/null'],
|
|
131
|
+
['User', worker.network.user],
|
|
132
|
+
['Port', String(worker.network.sshPort)],
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `-o NAME=VALUE` for each option, as ssh takes them. */
|
|
137
|
+
export function sshOptionArgs(
|
|
138
|
+
options: readonly (readonly [string, string])[]
|
|
139
|
+
): string[] {
|
|
140
|
+
return options.flatMap((entry) => ['-o', option(entry)]);
|
|
141
|
+
}
|
package/src/ssh.ts
CHANGED
|
@@ -1,40 +1,79 @@
|
|
|
1
1
|
import type { CommandResult, FleetNode, Check } from './types';
|
|
2
|
+
import { fleet } from './fleet-config';
|
|
3
|
+
import {
|
|
4
|
+
connectionOptions,
|
|
5
|
+
declaredConnection,
|
|
6
|
+
renderKnownHosts,
|
|
7
|
+
SSH_SESSION,
|
|
8
|
+
SSH_TRANSPORT,
|
|
9
|
+
sshOptionArgs,
|
|
10
|
+
} from './ssh-connection';
|
|
2
11
|
|
|
3
12
|
export function shellQuote(value: string): string {
|
|
4
13
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
5
14
|
}
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
/** The worker fleet.json declares for a roster entry that is reached from
|
|
17
|
+
* declared data, if it is one. No configuration (a fresh machine) is none.
|
|
18
|
+
*/
|
|
19
|
+
function declaredWorker(node: Pick<FleetNode, 'id' | 'ssh'>) {
|
|
20
|
+
try {
|
|
21
|
+
const worker = declaredConnection(
|
|
22
|
+
fleet.workers.find((entry) => entry.id === node.id)
|
|
23
|
+
);
|
|
24
|
+
return worker && worker.network.tailscaleName === node.ssh ? worker : null;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One SSH session to a node: unattended, strict about the host key,
|
|
31
|
+
* forwarding nothing. A worker without an alias is reached from its declared
|
|
32
|
+
* user, port and tailnet name, against the fleet-owned known_hosts.
|
|
33
|
+
*/
|
|
34
|
+
export function sshArgs(node: Pick<FleetNode, 'id' | 'ssh'>): string[] {
|
|
35
|
+
const declared = declaredWorker(node);
|
|
8
36
|
return [
|
|
9
37
|
'ssh',
|
|
10
38
|
'-T',
|
|
11
39
|
'-a',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
'UpdateHostKeys=no',
|
|
18
|
-
'-o',
|
|
19
|
-
'ConnectTimeout=8',
|
|
20
|
-
'-o',
|
|
21
|
-
'ConnectionAttempts=1',
|
|
22
|
-
'-o',
|
|
23
|
-
'ServerAliveInterval=15',
|
|
24
|
-
'-o',
|
|
25
|
-
'ServerAliveCountMax=4',
|
|
26
|
-
'-o',
|
|
27
|
-
'ClearAllForwardings=yes',
|
|
28
|
-
'-o',
|
|
29
|
-
'PermitLocalCommand=no',
|
|
30
|
-
'-o',
|
|
31
|
-
'ControlMaster=no',
|
|
32
|
-
'-o',
|
|
33
|
-
'ControlPath=none',
|
|
40
|
+
...sshOptionArgs(SSH_TRANSPORT),
|
|
41
|
+
...(declared
|
|
42
|
+
? sshOptionArgs(connectionOptions(declared, renderKnownHosts(fleet)))
|
|
43
|
+
: []),
|
|
44
|
+
...sshOptionArgs(SSH_SESSION),
|
|
34
45
|
node.ssh,
|
|
35
46
|
];
|
|
36
47
|
}
|
|
37
48
|
|
|
49
|
+
/** How a package playbook that addresses a worker over SSH itself
|
|
50
|
+
* (ansible/ubuntu/worker-account.yml, ansible/ubuntu/reboot.yml) reaches it:
|
|
51
|
+
* the options every direct session takes, as one `ansible_ssh_common_args`
|
|
52
|
+
* value, and the account a worker reached from declared data logs in as. The
|
|
53
|
+
* play names the login itself (root, or the declared account), so the
|
|
54
|
+
* declared user is not among the options.
|
|
55
|
+
*/
|
|
56
|
+
export function playbookConnection(node: Pick<FleetNode, 'id' | 'ssh'>): {
|
|
57
|
+
options: string;
|
|
58
|
+
user: string | null;
|
|
59
|
+
} {
|
|
60
|
+
const declared = declaredWorker(node);
|
|
61
|
+
const options = [
|
|
62
|
+
...SSH_TRANSPORT,
|
|
63
|
+
...(declared
|
|
64
|
+
? connectionOptions(declared, renderKnownHosts(fleet)).filter(
|
|
65
|
+
([name]) => name !== 'User'
|
|
66
|
+
)
|
|
67
|
+
: []),
|
|
68
|
+
];
|
|
69
|
+
return {
|
|
70
|
+
options: sshOptionArgs(options)
|
|
71
|
+
.map((arg) => (arg === '-o' ? arg : shellQuote(arg)))
|
|
72
|
+
.join(' '),
|
|
73
|
+
user: declared?.network.user ?? null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
38
77
|
export function remoteCommand(node: FleetNode, request: object): string[] {
|
|
39
78
|
const encoded = Buffer.from(JSON.stringify(request)).toString('base64');
|
|
40
79
|
const command = `cd -- ${shellQuote(node.workspace)} && export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH" && exec ${shellQuote(node.runtime ?? 'node')} --input-type=module - ${shellQuote(encoded)}`;
|
|
@@ -48,7 +87,7 @@ const FAILURES = [
|
|
|
48
87
|
id: 'ssh:host-key',
|
|
49
88
|
summary: 'SSH host key is unknown or changed.',
|
|
50
89
|
action:
|
|
51
|
-
'
|
|
90
|
+
'The worker presented a host key other than the pinned one. Verify its fingerprint out of band, then pin the new key: devfleet fleet-node-replace --id ID --host-key "TYPE BASE64" --apply --yes, or change hostKey in its worker record. A worker reached through your own SSH alias is checked against your known_hosts instead. Never disable host-key checking.',
|
|
52
91
|
},
|
|
53
92
|
{
|
|
54
93
|
pattern:
|