@crvouga/dev-fleet 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -9
- package/agents/audit.md +18 -18
- package/ansible/agents/agents.yml +134 -0
- package/ansible/agents/templates/antigravity.settings.json.j2 +3 -0
- package/ansible/agents/templates/claude.settings.json.j2 +3 -0
- package/ansible/agents/templates/codex.config.toml.j2 +11 -0
- package/ansible/agents/templates/gemini.settings.json.j2 +14 -0
- package/ansible/agents/templates/grok.config.toml.j2 +19 -0
- package/ansible/agents/templates/opencode.json.j2 +24 -0
- package/ansible/agents/templates/receipt.json.j2 +10 -0
- package/ansible/ansible.cfg +14 -0
- package/ansible/github/git.yml +82 -0
- package/ansible/macos/manager-privilege.yml +55 -9
- package/ansible/macos/manager.yml +154 -7
- package/ansible/macos/policy.json +13 -0
- package/ansible/macos/t3-policy.yml +5 -3
- package/ansible/macos/vault-renew.yml +85 -0
- package/ansible/monitoring/agent-darwin.yml +3 -0
- package/ansible/monitoring/agent.yml +2 -0
- package/ansible/monitoring/hub-darwin.yml +3 -0
- package/ansible/monitoring/hub-route-darwin.yml +1 -0
- package/ansible/net/remote-probe.sh +4 -1
- package/ansible/platforms.json +2 -2
- package/ansible/pyproject.toml +5 -0
- package/ansible/ubuntu/baseline.yml +39 -13
- package/ansible/ubuntu/disk-gc.py +6 -2
- package/ansible/ubuntu/firewall.nft.j2 +50 -0
- package/ansible/ubuntu/policy.json +125 -6
- package/ansible/ubuntu/reboot.yml +89 -0
- package/ansible/ubuntu/recovery.yml +13 -3
- package/ansible/ubuntu/t3/policy.json +3 -0
- package/ansible/ubuntu/t3-desktop.yml +23 -2
- package/ansible/ubuntu/t3-headless.yml +128 -0
- package/ansible/ubuntu/t3-health.sh +24 -10
- package/ansible/ubuntu/t3-idle.py +37 -2
- package/ansible/ubuntu/t3-policy.yml +4 -1
- package/ansible/ubuntu/t3-route.yml +1 -0
- package/ansible/ubuntu/tailscale.yml +54 -8
- package/ansible/ubuntu/tasks/browser.yml +3 -0
- package/ansible/ubuntu/tasks/containers-off.yml +43 -0
- package/ansible/ubuntu/tasks/docker.yml +53 -6
- package/ansible/ubuntu/tasks/dpkg.yml +4 -0
- package/ansible/ubuntu/tasks/features.yml +22 -0
- package/ansible/ubuntu/tasks/firewall.yml +172 -0
- package/ansible/ubuntu/tasks/install.yml +2 -1
- package/ansible/ubuntu/tasks/packages.yml +4 -2
- package/ansible/ubuntu/tasks/release.yml +6 -0
- package/ansible/ubuntu/tasks/resources.yml +6 -4
- package/ansible/ubuntu/tasks/services.yml +2 -2
- package/ansible/ubuntu/tasks/sizing.yml +49 -0
- package/ansible/ubuntu/tasks/snapshot.yml +14 -3
- package/ansible/ubuntu/tasks/ssh.yml +61 -4
- package/ansible/ubuntu/tasks/t3-restart.yml +3 -1
- package/ansible/ubuntu/tasks/user.yml +1 -0
- package/ansible/ubuntu/worker-account.yml +117 -0
- package/ansible/uv.lock +265 -0
- package/bin/devfleet.mjs +42 -4
- package/bootstrap/fleet-remote-bootstrap.sh +91 -25
- package/bootstrap/global-config.ts +29 -3
- package/bootstrap/package-smoke.ts +127 -0
- package/bootstrap/pins.ts +189 -0
- package/bootstrap/release-notes.ts +88 -0
- package/bootstrap/release-version.ts +41 -7
- package/bootstrap/worker-features.ts +114 -0
- package/bootstrap/worker-seed.yaml +7 -3
- package/bootstrap/worker-tools.ts +39 -9
- package/cliproxy/README.md +1 -1
- package/cliproxy/docs/health-workflow.example.yml +20 -143
- package/docs/authentication.md +19 -11
- package/docs/consumer-setup.md +42 -27
- package/docs/convergence.md +67 -0
- package/docs/declared.md +61 -34
- package/docs/machine-setup.md +73 -7
- package/docs/manager-setup.md +10 -3
- package/docs/operations.md +64 -9
- package/docs/releasing.md +49 -1
- package/docs/runbooks.md +98 -7
- package/docs/state.md +23 -11
- package/docs/support-matrix.md +40 -4
- package/docs/topology.md +67 -6
- package/docs/ubuntu-sync.md +78 -28
- package/docs/worker-contract.md +122 -19
- package/docs/worker-requirements.md +107 -0
- package/examples/fleet.json +1 -0
- package/examples/workers-from-infrastructure/main.tf +132 -0
- package/examples/workers-from-infrastructure/worker-records.json +14 -0
- package/llms.txt +3 -1
- package/mise.lock +29 -0
- package/mise.toml +5 -3
- package/package.json +2 -2
- package/schema/check-ids.json +1967 -170
- package/schema/check-ids.schema.json +34 -0
- package/schema/fleet.schema.json +327 -8
- package/schema/output/check.schema.json +1 -1
- package/schema/output/fleet-state-definition.schema.json +14 -0
- package/schema/output/fleet-sync-schedule.schema.json +26 -0
- package/schema/output/fleet-sync.schema.json +18 -0
- package/schema/output/report.schema.json +3 -2
- package/schema/worker-record.schema.json +76 -0
- package/src/agents/converge.ts +140 -24
- package/src/agents/files.ts +2 -32
- package/src/agents/native-verify.ts +34 -3
- package/src/agents/secrets.ts +39 -4
- package/src/check-owners.ts +61 -2
- package/src/check-registry.ts +630 -0
- package/src/cli/agent-guide.ts +3 -3
- package/src/cli/cli.ts +4 -0
- package/src/cli/fleet-manifest.ts +9 -3
- package/src/cli/fleet-node-lifecycle.ts +313 -64
- package/src/cli/fleet-node-park.ts +238 -0
- package/src/cli/fleet-node-seed.ts +51 -35
- package/src/cli/fleet-state.ts +2 -1
- package/src/cli/fleet-sync-checkout.ts +7 -0
- package/src/cli/fleet-sync-enrollment.ts +193 -32
- package/src/cli/fleet-sync-forward.ts +4 -0
- package/src/cli/fleet-sync-manager.ts +103 -33
- package/src/cli/fleet-sync-output.ts +31 -11
- package/src/cli/fleet-sync-plan.ts +65 -9
- package/src/cli/fleet-sync-records.ts +113 -0
- package/src/cli/fleet-sync-remote.ts +151 -9
- package/src/cli/fleet-sync-schedule.ts +14 -4
- package/src/cli/fleet-sync-state.ts +28 -16
- package/src/cli/fleet-sync-vault.ts +77 -0
- package/src/cli/fleet-sync-worker.ts +214 -0
- package/src/cli/fleet-sync.ts +150 -19
- package/src/cli/fleet-validate.ts +59 -21
- package/src/cli/fleet-watch.ts +7 -1
- package/src/cli/fleet.ts +87 -5
- package/src/cli/managed-health.ts +25 -1
- package/src/cli/register-fleet.ts +115 -4
- package/src/cli/registry.ts +14 -0
- package/src/cli/tailnet.ts +43 -10
- package/src/cli/ui-data.ts +3 -1
- package/src/cli/worker-usage-report.ts +63 -0
- package/src/clock.ts +42 -0
- package/src/definition.ts +332 -9
- package/src/diagnostics.ts +5 -2
- package/src/exposure.ts +102 -0
- package/src/fleet-config.ts +3 -1
- package/src/git-identity.ts +76 -3
- package/src/ide/ide.ts +5 -0
- package/src/ide/t3.ts +12 -2
- package/src/input-hashes.ts +6 -0
- package/src/machine/machine-class.ts +59 -0
- package/src/machine/platform.ts +90 -11
- package/src/machine/state.ts +37 -15
- package/src/machine/support-matrix.ts +11 -9
- package/src/machine-health.ts +28 -5
- package/src/manager-health.ts +148 -2
- package/src/manager-sync.ts +65 -4
- package/src/monitoring/beszel.ts +8 -8
- package/src/monitoring/enroll.ts +8 -1
- package/src/monitoring/hub-access.ts +10 -4
- package/src/monitoring/systems.ts +138 -0
- package/src/network-definition.ts +13 -4
- package/src/node-converge-linux.ts +136 -27
- package/src/node-converge.ts +13 -3
- package/src/platform/ansible-receipt.ts +17 -2
- package/src/platform/ansible.ts +40 -4
- package/src/platform/manager-host.ts +25 -8
- package/src/platform/resource-verify.ts +18 -7
- package/src/platform/t3-policy.ts +24 -10
- package/src/platform/t3-service.ts +109 -34
- package/src/platform/ubuntu-release.ts +24 -0
- package/src/platform/ubuntu-verify.ts +31 -3
- package/src/platform/ubuntu-worker.ts +58 -0
- package/src/platform/worker-platform.ts +72 -0
- package/src/protocol.ts +1 -1
- package/src/public-reachability.ts +186 -0
- package/src/remote-bootstrap.ts +50 -12
- package/src/remote-operation.ts +18 -4
- package/src/repair-plan.ts +23 -12
- package/src/report.ts +2 -1
- package/src/schedule-check.ts +21 -2
- package/src/secrets.ts +4 -0
- package/src/service-auth.ts +55 -48
- package/src/ssh-connection.ts +141 -0
- package/src/ssh.ts +63 -24
- package/src/state/store.ts +4 -2
- package/src/state/sync.ts +35 -6
- package/src/state/workers.ts +140 -0
- package/src/t3/pairing.ts +59 -0
- package/src/t3/peer-link.ts +333 -0
- package/src/t3/version.ts +1 -1
- package/src/tailnet-cleanup.ts +185 -0
- package/src/tailnet-control.ts +346 -0
- package/src/tailscale.ts +1 -1
- package/src/toolchain-checks.ts +11 -1
- package/src/types.ts +4 -2
- package/src/worker-account.ts +63 -0
- package/src/worker-activity.ts +303 -0
- package/src/worker-capability-checks.ts +55 -16
- package/src/worker-capacity.ts +31 -0
- package/src/worker-exposure.ts +388 -0
- package/src/worker-features.ts +201 -0
- package/src/worker-host-checks.ts +71 -20
- package/src/worker-manifest.ts +27 -4
- package/src/worker-parking.ts +70 -0
- package/src/worker-policy.ts +17 -1
- package/src/worker-readiness.ts +15 -3
- package/src/worker-reboot.ts +129 -0
- package/src/worker-records.ts +305 -0
- package/src/worker-seed.ts +16 -11
- package/src/worker-sizing.ts +76 -0
- package/src/cli/schedule-logs.ts +0 -36
|
@@ -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
|
);
|
package/src/worker-manifest.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface WorkerManifest {
|
|
|
21
21
|
/** The policy a worker's manifest is measured against. */
|
|
22
22
|
interface UbuntuPolicy {
|
|
23
23
|
packages: Record<string, string>;
|
|
24
|
+
releases: { release: string; packages?: Record<string, string> }[];
|
|
24
25
|
alwaysOnServices: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -41,6 +42,8 @@ export function manifestProbe(services: string[]): string {
|
|
|
41
42
|
"mise ls --current --json 2>/dev/null | tr -d '\\n'; echo",
|
|
42
43
|
'echo @units',
|
|
43
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")',
|
|
44
47
|
].join('; ');
|
|
45
48
|
}
|
|
46
49
|
|
|
@@ -95,6 +98,14 @@ export function parseManifest(stdout: string): WorkerManifest {
|
|
|
95
98
|
};
|
|
96
99
|
}
|
|
97
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
|
+
|
|
98
109
|
/** The same manifest always has the same digest. */
|
|
99
110
|
export function manifestDigest(manifest: WorkerManifest): string {
|
|
100
111
|
return createHash('sha256').update(JSON.stringify(manifest)).digest('hex');
|
|
@@ -133,10 +144,17 @@ export function diffManifests(
|
|
|
133
144
|
*/
|
|
134
145
|
export function declaredDifferences(
|
|
135
146
|
manifest: WorkerManifest,
|
|
136
|
-
policy: UbuntuPolicy
|
|
147
|
+
policy: UbuntuPolicy,
|
|
148
|
+
release: string | null = null
|
|
137
149
|
): ManifestDifference[] {
|
|
138
150
|
const differences: ManifestDifference[] = [];
|
|
139
|
-
|
|
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)) {
|
|
140
158
|
const actual = manifest.packages[name] ?? null;
|
|
141
159
|
if (actual !== version)
|
|
142
160
|
differences.push({
|
|
@@ -191,11 +209,16 @@ export async function measureManifest(
|
|
|
191
209
|
node: FleetNode,
|
|
192
210
|
run: Runner,
|
|
193
211
|
policy: UbuntuPolicy
|
|
194
|
-
): Promise<
|
|
212
|
+
): Promise<
|
|
213
|
+
{ manifest: WorkerManifest; release: string | null } | { check: Check }
|
|
214
|
+
> {
|
|
195
215
|
const result = await run(
|
|
196
216
|
[...sshArgs(node), manifestProbe(policy.alwaysOnServices)],
|
|
197
217
|
{ timeoutMs: 60_000 }
|
|
198
218
|
);
|
|
199
219
|
if (!result.ok) return { check: sshFailure(result) };
|
|
200
|
-
return {
|
|
220
|
+
return {
|
|
221
|
+
manifest: parseManifest(result.stdout),
|
|
222
|
+
release: manifestRelease(result.stdout),
|
|
223
|
+
};
|
|
201
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',
|
|
@@ -47,6 +54,8 @@ const FIELDS: Record<string, (value: unknown) => boolean> = {
|
|
|
47
54
|
neverSleep: (value) => value === true,
|
|
48
55
|
toolProfiles: (value) => value === undefined || toolProfiles(value) !== null,
|
|
49
56
|
tools: (value) => value === undefined || toolNames(value),
|
|
57
|
+
exposure: (value) => value === undefined || parseExposure(value) !== null,
|
|
58
|
+
features: (value) => value === undefined || featuresFault(value) === null,
|
|
50
59
|
};
|
|
51
60
|
/** The first field of a worker policy that breaks its rule, or null. */
|
|
52
61
|
export function policyFault(value: Record<string, unknown>): string | null {
|
|
@@ -79,5 +88,12 @@ export function parseWorkerPolicy(value: unknown): WorkerPolicy {
|
|
|
79
88
|
throw new FleetUsageError(
|
|
80
89
|
'Invalid mandatory worker policy. Workers require the locked toolchain, common services, unattended sudo, persistent T3 and no sleep.'
|
|
81
90
|
);
|
|
82
|
-
|
|
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
|
+
};
|
|
83
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
|
];
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { playbookConnection } from './ssh';
|
|
2
|
+
import type { PackagePlaybook } from './platform/ansible';
|
|
3
|
+
import { blocking, type Check, type FleetNode } from './types';
|
|
4
|
+
|
|
5
|
+
/** Why fleet-sync reboots a worker: its first converge, which nothing can be
|
|
6
|
+
* running on yet, or a maintenance reboot the operator asked for
|
|
7
|
+
* (`--maintenance-reboot`) to apply installed updates.
|
|
8
|
+
*/
|
|
9
|
+
export type RebootReason = 'first-converge' | 'maintenance';
|
|
10
|
+
|
|
11
|
+
/** The boot-policy codes a reboot settles: the managed power files were
|
|
12
|
+
* written after this boot, or the running login manager has not loaded them.
|
|
13
|
+
*/
|
|
14
|
+
const SETTLED_BY_REBOOT = [
|
|
15
|
+
'UBUNTU_MAINTENANCE_REBOOT_PENDING',
|
|
16
|
+
'UBUNTU_SESSION_POLICY_PENDING',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** The converge ran (its bootstrap passed) and says a reboot is needed. */
|
|
20
|
+
export function rebootNeeded(checks: Check[]): boolean {
|
|
21
|
+
if (
|
|
22
|
+
checks.some((check) => check.id.startsWith('bootstrap:') && blocking(check))
|
|
23
|
+
)
|
|
24
|
+
return false;
|
|
25
|
+
return checks.some(
|
|
26
|
+
(check) =>
|
|
27
|
+
(check.id === 'ubuntu:boot-policy' &&
|
|
28
|
+
SETTLED_BY_REBOOT.includes(check.summary)) ||
|
|
29
|
+
(check.id === 'worker:reboot-pending' && check.status !== 'pass')
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A maintenance run goes on to the next worker only once this one is back
|
|
34
|
+
* and ready: a reboot that failed, or one after which the worker is not
|
|
35
|
+
* ready, stops the rest.
|
|
36
|
+
*/
|
|
37
|
+
export function rebootStopsRollout(node: { checks: Check[] }): boolean {
|
|
38
|
+
const reboot = node.checks.find((check) => check.id === 'worker:reboot');
|
|
39
|
+
if (!reboot) return false;
|
|
40
|
+
if (reboot.summary === 'WORKER_REBOOT_FAILED') return true;
|
|
41
|
+
return reboot.status === 'pass' && node.checks.some(blocking);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Installed updates wait for a reboot: what --maintenance-reboot applies. */
|
|
45
|
+
export function updatesPending(checks: Check[]): boolean {
|
|
46
|
+
return checks.some(
|
|
47
|
+
(check) => check.id === 'worker:reboot-pending' && check.status !== 'pass'
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The home of the account a roster entry converges, from its workspace. */
|
|
52
|
+
export function workerHome(node: FleetNode): string | null {
|
|
53
|
+
const suffix = '/.local/share/personal/fleet';
|
|
54
|
+
return node.workspace.endsWith(suffix)
|
|
55
|
+
? node.workspace.slice(0, -suffix.length) || null
|
|
56
|
+
: null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reboots one worker through ansible/ubuntu/reboot.yml, run from the
|
|
60
|
+
* manager: only while its T3 runs no agent turn (unless `force`), waiting
|
|
61
|
+
* until it answers again. The play's output is read for its own codes only.
|
|
62
|
+
*/
|
|
63
|
+
export async function rebootWorker(
|
|
64
|
+
node: FleetNode,
|
|
65
|
+
request: { revision: string; reason: RebootReason; force?: boolean },
|
|
66
|
+
playbook: PackagePlaybook
|
|
67
|
+
): Promise<Check> {
|
|
68
|
+
const home = workerHome(node);
|
|
69
|
+
if (!home)
|
|
70
|
+
return {
|
|
71
|
+
id: 'worker:reboot',
|
|
72
|
+
status: 'fail',
|
|
73
|
+
summary: 'WORKER_REBOOT_FAILED',
|
|
74
|
+
remedy: {
|
|
75
|
+
action:
|
|
76
|
+
'The roster names no workspace under the account home, so the worker was not rebooted. Rerun fleet-sync once it is enrolled.',
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
// `user@alias` only for a worker whose account this run created.
|
|
80
|
+
// A worker reached from declared data logs in as its declared account.
|
|
81
|
+
const connection = playbookConnection(node);
|
|
82
|
+
const [login, target] = node.ssh.includes('@')
|
|
83
|
+
? node.ssh.split('@', 2)
|
|
84
|
+
: [connection.user ?? '', node.ssh];
|
|
85
|
+
const result = await playbook(
|
|
86
|
+
'ubuntu/reboot.yml',
|
|
87
|
+
{
|
|
88
|
+
target,
|
|
89
|
+
login,
|
|
90
|
+
home,
|
|
91
|
+
revision: request.revision,
|
|
92
|
+
reason: request.reason,
|
|
93
|
+
force: Boolean(request.force),
|
|
94
|
+
ssh_options: connection.options,
|
|
95
|
+
},
|
|
96
|
+
// The play waits up to 900 seconds for the worker to answer again.
|
|
97
|
+
1_200_000
|
|
98
|
+
);
|
|
99
|
+
if (result.ok)
|
|
100
|
+
return {
|
|
101
|
+
id: 'worker:reboot',
|
|
102
|
+
status: 'pass',
|
|
103
|
+
summary:
|
|
104
|
+
request.reason === 'first-converge'
|
|
105
|
+
? 'Rebooted once after its first converge, which no agent turn ran on; it answered again and was converged once more.'
|
|
106
|
+
: 'Rebooted for maintenance while no agent turn ran; it answered again and was converged once more.',
|
|
107
|
+
};
|
|
108
|
+
if (result.stdout.includes('WORKER_REBOOT_BUSY'))
|
|
109
|
+
return {
|
|
110
|
+
id: 'worker:reboot',
|
|
111
|
+
// A busy worker is left alone, and the others still go: its boot
|
|
112
|
+
// policy or its pending updates still say a reboot is needed.
|
|
113
|
+
status: 'warn',
|
|
114
|
+
summary: 'WORKER_REBOOT_BUSY',
|
|
115
|
+
remedy: {
|
|
116
|
+
action:
|
|
117
|
+
'An agent turn is running on the worker, so it was not rebooted. Rerun fleet-sync --maintenance-reboot once the turn has ended, or add --force to end it.',
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
id: 'worker:reboot',
|
|
122
|
+
status: 'fail',
|
|
123
|
+
summary: 'WORKER_REBOOT_FAILED',
|
|
124
|
+
remedy: {
|
|
125
|
+
action:
|
|
126
|
+
'The reboot did not complete, or the worker did not answer within 15 minutes. Check its console with the provider, then rerun fleet-sync.',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|