@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
|
@@ -4,14 +4,28 @@ import { verifyUbuntuBoot } from './platform/ubuntu-verify';
|
|
|
4
4
|
import { verifyResourceSafety } from './platform/resource-verify';
|
|
5
5
|
import policy from '../ansible/ubuntu/policy.json';
|
|
6
6
|
import { userInfo } from 'node:os';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
T3_HEALTH_SCRIPT,
|
|
9
|
+
runningVersionCheck,
|
|
10
|
+
t3Unit,
|
|
11
|
+
} from './platform/t3-service';
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_FEATURES,
|
|
14
|
+
featuresCheck,
|
|
15
|
+
notApplicable,
|
|
16
|
+
offReason,
|
|
17
|
+
partsOff,
|
|
18
|
+
presentParts,
|
|
19
|
+
type WorkerFeatures,
|
|
20
|
+
} from './worker-features';
|
|
21
|
+
import { detectMachineClass, type MachineClass } from './machine/machine-class';
|
|
8
22
|
import { compareDebianVersions } from './platform/debian-version';
|
|
9
23
|
import { packagePins, ubuntuRelease } from './platform/ubuntu-release';
|
|
10
24
|
import type { Check, Runner } from './types';
|
|
11
25
|
|
|
12
26
|
function check(id: string, ok: boolean, summary: string): Check {
|
|
13
27
|
return {
|
|
14
|
-
id
|
|
28
|
+
id,
|
|
15
29
|
status: ok ? 'pass' : 'fail',
|
|
16
30
|
summary,
|
|
17
31
|
...(!ok
|
|
@@ -35,7 +49,7 @@ export function pinnedPackage(
|
|
|
35
49
|
installed === undefined ? -1 : compareDebianVersions(installed, pinned);
|
|
36
50
|
if (order <= 0)
|
|
37
51
|
return check(
|
|
38
|
-
`package:${name}`,
|
|
52
|
+
`worker:package:${name}`,
|
|
39
53
|
order === 0,
|
|
40
54
|
order === 0
|
|
41
55
|
? 'Installed at the version the policy pins.'
|
|
@@ -69,25 +83,34 @@ export function serviceState(name: string, show: string): string {
|
|
|
69
83
|
return `${code} ${name}.service (${state}, unit file ${file || 'missing'}, restart ${field('Restart') || 'unknown'})`;
|
|
70
84
|
}
|
|
71
85
|
|
|
72
|
-
/** Measured runtime evidence, independent of installer receipts.
|
|
86
|
+
/** Measured runtime evidence, independent of installer receipts. What a
|
|
87
|
+
* worker's declared features leave out, and what its class of machine does
|
|
88
|
+
* not have, is reported as not applicable, with the reason. */
|
|
73
89
|
export async function workerHostChecks(
|
|
74
90
|
user: string,
|
|
75
|
-
run: Runner
|
|
91
|
+
run: Runner,
|
|
92
|
+
features: WorkerFeatures = DEFAULT_FEATURES,
|
|
93
|
+
powerRecoveryNote?: string
|
|
76
94
|
): Promise<Check[]> {
|
|
77
95
|
const audit = await auditUbuntu(run);
|
|
96
|
+
const off = partsOff(features);
|
|
97
|
+
const { machine, virtualization } = await detectMachineClass(run);
|
|
78
98
|
const checks = [
|
|
79
99
|
check(
|
|
80
|
-
'ubuntu',
|
|
100
|
+
'worker:ubuntu',
|
|
81
101
|
audit.supported,
|
|
82
102
|
'Required Ubuntu release and architecture.'
|
|
83
103
|
),
|
|
84
104
|
check(
|
|
85
|
-
'sudo',
|
|
105
|
+
'worker:sudo',
|
|
86
106
|
audit.passwordlessSudo,
|
|
87
107
|
'Designated worker user has noninteractive sudo.'
|
|
88
108
|
),
|
|
89
109
|
...Object.entries(packagePins(ubuntuRelease(audit.release))).map(
|
|
90
|
-
([name, version]) =>
|
|
110
|
+
([name, version]) =>
|
|
111
|
+
off.packages.includes(name)
|
|
112
|
+
? notApplicable(`worker:package:${name}`, offReason(features, name))
|
|
113
|
+
: pinnedPackage(name, version, audit.packages[name])
|
|
91
114
|
),
|
|
92
115
|
pinnedPackage(
|
|
93
116
|
'tailscale',
|
|
@@ -96,6 +119,12 @@ export async function workerHostChecks(
|
|
|
96
119
|
),
|
|
97
120
|
];
|
|
98
121
|
for (const name of policy.alwaysOnServices) {
|
|
122
|
+
if (off.services.includes(name)) {
|
|
123
|
+
checks.push(
|
|
124
|
+
notApplicable(`worker:service:${name}`, offReason(features, name))
|
|
125
|
+
);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
99
128
|
const unit = await run([
|
|
100
129
|
'systemctl',
|
|
101
130
|
'show',
|
|
@@ -113,7 +142,7 @@ export async function workerHostChecks(
|
|
|
113
142
|
/^Restart=always$/m.test(unit.stdout);
|
|
114
143
|
checks.push(
|
|
115
144
|
check(
|
|
116
|
-
`service:${name}`,
|
|
145
|
+
`worker:service:${name}`,
|
|
117
146
|
ok,
|
|
118
147
|
ok
|
|
119
148
|
? 'Required system service is active, enabled for boot and restarts unconditionally.'
|
|
@@ -122,33 +151,48 @@ export async function workerHostChecks(
|
|
|
122
151
|
);
|
|
123
152
|
}
|
|
124
153
|
checks.push(await rebootPendingCheck(audit.rebootPending, run));
|
|
125
|
-
checks.push(
|
|
126
|
-
checks.push(
|
|
154
|
+
checks.push(featuresCheck(features, await presentParts(run, user, features)));
|
|
155
|
+
checks.push(
|
|
156
|
+
...(await recoveryChecks(run, features, {
|
|
157
|
+
machine,
|
|
158
|
+
virtualization,
|
|
159
|
+
...(powerRecoveryNote ? { note: powerRecoveryNote } : {}),
|
|
160
|
+
}))
|
|
161
|
+
);
|
|
162
|
+
checks.push(...(await verifyUbuntuBoot(user, run, machine)));
|
|
127
163
|
checks.push(...(await verifyResourceSafety(userInfo().homedir, run)));
|
|
128
164
|
return checks;
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
async function recoveryChecks(
|
|
167
|
+
async function recoveryChecks(
|
|
168
|
+
run: Runner,
|
|
169
|
+
features: WorkerFeatures,
|
|
170
|
+
host: { machine: MachineClass; virtualization: string; note?: string }
|
|
171
|
+
): Promise<Check[]> {
|
|
132
172
|
const checks: Check[] = [];
|
|
133
173
|
const t3 = await run([
|
|
134
174
|
'systemctl',
|
|
135
175
|
'--user',
|
|
136
176
|
'show',
|
|
137
|
-
|
|
177
|
+
t3Unit(features.session),
|
|
138
178
|
'--property=ActiveState,UnitFileState,Restart',
|
|
139
179
|
]);
|
|
140
180
|
checks.push(
|
|
141
181
|
check(
|
|
142
|
-
't3-recovery',
|
|
182
|
+
'worker:t3-recovery',
|
|
143
183
|
t3.ok &&
|
|
144
184
|
/^ActiveState=active$/m.test(t3.stdout) &&
|
|
145
185
|
/^UnitFileState=enabled$/m.test(t3.stdout) &&
|
|
146
186
|
/^Restart=(always|on-failure)$/m.test(t3.stdout),
|
|
147
|
-
|
|
187
|
+
features.session === 'headless'
|
|
188
|
+
? 'The T3 server runs as an enabled lingering user service with automatic crash recovery.'
|
|
189
|
+
: 'The T3 desktop app runs as an enabled session service with automatic crash recovery.'
|
|
148
190
|
)
|
|
149
191
|
);
|
|
150
192
|
checks.push(await t3HealthCheck(run));
|
|
151
|
-
checks.push(
|
|
193
|
+
checks.push(
|
|
194
|
+
await runningVersionCheck(run, userInfo().uid, Date.now(), features.session)
|
|
195
|
+
);
|
|
152
196
|
const renewal = await run([
|
|
153
197
|
'systemctl',
|
|
154
198
|
'--user',
|
|
@@ -157,13 +201,20 @@ async function recoveryChecks(run: Runner): Promise<Check[]> {
|
|
|
157
201
|
]);
|
|
158
202
|
checks.push(
|
|
159
203
|
check(
|
|
160
|
-
'credential-renewal',
|
|
204
|
+
'worker:credential-renewal',
|
|
161
205
|
renewal.ok,
|
|
162
206
|
'Worker credential renewal timer is active.'
|
|
163
207
|
)
|
|
164
208
|
);
|
|
165
209
|
checks.push(await serviceRecoveryCheck(run));
|
|
166
|
-
checks.push(
|
|
210
|
+
checks.push(
|
|
211
|
+
host.machine === 'virtual'
|
|
212
|
+
? notApplicable(
|
|
213
|
+
'worker:power-loss-recovery',
|
|
214
|
+
`a virtual machine (${host.virtualization}) has no firmware its guest can read, and its provider restarts it after a power loss${host.note ? ` (${host.note})` : ''}`
|
|
215
|
+
)
|
|
216
|
+
: await powerRecoveryCheck(run)
|
|
217
|
+
);
|
|
167
218
|
return checks;
|
|
168
219
|
}
|
|
169
220
|
|
|
@@ -194,7 +245,7 @@ export async function serviceRecoveryCheck(run: Runner): Promise<Check> {
|
|
|
194
245
|
? `WORKER_RECOVERY_LAST_RUN_FAILED (${/^[\w-]+$/.test(result) ? result : 'unknown'})`
|
|
195
246
|
: 'Worker service recovery timer is active and its last run succeeded.';
|
|
196
247
|
return check(
|
|
197
|
-
'service-recovery',
|
|
248
|
+
'worker:service-recovery',
|
|
198
249
|
timer.ok && script.ok && unit.ok && result === 'success',
|
|
199
250
|
summary
|
|
200
251
|
);
|
|
@@ -224,7 +275,7 @@ async function t3HealthCheck(run: Runner): Promise<Check> {
|
|
|
224
275
|
/* Raw responses are never reported. */
|
|
225
276
|
}
|
|
226
277
|
return check(
|
|
227
|
-
't3-health',
|
|
278
|
+
'worker:t3-health',
|
|
228
279
|
probe.ok && identified,
|
|
229
280
|
'T3 local API answers with its environment identity within 10 seconds.'
|
|
230
281
|
);
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { PACKAGE_ROOT } from './package-info';
|
|
5
|
+
import { shellQuote, sshArgs, sshFailure } from './ssh';
|
|
6
|
+
import type { Check, FleetNode, Runner } from './types';
|
|
7
|
+
|
|
8
|
+
/** What a worker is made of, measured: every installed package and its
|
|
9
|
+
* version, the tools mise has active, and whether each always-on service is
|
|
10
|
+
* enabled and running. Two workers built from the same revision have the same
|
|
11
|
+
* manifest, so its digest proves a rebuild reproduces a machine, and its
|
|
12
|
+
* difference from a fresh build shows what a long-lived worker gained.
|
|
13
|
+
*/
|
|
14
|
+
export interface WorkerManifest {
|
|
15
|
+
version: 1;
|
|
16
|
+
packages: Record<string, string>;
|
|
17
|
+
tools: Record<string, string>;
|
|
18
|
+
units: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The policy a worker's manifest is measured against. */
|
|
22
|
+
interface UbuntuPolicy {
|
|
23
|
+
packages: Record<string, string>;
|
|
24
|
+
releases: { release: string; packages?: Record<string, string> }[];
|
|
25
|
+
alwaysOnServices: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ubuntuPolicy(root = PACKAGE_ROOT): Promise<UbuntuPolicy> {
|
|
29
|
+
return JSON.parse(
|
|
30
|
+
await readFile(join(root, 'ansible/ubuntu/policy.json'), 'utf8')
|
|
31
|
+
) as UbuntuPolicy;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A read-only POSIX probe: it lists, and changes nothing. */
|
|
35
|
+
export function manifestProbe(services: string[]): string {
|
|
36
|
+
const units = services.map(shellQuote).join(' ');
|
|
37
|
+
return [
|
|
38
|
+
'export PATH="$HOME/.local/share/mise/shims:$HOME/.local/bin:$PATH"',
|
|
39
|
+
'echo @packages',
|
|
40
|
+
"dpkg-query -W -f='${binary:Package}=${Version}\\n' 2>/dev/null",
|
|
41
|
+
'echo @tools',
|
|
42
|
+
"mise ls --current --json 2>/dev/null | tr -d '\\n'; echo",
|
|
43
|
+
'echo @units',
|
|
44
|
+
`for unit in ${units}; do printf '%s=%s/%s\\n' "$unit" "$(systemctl is-enabled "$unit" 2>/dev/null)" "$(systemctl is-active "$unit" 2>/dev/null)"; done`,
|
|
45
|
+
'echo @release',
|
|
46
|
+
'(. /etc/os-release 2>/dev/null && echo "$VERSION_ID")',
|
|
47
|
+
].join('; ');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const sorted = (entries: [string, string][]): Record<string, string> =>
|
|
51
|
+
Object.fromEntries(entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
52
|
+
|
|
53
|
+
function pairs(lines: string[]): [string, string][] {
|
|
54
|
+
return lines
|
|
55
|
+
.map((line) => line.trim())
|
|
56
|
+
.filter((line) => line.includes('='))
|
|
57
|
+
.map((line) => {
|
|
58
|
+
const at = line.indexOf('=');
|
|
59
|
+
return [line.slice(0, at), line.slice(at + 1)] as [string, string];
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tools(line: string | undefined): Record<string, string> {
|
|
64
|
+
try {
|
|
65
|
+
const listed = JSON.parse(line ?? '{}') as Record<
|
|
66
|
+
string,
|
|
67
|
+
{ version?: string }[]
|
|
68
|
+
>;
|
|
69
|
+
return sorted(
|
|
70
|
+
Object.entries(listed).map(([tool, versions]) => [
|
|
71
|
+
tool,
|
|
72
|
+
versions
|
|
73
|
+
.map((entry) => entry.version ?? '')
|
|
74
|
+
.sort()
|
|
75
|
+
.join(','),
|
|
76
|
+
])
|
|
77
|
+
);
|
|
78
|
+
} catch {
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The manifest the probe printed. Order never matters: everything is sorted. */
|
|
84
|
+
export function parseManifest(stdout: string): WorkerManifest {
|
|
85
|
+
const sections: Record<string, string[]> = {};
|
|
86
|
+
let current = '';
|
|
87
|
+
for (const line of stdout.split('\n')) {
|
|
88
|
+
if (/^@[a-z]+$/.test(line.trim())) {
|
|
89
|
+
current = line.trim().slice(1);
|
|
90
|
+
sections[current] = [];
|
|
91
|
+
} else if (current) sections[current]!.push(line);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
version: 1,
|
|
95
|
+
packages: sorted(pairs(sections['packages'] ?? [])),
|
|
96
|
+
tools: tools(sections['tools']?.find((line) => line.trim())),
|
|
97
|
+
units: sorted(pairs(sections['units'] ?? [])),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The release the probe reported. It is kept apart from the manifest, so
|
|
102
|
+
* the manifest and its digest are what they were before releases differed. */
|
|
103
|
+
export function manifestRelease(stdout: string): string | null {
|
|
104
|
+
const lines = stdout.split('\n').map((line) => line.trim());
|
|
105
|
+
const at = lines.indexOf('@release');
|
|
106
|
+
return (at >= 0 && lines.slice(at + 1).find(Boolean)) || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The same manifest always has the same digest. */
|
|
110
|
+
export function manifestDigest(manifest: WorkerManifest): string {
|
|
111
|
+
return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ManifestDifference {
|
|
115
|
+
section: 'packages' | 'tools' | 'units';
|
|
116
|
+
name: string;
|
|
117
|
+
expected: string | null;
|
|
118
|
+
actual: string | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What `live` has that `reference` does not, lacks, or has otherwise. */
|
|
122
|
+
export function diffManifests(
|
|
123
|
+
reference: WorkerManifest,
|
|
124
|
+
live: WorkerManifest
|
|
125
|
+
): ManifestDifference[] {
|
|
126
|
+
const differences: ManifestDifference[] = [];
|
|
127
|
+
for (const section of ['packages', 'tools', 'units'] as const) {
|
|
128
|
+
const names = new Set([
|
|
129
|
+
...Object.keys(reference[section]),
|
|
130
|
+
...Object.keys(live[section]),
|
|
131
|
+
]);
|
|
132
|
+
for (const name of [...names].sort()) {
|
|
133
|
+
const expected = reference[section][name] ?? null;
|
|
134
|
+
const actual = live[section][name] ?? null;
|
|
135
|
+
if (expected !== actual)
|
|
136
|
+
differences.push({ section, name, expected, actual });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return differences;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Where a manifest departs from what the policy declares: a pinned package
|
|
143
|
+
* at another version or missing, an always-on service not enabled and active.
|
|
144
|
+
*/
|
|
145
|
+
export function declaredDifferences(
|
|
146
|
+
manifest: WorkerManifest,
|
|
147
|
+
policy: UbuntuPolicy,
|
|
148
|
+
release: string | null = null
|
|
149
|
+
): ManifestDifference[] {
|
|
150
|
+
const differences: ManifestDifference[] = [];
|
|
151
|
+
// The release's own pins over the common ones, as tasks/release.yml
|
|
152
|
+
// combines them.
|
|
153
|
+
const pins = {
|
|
154
|
+
...policy.packages,
|
|
155
|
+
...policy.releases.find((entry) => entry.release === release)?.packages,
|
|
156
|
+
};
|
|
157
|
+
for (const [name, version] of Object.entries(pins)) {
|
|
158
|
+
const actual = manifest.packages[name] ?? null;
|
|
159
|
+
if (actual !== version)
|
|
160
|
+
differences.push({
|
|
161
|
+
section: 'packages',
|
|
162
|
+
name,
|
|
163
|
+
expected: version,
|
|
164
|
+
actual,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
for (const unit of policy.alwaysOnServices) {
|
|
168
|
+
const actual = manifest.units[unit] ?? null;
|
|
169
|
+
if (actual !== 'enabled/active')
|
|
170
|
+
differences.push({
|
|
171
|
+
section: 'units',
|
|
172
|
+
name: unit,
|
|
173
|
+
expected: 'enabled/active',
|
|
174
|
+
actual,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return differences;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `worker:manifest`: the worker is what a fresh build of the same revision
|
|
181
|
+
* is (when a reference is given) and what the policy declares.
|
|
182
|
+
*/
|
|
183
|
+
export function manifestCheck(differences: ManifestDifference[]): Check {
|
|
184
|
+
if (!differences.length)
|
|
185
|
+
return {
|
|
186
|
+
id: 'worker:manifest',
|
|
187
|
+
status: 'pass',
|
|
188
|
+
summary: 'The worker matches the declared pins and the fresh build.',
|
|
189
|
+
};
|
|
190
|
+
const shown = differences
|
|
191
|
+
.slice(0, 20)
|
|
192
|
+
.map(
|
|
193
|
+
(difference) =>
|
|
194
|
+
`${difference.section}:${difference.name} ${difference.expected ?? 'absent'} -> ${difference.actual ?? 'absent'}`
|
|
195
|
+
);
|
|
196
|
+
return {
|
|
197
|
+
id: 'worker:manifest',
|
|
198
|
+
status: 'fail',
|
|
199
|
+
summary: `WORKER_MANIFEST_DRIFT: ${differences.length} difference(s): ${shown.join('; ')}`,
|
|
200
|
+
remedy: {
|
|
201
|
+
action:
|
|
202
|
+
'Something was installed, removed or changed outside fleet-sync. Rebuild the worker (fleet-node-replace) or declare the change in dev-fleet.',
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Measures one worker's manifest over the verified SSH connection. */
|
|
208
|
+
export async function measureManifest(
|
|
209
|
+
node: FleetNode,
|
|
210
|
+
run: Runner,
|
|
211
|
+
policy: UbuntuPolicy
|
|
212
|
+
): Promise<
|
|
213
|
+
{ manifest: WorkerManifest; release: string | null } | { check: Check }
|
|
214
|
+
> {
|
|
215
|
+
const result = await run(
|
|
216
|
+
[...sshArgs(node), manifestProbe(policy.alwaysOnServices)],
|
|
217
|
+
{ timeoutMs: 60_000 }
|
|
218
|
+
);
|
|
219
|
+
if (!result.ok) return { check: sshFailure(result) };
|
|
220
|
+
return {
|
|
221
|
+
manifest: parseManifest(result.stdout),
|
|
222
|
+
release: manifestRelease(result.stdout),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { TailnetComputer } from './tailscale';
|
|
2
|
+
import type { Check } from './types';
|
|
3
|
+
import { duration } from './worker-activity';
|
|
4
|
+
|
|
5
|
+
/** A parked worker is stopped or destroyed on purpose: it is listed with
|
|
6
|
+
* the time since it was parked, never contacted, never the canary, and it
|
|
7
|
+
* does not change the exit code. The fleet state says which ones are parked
|
|
8
|
+
* (src/state/workers.ts).
|
|
9
|
+
*/
|
|
10
|
+
export function parkedCheck(id: string, parkedAt: string, now: Date): Check {
|
|
11
|
+
const since = Math.max(0, (now.getTime() - Date.parse(parkedAt)) / 1000);
|
|
12
|
+
return {
|
|
13
|
+
id: 'worker:parked',
|
|
14
|
+
status: 'not-applicable',
|
|
15
|
+
summary: `${id} is parked since ${parkedAt} (${duration(since)}): it is not contacted. devfleet fleet-node-resume --id ${id} brings it back.`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What fleet-sync converges once parked workers are set aside: the fleet
|
|
20
|
+
* without them (so none is contacted, expected online or made the canary)
|
|
21
|
+
* and a not-applicable check for each, which never changes the exit code.
|
|
22
|
+
*/
|
|
23
|
+
export function withoutParked<
|
|
24
|
+
D extends { workers: { id: string }[] },
|
|
25
|
+
N extends { id: string },
|
|
26
|
+
>(
|
|
27
|
+
definition: D,
|
|
28
|
+
nodes: N[],
|
|
29
|
+
parked: Record<string, string>,
|
|
30
|
+
now: Date
|
|
31
|
+
): { definition: D; nodes: N[]; checks: Check[] } {
|
|
32
|
+
return {
|
|
33
|
+
definition: {
|
|
34
|
+
...definition,
|
|
35
|
+
workers: definition.workers.filter((worker) => !parked[worker.id]),
|
|
36
|
+
},
|
|
37
|
+
nodes: nodes.filter((node) => !parked[node.id]),
|
|
38
|
+
checks: definition.workers
|
|
39
|
+
.filter((worker) => parked[worker.id])
|
|
40
|
+
.map((worker) => parkedCheck(worker.id, parked[worker.id]!, now)),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A parked worker that is online on the tailnet anyway: it costs money
|
|
45
|
+
* while nobody uses it, or it was resumed by hand.
|
|
46
|
+
*/
|
|
47
|
+
export function parkedOnline(
|
|
48
|
+
id: string,
|
|
49
|
+
tailscaleName: string | undefined,
|
|
50
|
+
computers: TailnetComputer[]
|
|
51
|
+
): Check | null {
|
|
52
|
+
if (!tailscaleName) return null;
|
|
53
|
+
const name = tailscaleName.toLowerCase();
|
|
54
|
+
const online = computers.some(
|
|
55
|
+
(computer) =>
|
|
56
|
+
!computer.self &&
|
|
57
|
+
computer.online &&
|
|
58
|
+
computer.dns.toLowerCase().replace(/\.$/, '') === name
|
|
59
|
+
);
|
|
60
|
+
return online
|
|
61
|
+
? {
|
|
62
|
+
id: 'worker:parked-online',
|
|
63
|
+
status: 'warn',
|
|
64
|
+
summary: `${id} is parked, and online on the tailnet.`,
|
|
65
|
+
remedy: {
|
|
66
|
+
action: `Stop the machine if nobody uses it (it costs money while it runs), or resume it: devfleet fleet-node-resume --id ${id}.`,
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
: null;
|
|
70
|
+
}
|
package/src/worker-policy.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { CAPABILITIES, record } from './config';
|
|
|
2
2
|
import { FleetUsageError } from './errors';
|
|
3
3
|
import type { Capability } from './types';
|
|
4
4
|
import { TOOL_PROFILES, type ToolProfile } from '../bootstrap/worker-tools';
|
|
5
|
+
import { parseExposure, type ExposureDeclaration } from './exposure';
|
|
6
|
+
import { featuresFault } from '../bootstrap/worker-features';
|
|
7
|
+
import type { FeaturesDefinition } from './definition';
|
|
5
8
|
|
|
6
9
|
export interface WorkerPolicy {
|
|
7
10
|
requiredCapabilities: Capability[];
|
|
@@ -17,6 +20,10 @@ export interface WorkerPolicy {
|
|
|
17
20
|
toolProfiles?: ToolProfile[];
|
|
18
21
|
/** Single tools pinned in dev-fleet's mise.toml every worker installs. */
|
|
19
22
|
tools?: string[];
|
|
23
|
+
/** What every worker opens beyond loopback and the tailnet (src/exposure.ts). */
|
|
24
|
+
exposure?: ExposureDeclaration;
|
|
25
|
+
/** Features every worker has unless its own `features` say otherwise. */
|
|
26
|
+
features?: FeaturesDefinition;
|
|
20
27
|
}
|
|
21
28
|
const REQUIRED = [
|
|
22
29
|
'github',
|
|
@@ -34,33 +41,29 @@ function validCapabilities(value: unknown): boolean {
|
|
|
34
41
|
REQUIRED.every((item) => value.includes(item))
|
|
35
42
|
);
|
|
36
43
|
}
|
|
37
|
-
|
|
44
|
+
const FIELDS: Record<string, (value: unknown) => boolean> = {
|
|
45
|
+
requiredCapabilities: validCapabilities,
|
|
46
|
+
githubLogin: (value) =>
|
|
47
|
+
typeof value === 'string' && /^[a-zA-Z0-9-]{1,39}$/.test(value),
|
|
48
|
+
vaultUser: (value) =>
|
|
49
|
+
typeof value === 'string' && /^[a-zA-Z0-9_.-]{1,64}$/.test(value),
|
|
50
|
+
toolchain: (value) => value === 'mise.lock',
|
|
51
|
+
agentProfile: (value) => value === 'proxy',
|
|
52
|
+
unattendedSudo: (value) => value === true,
|
|
53
|
+
persistentT3: (value) => value === true,
|
|
54
|
+
neverSleep: (value) => value === true,
|
|
55
|
+
toolProfiles: (value) => value === undefined || toolProfiles(value) !== null,
|
|
56
|
+
tools: (value) => value === undefined || toolNames(value),
|
|
57
|
+
exposure: (value) => value === undefined || parseExposure(value) !== null,
|
|
58
|
+
features: (value) => value === undefined || featuresFault(value) === null,
|
|
59
|
+
};
|
|
60
|
+
/** The first field of a worker policy that breaks its rule, or null. */
|
|
61
|
+
export function policyFault(value: Record<string, unknown>): string | null {
|
|
62
|
+
const unknown = Object.keys(value).find((key) => !(key in FIELDS));
|
|
63
|
+
if (unknown !== undefined) return unknown;
|
|
38
64
|
return (
|
|
39
|
-
Object.
|
|
40
|
-
|
|
41
|
-
'requiredCapabilities',
|
|
42
|
-
'githubLogin',
|
|
43
|
-
'vaultUser',
|
|
44
|
-
'toolchain',
|
|
45
|
-
'agentProfile',
|
|
46
|
-
'unattendedSudo',
|
|
47
|
-
'persistentT3',
|
|
48
|
-
'neverSleep',
|
|
49
|
-
'toolProfiles',
|
|
50
|
-
'tools',
|
|
51
|
-
].includes(key)
|
|
52
|
-
) &&
|
|
53
|
-
validTools(value) &&
|
|
54
|
-
validCapabilities(value['requiredCapabilities']) &&
|
|
55
|
-
typeof value['githubLogin'] === 'string' &&
|
|
56
|
-
/^[a-zA-Z0-9-]{1,39}$/.test(value['githubLogin']) &&
|
|
57
|
-
typeof value['vaultUser'] === 'string' &&
|
|
58
|
-
/^[a-zA-Z0-9_.-]{1,64}$/.test(value['vaultUser']) &&
|
|
59
|
-
value['toolchain'] === 'mise.lock' &&
|
|
60
|
-
value['agentProfile'] === 'proxy' &&
|
|
61
|
-
['unattendedSudo', 'persistentT3', 'neverSleep'].every(
|
|
62
|
-
(key) => value[key] === true
|
|
63
|
-
)
|
|
65
|
+
Object.entries(FIELDS).find(([key, valid]) => !valid(value[key]))?.[0] ??
|
|
66
|
+
null
|
|
64
67
|
);
|
|
65
68
|
}
|
|
66
69
|
/** A list of known tool profiles, each once; null when it is not one. */
|
|
@@ -71,14 +74,6 @@ export function toolProfiles(value: unknown): ToolProfile[] | null {
|
|
|
71
74
|
? (value as ToolProfile[])
|
|
72
75
|
: null;
|
|
73
76
|
}
|
|
74
|
-
/** The optional tool profiles and single tools every worker adds. */
|
|
75
|
-
function validTools(value: Record<string, unknown>): boolean {
|
|
76
|
-
return (
|
|
77
|
-
(value['toolProfiles'] === undefined ||
|
|
78
|
-
toolProfiles(value['toolProfiles']) !== null) &&
|
|
79
|
-
(value['tools'] === undefined || toolNames(value['tools']))
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
77
|
function toolNames(value: unknown): boolean {
|
|
83
78
|
return (
|
|
84
79
|
Array.isArray(value) &&
|
|
@@ -89,9 +84,16 @@ function toolNames(value: unknown): boolean {
|
|
|
89
84
|
);
|
|
90
85
|
}
|
|
91
86
|
export function parseWorkerPolicy(value: unknown): WorkerPolicy {
|
|
92
|
-
if (!record(value) ||
|
|
87
|
+
if (!record(value) || policyFault(value) !== null)
|
|
93
88
|
throw new FleetUsageError(
|
|
94
89
|
'Invalid mandatory worker policy. Workers require the locked toolchain, common services, unattended sudo, persistent T3 and no sleep.'
|
|
95
90
|
);
|
|
96
|
-
|
|
91
|
+
const exposure =
|
|
92
|
+
value['exposure'] === undefined
|
|
93
|
+
? undefined
|
|
94
|
+
: parseExposure(value['exposure']);
|
|
95
|
+
return {
|
|
96
|
+
...(value as unknown as WorkerPolicy),
|
|
97
|
+
...(exposure ? { exposure } : {}),
|
|
98
|
+
};
|
|
97
99
|
}
|
package/src/worker-readiness.ts
CHANGED
|
@@ -6,16 +6,25 @@ import { workerSelection } from '../bootstrap/worker-tools';
|
|
|
6
6
|
import { browserCheck, dockerRunCheck } from './worker-capability-checks';
|
|
7
7
|
import { gitChecks } from './git-identity';
|
|
8
8
|
import type { Check, Runner } from './types';
|
|
9
|
-
import {
|
|
9
|
+
import { UBUNTU_WORKER } from './platform/ubuntu-worker';
|
|
10
10
|
import { workerAccessChecks } from './worker-access-checks';
|
|
11
11
|
import { record } from './config';
|
|
12
|
+
import { workerExposure } from './exposure';
|
|
13
|
+
import { exposureCheck } from './worker-exposure';
|
|
14
|
+
import { notApplicable, offReason, workerFeatures } from './worker-features';
|
|
12
15
|
|
|
13
16
|
export async function workerReadiness(
|
|
14
17
|
run: Runner,
|
|
15
18
|
nodeId = hostId(hostname()),
|
|
16
19
|
root = process.cwd()
|
|
17
20
|
): Promise<Check[]> {
|
|
18
|
-
const
|
|
21
|
+
const features = workerFeatures(fleet, nodeId);
|
|
22
|
+
const host = await UBUNTU_WORKER.hostChecks(
|
|
23
|
+
userInfo().username,
|
|
24
|
+
run,
|
|
25
|
+
features,
|
|
26
|
+
fleet.workers.find((entry) => entry.id === nodeId)?.powerRecoveryNote
|
|
27
|
+
);
|
|
19
28
|
const access = await workerAccessChecks(run, nodeId);
|
|
20
29
|
const revision = await run(['git', 'rev-parse', 'HEAD']);
|
|
21
30
|
const singleton = await run(
|
|
@@ -62,11 +71,14 @@ export async function workerReadiness(
|
|
|
62
71
|
...host,
|
|
63
72
|
...access,
|
|
64
73
|
proxy,
|
|
65
|
-
|
|
74
|
+
features.containers
|
|
75
|
+
? await dockerRunCheck(run)
|
|
76
|
+
: notApplicable('worker:docker-run', offReason(features, 'docker')),
|
|
66
77
|
await browserCheck(
|
|
67
78
|
run,
|
|
68
79
|
workerSelection(fleet, nodeId).profiles.includes('web')
|
|
69
80
|
),
|
|
81
|
+
await exposureCheck(workerExposure(fleet, nodeId), run),
|
|
70
82
|
...(await gitChecks(fleet.git, run)),
|
|
71
83
|
...(await workerCapacity(root, nodeId, run)),
|
|
72
84
|
];
|