@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
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
2
4
|
import { fleet as definition } from '../fleet-config';
|
|
3
5
|
import { sshArgs, shellQuote } from '../ssh';
|
|
4
6
|
import { resolveVaultConfig, usesVault } from '../vault-config';
|
|
7
|
+
import { workerTokenLocation } from '../worker-credentials';
|
|
8
|
+
import { runAnsible } from '../platform/ansible';
|
|
5
9
|
import type { Check, FleetNode, Runner } from '../types';
|
|
6
10
|
|
|
7
11
|
const TOOL_PATH =
|
|
@@ -15,7 +19,11 @@ const terminal: TerminalRunner = (args) =>
|
|
|
15
19
|
|
|
16
20
|
type VaultState = 'authenticated' | 'unauthenticated' | 'unavailable';
|
|
17
21
|
|
|
18
|
-
/**
|
|
22
|
+
/** The field of the fleet's shared document that held a worker's token
|
|
23
|
+
* before each worker had a document of its own (src/worker-credentials.ts).
|
|
24
|
+
* Read only when the worker's own document holds none, and reported: every
|
|
25
|
+
* worker that can read the shared document can read that field.
|
|
26
|
+
*/
|
|
19
27
|
export function workerTokenField(node: string): string {
|
|
20
28
|
return `FLEET_WORKER_TOKEN_${node.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
21
29
|
}
|
|
@@ -49,7 +57,13 @@ export async function authorizeManager(
|
|
|
49
57
|
*/
|
|
50
58
|
export async function authorizeVault(
|
|
51
59
|
node: FleetNode,
|
|
52
|
-
options: {
|
|
60
|
+
options: {
|
|
61
|
+
interactive: boolean;
|
|
62
|
+
/** Opt-in: the fleet's shared Vault user, typed on the worker's own
|
|
63
|
+
* terminal, when no token of its own was issued.
|
|
64
|
+
*/
|
|
65
|
+
passwordLogin?: boolean;
|
|
66
|
+
},
|
|
53
67
|
run: Runner,
|
|
54
68
|
attach: TerminalRunner = terminal
|
|
55
69
|
): Promise<Check[]> {
|
|
@@ -59,8 +73,10 @@ export async function authorizeVault(
|
|
|
59
73
|
if (before === 'authenticated') return [pass(node)];
|
|
60
74
|
// Fresh node: the locked toolchain (including vault) is not installed yet.
|
|
61
75
|
if (before === 'unavailable') return [];
|
|
62
|
-
|
|
63
|
-
if (
|
|
76
|
+
const delivered = await deliverWorkerToken(node, run);
|
|
77
|
+
if (delivered === 'own') return [pass(node)];
|
|
78
|
+
if (delivered === 'shared') return [pass(node), shared(node)];
|
|
79
|
+
if (!options.interactive || !options.passwordLogin) return [required(node)];
|
|
64
80
|
process.stderr.write(
|
|
65
81
|
`[${node.id}] No issued worker token found. Enter the Vault password to log this machine in.\n`
|
|
66
82
|
);
|
|
@@ -72,30 +88,54 @@ export async function authorizeVault(
|
|
|
72
88
|
];
|
|
73
89
|
}
|
|
74
90
|
|
|
91
|
+
/** Delivers the worker's token, from its own document or else from the
|
|
92
|
+
* field it used to have in the shared one; says which, or that none was.
|
|
93
|
+
*/
|
|
75
94
|
async function deliverWorkerToken(
|
|
76
95
|
node: FleetNode,
|
|
77
96
|
run: Runner
|
|
78
|
-
): Promise<
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
'
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
): Promise<'own' | 'shared' | null> {
|
|
98
|
+
const vault = resolveVaultConfig();
|
|
99
|
+
const own = workerTokenLocation(node.id, vault);
|
|
100
|
+
const sources = [
|
|
101
|
+
{ from: 'own' as const, ...own },
|
|
102
|
+
{
|
|
103
|
+
from: 'shared' as const,
|
|
104
|
+
mount: vault.mount,
|
|
105
|
+
path: `${vault.project}/${vault.config}`,
|
|
106
|
+
field: workerTokenField(node.id),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
for (const source of sources) {
|
|
110
|
+
const secret = await run(
|
|
111
|
+
[
|
|
112
|
+
'vault',
|
|
113
|
+
'kv',
|
|
114
|
+
'get',
|
|
115
|
+
`-address=${vault.addr}`,
|
|
116
|
+
`-mount=${source.mount}`,
|
|
117
|
+
`-field=${source.field}`,
|
|
118
|
+
source.path,
|
|
119
|
+
],
|
|
120
|
+
{ timeoutMs: 20_000 }
|
|
121
|
+
);
|
|
122
|
+
const token = secret.ok ? secret.stdout.trim() : '';
|
|
123
|
+
if (!/^[\x21-\x7e]{16,4096}$/.test(token)) continue;
|
|
124
|
+
const delivered = await run(
|
|
125
|
+
remote(node, [
|
|
126
|
+
'vault',
|
|
127
|
+
'login',
|
|
128
|
+
`-address=${vault.addr}`,
|
|
129
|
+
'-no-print',
|
|
130
|
+
'-',
|
|
131
|
+
]),
|
|
132
|
+
{ input: token + '\n', timeoutMs: 30_000 }
|
|
133
|
+
);
|
|
134
|
+
return delivered.ok && (await vaultState(node, run)) === 'authenticated'
|
|
135
|
+
? source.from
|
|
136
|
+
: null;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
async function vaultState(node: FleetNode, run: Runner): Promise<VaultState> {
|
|
@@ -153,6 +193,18 @@ function pass(node: FleetNode): Check {
|
|
|
153
193
|
};
|
|
154
194
|
}
|
|
155
195
|
|
|
196
|
+
function shared(node: FleetNode): Check {
|
|
197
|
+
const own = workerTokenLocation(node.id);
|
|
198
|
+
return {
|
|
199
|
+
id: 'auth:vault-isolation',
|
|
200
|
+
status: 'warn',
|
|
201
|
+
summary: 'VAULT_WORKER_TOKEN_SHARED',
|
|
202
|
+
remedy: {
|
|
203
|
+
action: `${node.id}'s token was read from the fleet's shared document, which every worker can read. Issue it a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes (kept at ${own.mount}/${own.path}), then remove ${workerTokenField(node.id)} from the shared document.`,
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
156
208
|
function required(node: FleetNode): Check {
|
|
157
209
|
const { addr } = resolveVaultConfig();
|
|
158
210
|
return {
|
|
@@ -160,7 +212,81 @@ function required(node: FleetNode): Check {
|
|
|
160
212
|
status: 'fail',
|
|
161
213
|
summary: 'VAULT_LOGIN_REQUIRED',
|
|
162
214
|
remedy: {
|
|
163
|
-
action: `
|
|
215
|
+
action: `Issue ${node.id} a token of its own with devfleet fleet-node-replace --id ${node.id} --apply --yes. Opt-in fallback: devfleet fleet-sync --vault-password-login in a terminal, or on ${node.id}: vault login -address=${addr} -method=userpass username=${definition.worker!.vaultUser}. Proxy providers in T3 stay unavailable until then.`,
|
|
164
216
|
},
|
|
165
217
|
};
|
|
166
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The manager's own Vault session renews itself every ten minutes, like each
|
|
222
|
+
* worker's: `ansible/macos/vault-renew.yml`, applied on every applying sync.
|
|
223
|
+
* Measured as the session answering and the job being loaded; a lapsed
|
|
224
|
+
* session needs one login by a person, and until then agents on the manager
|
|
225
|
+
* use the proxy client key they last read (src/agents/secrets.ts).
|
|
226
|
+
*/
|
|
227
|
+
export async function holdManagerVault(
|
|
228
|
+
run: Runner,
|
|
229
|
+
options: {
|
|
230
|
+
apply: boolean;
|
|
231
|
+
root: string;
|
|
232
|
+
home?: string;
|
|
233
|
+
uid?: number;
|
|
234
|
+
scratch?: string;
|
|
235
|
+
}
|
|
236
|
+
): Promise<Check[]> {
|
|
237
|
+
if (!usesVault()) return [];
|
|
238
|
+
const { addr } = resolveVaultConfig();
|
|
239
|
+
const home = options.home ?? homedir();
|
|
240
|
+
if (options.apply)
|
|
241
|
+
await runAnsible(
|
|
242
|
+
run,
|
|
243
|
+
join(options.root, 'ansible/macos/vault-renew.yml'),
|
|
244
|
+
{
|
|
245
|
+
input: JSON.stringify({
|
|
246
|
+
vault_address: addr,
|
|
247
|
+
mise: join(home, '.local/bin/mise'),
|
|
248
|
+
}),
|
|
249
|
+
timeoutMs: 120_000,
|
|
250
|
+
},
|
|
251
|
+
options.scratch
|
|
252
|
+
);
|
|
253
|
+
const loaded = (
|
|
254
|
+
await run(
|
|
255
|
+
[
|
|
256
|
+
'launchctl',
|
|
257
|
+
'print',
|
|
258
|
+
`gui/${options.uid ?? process.getuid?.() ?? 0}/${MANAGER_VAULT_RENEWAL}`,
|
|
259
|
+
],
|
|
260
|
+
{ timeoutMs: 10_000 }
|
|
261
|
+
)
|
|
262
|
+
).ok;
|
|
263
|
+
const session = (await run(lookup(), { timeoutMs: 20_000 })).ok;
|
|
264
|
+
return [vaultRenewalCheck(session, loaded, addr)];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function vaultRenewalCheck(
|
|
268
|
+
session: boolean,
|
|
269
|
+
loaded: boolean,
|
|
270
|
+
addr: string
|
|
271
|
+
): Check {
|
|
272
|
+
if (session && loaded)
|
|
273
|
+
return {
|
|
274
|
+
id: 'manager:vault-renewal',
|
|
275
|
+
status: 'pass',
|
|
276
|
+
summary:
|
|
277
|
+
'The manager is logged in to Vault and renews its session every ten minutes.',
|
|
278
|
+
};
|
|
279
|
+
return {
|
|
280
|
+
id: 'manager:vault-renewal',
|
|
281
|
+
status: 'fail',
|
|
282
|
+
summary: session ? 'VAULT_RENEWAL_MISSING' : 'VAULT_LOGIN_REQUIRED',
|
|
283
|
+
remedy: {
|
|
284
|
+
action: session
|
|
285
|
+
? `The manager's Vault session is not renewed and lapses at its TTL. Run devfleet fleet-sync on the manager; it loads ${MANAGER_VAULT_RENEWAL}.`
|
|
286
|
+
: `Log the manager in once, in your own terminal: vault login -address=${addr} -method=userpass username=${definition.worker?.vaultUser ?? 'YOUR_USERNAME'}. Then run devfleet fleet-sync. Until then the manager's agents use the proxy client key they last read, and new worker tokens cannot be issued.`,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The launchd label of the manager's Vault renewal job (vault-renew.yml). */
|
|
292
|
+
export const MANAGER_VAULT_RENEWAL = 'dev.devfleet.vault-renew';
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import type { FleetDefinition } from '../definition';
|
|
2
|
+
import {
|
|
3
|
+
controlRemedy,
|
|
4
|
+
TailnetControlError,
|
|
5
|
+
type TailnetControl,
|
|
6
|
+
} from '../tailnet-control';
|
|
7
|
+
import type { TailnetComputer } from '../tailscale';
|
|
8
|
+
import type { PackagePlaybook } from '../platform/ansible';
|
|
9
|
+
import type { Check, FleetNode, NodeReport } from '../types';
|
|
10
|
+
import { createWorkerAccount } from '../worker-account';
|
|
11
|
+
import {
|
|
12
|
+
rebootNeeded,
|
|
13
|
+
rebootWorker,
|
|
14
|
+
updatesPending,
|
|
15
|
+
type RebootReason,
|
|
16
|
+
} from '../worker-reboot';
|
|
17
|
+
|
|
18
|
+
/** What one worker's deployment needs around its converge: the converge
|
|
19
|
+
* itself (bootstrap, verification, monitoring), the tailnet control plane
|
|
20
|
+
* when the fleet declares one, the tailnet as the manager sees it, and what
|
|
21
|
+
* the run was asked to do.
|
|
22
|
+
*/
|
|
23
|
+
export interface WorkerDeployment {
|
|
24
|
+
definition: FleetDefinition;
|
|
25
|
+
/** Applies a playbook of the package from the manager. */
|
|
26
|
+
playbook: PackagePlaybook;
|
|
27
|
+
/** One converge of the worker; `tailnetKey` is handed to its bootstrap. */
|
|
28
|
+
converge: (
|
|
29
|
+
node: FleetNode,
|
|
30
|
+
revision: string,
|
|
31
|
+
tailnetKey?: string
|
|
32
|
+
) => Promise<NodeReport>;
|
|
33
|
+
/** Null when the fleet declares no tailnet control plane. */
|
|
34
|
+
control: () => Promise<TailnetControl | null>;
|
|
35
|
+
/** The devices of the tailnet, as the manager's Tailscale lists them. */
|
|
36
|
+
computers: () => Promise<TailnetComputer[]>;
|
|
37
|
+
/** The workers that converged in an earlier run. */
|
|
38
|
+
readyBefore: string[];
|
|
39
|
+
maintenanceReboot?: boolean;
|
|
40
|
+
force?: boolean;
|
|
41
|
+
/** Whether the bootstrap of this revision reads a tailnet key. */
|
|
42
|
+
acceptsTailnetKey: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Deploys one worker and, around its converge: hands a worker that is not
|
|
46
|
+
* on the tailnet a single-use key, keeps an enrolled worker's node key from
|
|
47
|
+
* expiring, creates the declared account on an image that lets only root
|
|
48
|
+
* in, and reboots the worker once when its first converge needs it (or for
|
|
49
|
+
* maintenance, when asked), converging it again to verify it.
|
|
50
|
+
*/
|
|
51
|
+
export async function deployWorker(
|
|
52
|
+
node: FleetNode,
|
|
53
|
+
revision: string,
|
|
54
|
+
deployment: WorkerDeployment
|
|
55
|
+
): Promise<NodeReport> {
|
|
56
|
+
const before: Check[] = [];
|
|
57
|
+
const key = await enrolmentKey(node, deployment, before);
|
|
58
|
+
before.push(...(await keyExpiry(node, deployment)));
|
|
59
|
+
let target = node;
|
|
60
|
+
let result = await deployment.converge(target, revision, key);
|
|
61
|
+
const account = await rootOnly(node, result, deployment);
|
|
62
|
+
if (account) {
|
|
63
|
+
before.push(account.check);
|
|
64
|
+
if (account.node) {
|
|
65
|
+
target = account.node;
|
|
66
|
+
result = await deployment.converge(target, revision, key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const reason = rebootReason(node.id, result.checks, deployment);
|
|
70
|
+
if (reason) {
|
|
71
|
+
const reboot = await rebootWorker(
|
|
72
|
+
target,
|
|
73
|
+
{
|
|
74
|
+
revision,
|
|
75
|
+
reason,
|
|
76
|
+
...(reason === 'maintenance' && deployment.force
|
|
77
|
+
? { force: true }
|
|
78
|
+
: {}),
|
|
79
|
+
},
|
|
80
|
+
deployment.playbook
|
|
81
|
+
);
|
|
82
|
+
if (reboot.status === 'pass')
|
|
83
|
+
result = await deployment.converge(target, revision);
|
|
84
|
+
result.checks.unshift(reboot);
|
|
85
|
+
}
|
|
86
|
+
result.checks.unshift(...before);
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why this worker is rebooted now, if it is. A worker that converged
|
|
91
|
+
* before is never rebooted for its first converge; only an explicit
|
|
92
|
+
* maintenance run reboots it.
|
|
93
|
+
*/
|
|
94
|
+
function rebootReason(
|
|
95
|
+
id: string,
|
|
96
|
+
checks: Check[],
|
|
97
|
+
deployment: WorkerDeployment
|
|
98
|
+
): RebootReason | null {
|
|
99
|
+
if (deployment.maintenanceReboot && updatesPending(checks))
|
|
100
|
+
return 'maintenance';
|
|
101
|
+
if (!deployment.readyBefore.includes(id) && rebootNeeded(checks))
|
|
102
|
+
return 'first-converge';
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A single-use, pre-authorised, tagged key for a worker the roster has no
|
|
107
|
+
* tailnet identity for yet. The key is never reported.
|
|
108
|
+
*/
|
|
109
|
+
async function enrolmentKey(
|
|
110
|
+
node: FleetNode,
|
|
111
|
+
deployment: WorkerDeployment,
|
|
112
|
+
checks: Check[]
|
|
113
|
+
): Promise<string | undefined> {
|
|
114
|
+
if (node.tailscaleId || !deployment.acceptsTailnetKey) return undefined;
|
|
115
|
+
try {
|
|
116
|
+
const control = await deployment.control();
|
|
117
|
+
if (!control) return undefined;
|
|
118
|
+
const minted = await control.createAuthKey({ worker: node.id });
|
|
119
|
+
checks.push({
|
|
120
|
+
id: 'tailscale:enrolment-key',
|
|
121
|
+
status: 'pass',
|
|
122
|
+
summary: `A single-use, pre-authorised key tagged ${deployment.definition.tailnet!.tag}, expiring within minutes, was handed to the worker on the bootstrap's stdin.`,
|
|
123
|
+
});
|
|
124
|
+
return minted.key;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (!(error instanceof TailnetControlError)) throw error;
|
|
127
|
+
checks.push(controlFailure('tailscale:enrolment-key', error));
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** An enrolled worker's node key never expires: the control plane disables
|
|
133
|
+
* expiry for a worker whose key has one (tagged workers have none on the
|
|
134
|
+
* hosted Tailscale). The tailscale:key-expiry check still measures it.
|
|
135
|
+
*/
|
|
136
|
+
async function keyExpiry(
|
|
137
|
+
node: FleetNode,
|
|
138
|
+
deployment: WorkerDeployment
|
|
139
|
+
): Promise<Check[]> {
|
|
140
|
+
if (!node.tailscaleId) return [];
|
|
141
|
+
try {
|
|
142
|
+
const control = await deployment.control();
|
|
143
|
+
if (!control) return [];
|
|
144
|
+
const peer = (await deployment.computers()).find(
|
|
145
|
+
(computer) => computer.id === node.tailscaleId
|
|
146
|
+
);
|
|
147
|
+
if (peer?.keyExpiry === undefined) return [];
|
|
148
|
+
await control.disableKeyExpiry(node.tailscaleId);
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
id: 'tailscale:key-expiry-policy',
|
|
152
|
+
status: 'pass',
|
|
153
|
+
summary:
|
|
154
|
+
'Key expiry of this worker was disabled through the tailnet control plane; tailscale:key-expiry measures it again on the next run.',
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!(error instanceof TailnetControlError)) throw error;
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
...controlFailure('tailscale:key-expiry-policy', error),
|
|
162
|
+
status: 'warn',
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function controlFailure(id: string, error: TailnetControlError): Check {
|
|
169
|
+
return {
|
|
170
|
+
id,
|
|
171
|
+
status: 'fail',
|
|
172
|
+
summary: error.code,
|
|
173
|
+
remedy: { action: controlRemedy(error.code) },
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The bootstrap refused to run as root: the alias logs in as root. With a
|
|
178
|
+
* declared account, it is created over that login and the run continues as
|
|
179
|
+
* it (`user@alias`); the next runs need the alias to name it, since the SSH
|
|
180
|
+
* policy turns root login off.
|
|
181
|
+
*/
|
|
182
|
+
async function rootOnly(
|
|
183
|
+
node: FleetNode,
|
|
184
|
+
result: NodeReport,
|
|
185
|
+
deployment: WorkerDeployment
|
|
186
|
+
): Promise<{ check: Check; node?: FleetNode } | null> {
|
|
187
|
+
if (
|
|
188
|
+
!result.checks.some(
|
|
189
|
+
(check) => check.summary === 'ROOT_DEVELOPMENT_USER_REJECTED'
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return null;
|
|
193
|
+
const user = deployment.definition.workers.find(
|
|
194
|
+
(worker) => worker.id === node.id
|
|
195
|
+
)?.network?.user;
|
|
196
|
+
const check = await createWorkerAccount(
|
|
197
|
+
{ id: node.id, ssh: node.ssh, login: '' },
|
|
198
|
+
user,
|
|
199
|
+
deployment.playbook
|
|
200
|
+
);
|
|
201
|
+
if (check.status !== 'pass') return { check };
|
|
202
|
+
return {
|
|
203
|
+
check: {
|
|
204
|
+
...check,
|
|
205
|
+
status: 'warn',
|
|
206
|
+
summary: `${check.summary} This run continued as ${user}@${node.ssh}; set User ${user} for the SSH alias ${node.ssh}, since root login is turned off.`,
|
|
207
|
+
},
|
|
208
|
+
node: {
|
|
209
|
+
...node,
|
|
210
|
+
ssh: `${user}@${node.ssh}`,
|
|
211
|
+
workspace: `/home/${user}/.local/share/personal/fleet`,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|