@crvouga/dev-fleet 0.3.0 → 0.4.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 +22 -16
- package/ansible/github/git.yml +66 -0
- package/ansible/macos/policy.json +11 -1
- package/ansible/monitoring/compose.yml +10 -0
- package/ansible/monitoring/container-watchdog.py +118 -0
- package/ansible/monitoring/hub-darwin.yml +146 -0
- package/ansible/net/remote-probe.sh +5 -0
- package/ansible/ubuntu/baseline.yml +2 -0
- package/ansible/ubuntu/disk-gc.py +25 -3
- package/ansible/ubuntu/disk-guard.py +24 -5
- package/ansible/ubuntu/policy.json +50 -1
- package/ansible/ubuntu/tasks/browser.yml +56 -0
- package/ansible/ubuntu/tasks/docker.yml +52 -0
- package/ansible/ubuntu/tasks/resources.yml +2 -2
- package/ansible/ubuntu/tasks/services.yml +13 -0
- package/bootstrap/doctor.ts +32 -6
- package/bootstrap/fleet-remote-bootstrap.sh +31 -4
- package/bootstrap/setup.ts +23 -8
- package/bootstrap/worker-tools.ts +118 -0
- package/cliproxy/README.md +1 -1
- package/cliproxy/compose.yaml +4 -0
- package/cliproxy/scripts/checkpoint.py +1 -1
- package/docs/authentication.md +90 -0
- package/docs/declared.md +2 -0
- package/docs/machine-setup.md +22 -12
- package/docs/operations.md +87 -31
- package/docs/runbooks.md +380 -0
- package/docs/state.md +13 -7
- package/docs/ubuntu-sync.md +7 -6
- package/examples/fleet.json +19 -1
- package/llms.txt +2 -0
- package/mise.toml +7 -0
- package/package.json +2 -2
- package/schema/check-ids.json +522 -0
- package/schema/fleet.schema.json +178 -1
- package/schema/output/check.schema.json +23 -0
- package/schema/output/error.schema.json +14 -0
- package/schema/output/fleet-report.schema.json +79 -0
- package/schema/output/fleet-sync.schema.json +112 -0
- package/schema/output/report.schema.json +38 -0
- package/src/auth-checks.ts +3 -0
- package/src/cli/agent-guide.ts +5 -3
- package/src/cli/fleet-report.ts +126 -0
- package/src/cli/fleet-sync-manager.ts +11 -0
- package/src/cli/fleet-sync-plan.ts +200 -0
- package/src/cli/fleet-sync-remote.ts +3 -1
- package/src/cli/fleet-sync-schedule.ts +142 -35
- package/src/cli/fleet-sync.ts +90 -8
- package/src/cli/fleet-watch.ts +198 -0
- package/src/cli/fleet.ts +4 -0
- package/src/cli/register-fleet.ts +51 -0
- package/src/cli/registry.ts +13 -0
- package/src/cli/schedule-logs.ts +36 -0
- package/src/clock.ts +63 -0
- package/src/definition.ts +142 -5
- package/src/git-identity.ts +160 -0
- package/src/machine-health.ts +51 -4
- package/src/manager-health.ts +205 -0
- package/src/manager-sync.ts +12 -2
- package/src/monitoring/hub-backup.ts +63 -17
- package/src/monitoring/manager-hub.ts +44 -2
- package/src/node-converge-linux.ts +16 -5
- package/src/notify.ts +239 -0
- package/src/remote-bootstrap.ts +87 -25
- package/src/remote-operation.ts +213 -0
- package/src/schedule-check.ts +40 -0
- package/src/secrets.ts +26 -8
- package/src/ssh.ts +10 -2
- package/src/state/history.ts +172 -0
- package/src/state/store.ts +11 -4
- package/src/tailscale.ts +51 -0
- package/src/toolchain-checks.ts +8 -3
- package/src/worker-capability-checks.ts +167 -0
- package/src/worker-capacity.ts +19 -7
- package/src/worker-host-checks.ts +32 -0
- package/src/worker-policy.ts +33 -0
- package/src/worker-readiness.ts +10 -1
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
2
|
+
import type { Check, CommandResult, Runner } from './types';
|
|
3
|
+
|
|
4
|
+
/** What an agent does on a worker, tried the way it does it: start a
|
|
5
|
+
* container, build an image, launch a headless browser. Each check runs as
|
|
6
|
+
* the worker's user, in its session, and reports a code and a remedy, never
|
|
7
|
+
* what a command printed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function failure(id: string, summary: string, action: string): Check {
|
|
11
|
+
return { id, status: 'fail', summary, remedy: { action } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Docker's own words for the causes an agent meets most. */
|
|
15
|
+
function dockerFailure(result: CommandResult): {
|
|
16
|
+
code: string;
|
|
17
|
+
action: string;
|
|
18
|
+
} {
|
|
19
|
+
const said = `${result.stdout}\n${result.stderr}`;
|
|
20
|
+
if (result.reason === 'timeout')
|
|
21
|
+
return {
|
|
22
|
+
code: 'DOCKER_RUN_TIMEOUT',
|
|
23
|
+
action:
|
|
24
|
+
'A container did not start and exit in time. Inspect the engine (systemctl status docker) and its disk; rerun fleet-sync.',
|
|
25
|
+
};
|
|
26
|
+
if (/permission denied[^\n]*docker\.sock/i.test(said))
|
|
27
|
+
return {
|
|
28
|
+
code: 'DOCKER_SOCKET_ACCESS_DENIED',
|
|
29
|
+
action:
|
|
30
|
+
"This session cannot use the Docker socket. fleet-sync adds the worker's user to the docker group; a session started before that keeps its old groups. Rerun fleet-sync, then restart the session (log out and in, or restart the T3 service).",
|
|
31
|
+
};
|
|
32
|
+
if (/cannot connect to the docker daemon/i.test(said))
|
|
33
|
+
return {
|
|
34
|
+
code: 'DOCKER_DAEMON_UNREACHABLE',
|
|
35
|
+
action:
|
|
36
|
+
'The Docker engine is not running. Rerun fleet-sync, which starts and enables docker.service.',
|
|
37
|
+
};
|
|
38
|
+
if (/no such image|pull access denied|not found/i.test(said))
|
|
39
|
+
return {
|
|
40
|
+
code: 'DOCKER_SMOKE_IMAGE_MISSING',
|
|
41
|
+
action:
|
|
42
|
+
'The pinned smoke-test image is not cached on this worker. Rerun fleet-sync, which pulls it once so this check works offline.',
|
|
43
|
+
};
|
|
44
|
+
if (/no space left on device/i.test(said))
|
|
45
|
+
return {
|
|
46
|
+
code: 'DOCKER_DISK_FULL',
|
|
47
|
+
action:
|
|
48
|
+
'The image store is full. Free space under /var/lib/docker (the disk GC prunes old images) and rerun fleet-sync.',
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
code: 'DOCKER_RUN_FAILED',
|
|
52
|
+
action:
|
|
53
|
+
'Docker answers but cannot run a container here. Inspect cgroups and the engine log (journalctl -u docker), then rerun fleet-sync.',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Runs the pinned image offline, then builds a one-line image from it. */
|
|
58
|
+
export async function dockerRunCheck(run: Runner): Promise<Check> {
|
|
59
|
+
const id = 'worker:docker-run';
|
|
60
|
+
const { image, timeoutSeconds } = policy.dockerSmokeTest;
|
|
61
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
62
|
+
const started = await run(
|
|
63
|
+
[
|
|
64
|
+
'docker',
|
|
65
|
+
'run',
|
|
66
|
+
'--rm',
|
|
67
|
+
'--network',
|
|
68
|
+
'none',
|
|
69
|
+
'--pull',
|
|
70
|
+
'never',
|
|
71
|
+
image,
|
|
72
|
+
'true',
|
|
73
|
+
],
|
|
74
|
+
{ timeoutMs }
|
|
75
|
+
);
|
|
76
|
+
if (!started.ok) {
|
|
77
|
+
const { code, action } = dockerFailure(started);
|
|
78
|
+
return failure(id, code, action);
|
|
79
|
+
}
|
|
80
|
+
const built = await run(
|
|
81
|
+
['docker', 'build', '--quiet', '--pull=false', '--network', 'none', '-'],
|
|
82
|
+
{ input: `FROM ${image}\nRUN true\n`, timeoutMs }
|
|
83
|
+
);
|
|
84
|
+
if (!built.ok) {
|
|
85
|
+
const { code, action } = dockerFailure(built);
|
|
86
|
+
return failure(
|
|
87
|
+
id,
|
|
88
|
+
code === 'DOCKER_RUN_FAILED' ? 'DOCKER_BUILD_FAILED' : code,
|
|
89
|
+
code === 'DOCKER_RUN_FAILED'
|
|
90
|
+
? 'A container runs but an image does not build. Inspect Buildx (docker buildx ls) and the image store, then rerun fleet-sync.'
|
|
91
|
+
: action
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
status: 'pass',
|
|
97
|
+
summary:
|
|
98
|
+
'A container of the pinned image ran and a one-line image built, offline, in this session.',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Where `ansible/ubuntu/tasks/browser.yml` unpacks the pinned build. */
|
|
103
|
+
export function headlessShell(arch: string = process.arch): string {
|
|
104
|
+
const { version, amd64, arm64 } = policy.browser.headlessShell;
|
|
105
|
+
const build = arch === 'arm64' ? arm64 : amd64;
|
|
106
|
+
const directory = build.archive.split('/').pop()!;
|
|
107
|
+
return `/opt/personal-fleet/browser/${version}/${directory}/chrome-headless-shell`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** A worker with the web profile launches the pinned headless Chromium,
|
|
111
|
+
* loads a blank page and exits, as a browser test would.
|
|
112
|
+
*/
|
|
113
|
+
export async function browserCheck(
|
|
114
|
+
run: Runner,
|
|
115
|
+
web: boolean,
|
|
116
|
+
arch: string = process.arch
|
|
117
|
+
): Promise<Check> {
|
|
118
|
+
const id = 'worker:browser';
|
|
119
|
+
if (!web)
|
|
120
|
+
return {
|
|
121
|
+
id,
|
|
122
|
+
status: 'skip',
|
|
123
|
+
summary:
|
|
124
|
+
'This worker does not select the web tool profile, so no browser is installed for tests.',
|
|
125
|
+
};
|
|
126
|
+
const launched = await run(
|
|
127
|
+
[
|
|
128
|
+
headlessShell(arch),
|
|
129
|
+
'--headless',
|
|
130
|
+
'--no-sandbox',
|
|
131
|
+
'--disable-gpu',
|
|
132
|
+
'--dump-dom',
|
|
133
|
+
'about:blank',
|
|
134
|
+
],
|
|
135
|
+
{ timeoutMs: policy.browser.launchTimeoutSeconds * 1000 }
|
|
136
|
+
);
|
|
137
|
+
if (launched.ok && /<html/i.test(launched.stdout))
|
|
138
|
+
return {
|
|
139
|
+
id,
|
|
140
|
+
status: 'pass',
|
|
141
|
+
summary: `Headless Chromium ${policy.browser.headlessShell.version} launched, loaded a blank page and exited.`,
|
|
142
|
+
};
|
|
143
|
+
const said = `${launched.stdout}\n${launched.stderr}`;
|
|
144
|
+
if (launched.reason === 'missing')
|
|
145
|
+
return failure(
|
|
146
|
+
id,
|
|
147
|
+
'BROWSER_NOT_INSTALLED',
|
|
148
|
+
'The pinned headless Chromium is not installed. Rerun fleet-sync, which installs it on workers with the web profile.'
|
|
149
|
+
);
|
|
150
|
+
if (/error while loading shared libraries/i.test(said))
|
|
151
|
+
return failure(
|
|
152
|
+
id,
|
|
153
|
+
'BROWSER_LIBRARY_MISSING',
|
|
154
|
+
'A system library headless Chromium loads is missing. Rerun fleet-sync, which installs the pinned libraries of the web profile.'
|
|
155
|
+
);
|
|
156
|
+
if (launched.reason === 'timeout')
|
|
157
|
+
return failure(
|
|
158
|
+
id,
|
|
159
|
+
'BROWSER_LAUNCH_TIMEOUT',
|
|
160
|
+
'Headless Chromium did not exit in time. Check the worker for memory pressure, then rerun fleet-sync.'
|
|
161
|
+
);
|
|
162
|
+
return failure(
|
|
163
|
+
id,
|
|
164
|
+
'BROWSER_LAUNCH_FAILED',
|
|
165
|
+
'Headless Chromium did not load a blank page. Run it by hand with --dump-dom about:blank to see why, then rerun fleet-sync.'
|
|
166
|
+
);
|
|
167
|
+
}
|
package/src/worker-capacity.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
|
+
import { clockCheck } from './clock';
|
|
2
3
|
import { healthChecks, parseProbe } from './machine-health';
|
|
3
4
|
import type { Check, Runner } from './types';
|
|
4
5
|
|
|
5
6
|
export async function workerCapacity(
|
|
6
7
|
root: string,
|
|
7
8
|
node: string,
|
|
8
|
-
run: Runner
|
|
9
|
+
run: Runner,
|
|
10
|
+
/** The manager's clock in seconds since the epoch, when it sent one. */
|
|
11
|
+
reference?: number
|
|
9
12
|
): Promise<Check[]> {
|
|
10
13
|
const result = await run(['sh', join(root, 'ansible/net/remote-probe.sh')], {
|
|
11
14
|
timeoutMs: 10_000,
|
|
@@ -25,10 +28,19 @@ export async function workerCapacity(
|
|
|
25
28
|
summary: 'Required CPU, memory and disk evidence is unavailable.',
|
|
26
29
|
},
|
|
27
30
|
];
|
|
28
|
-
return
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
return [
|
|
32
|
+
...healthChecks(sample, node).map((check) => ({
|
|
33
|
+
id: `worker:capacity:${check.id}`,
|
|
34
|
+
status: check.severity,
|
|
35
|
+
summary: check.detail,
|
|
36
|
+
...(check.fix ? { remedy: { action: check.fix } } : {}),
|
|
37
|
+
})),
|
|
38
|
+
clockCheck(
|
|
39
|
+
'worker:clock',
|
|
40
|
+
sample,
|
|
41
|
+
reference === undefined || sample.epoch === undefined
|
|
42
|
+
? undefined
|
|
43
|
+
: sample.epoch - reference
|
|
44
|
+
),
|
|
45
|
+
];
|
|
34
46
|
}
|
|
@@ -95,6 +95,7 @@ export async function workerHostChecks(
|
|
|
95
95
|
)
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
|
+
checks.push(await rebootPendingCheck(audit.rebootPending, run));
|
|
98
99
|
checks.push(...(await recoveryChecks(run)));
|
|
99
100
|
checks.push(...(await verifyUbuntuBoot(user, run)));
|
|
100
101
|
checks.push(...(await verifyResourceSafety(userInfo().homedir, run)));
|
|
@@ -202,3 +203,34 @@ async function t3HealthCheck(run: Runner): Promise<Check> {
|
|
|
202
203
|
'T3 local API answers with its environment identity within 10 seconds.'
|
|
203
204
|
);
|
|
204
205
|
}
|
|
206
|
+
|
|
207
|
+
const DAY_SECONDS = 86_400;
|
|
208
|
+
|
|
209
|
+
/** Kernel and library updates apply only after a reboot. A pending reboot
|
|
210
|
+
* warns with its age, and fails once it is older than `policy.json` allows.
|
|
211
|
+
*/
|
|
212
|
+
export async function rebootPendingCheck(
|
|
213
|
+
pending: boolean,
|
|
214
|
+
run: Runner,
|
|
215
|
+
now = Date.now()
|
|
216
|
+
): Promise<Check> {
|
|
217
|
+
const id = 'worker:reboot-pending';
|
|
218
|
+
if (!pending) return { id, status: 'pass', summary: 'No reboot is pending.' };
|
|
219
|
+
const since = await run(['stat', '-c', '%Y', '/var/run/reboot-required']);
|
|
220
|
+
const seconds = Number(since.stdout.trim());
|
|
221
|
+
const days =
|
|
222
|
+
since.ok && /^\d+$/.test(since.stdout.trim())
|
|
223
|
+
? Math.max(0, Math.floor((now / 1000 - seconds) / DAY_SECONDS))
|
|
224
|
+
: null;
|
|
225
|
+
const age = days === null ? '' : ` for ${days} days`;
|
|
226
|
+
return {
|
|
227
|
+
id,
|
|
228
|
+
status:
|
|
229
|
+
days !== null && days > policy.rebootPendingFailDays ? 'fail' : 'warn',
|
|
230
|
+
summary: `REBOOT_PENDING${age}: installed updates apply only after a reboot.`,
|
|
231
|
+
remedy: {
|
|
232
|
+
action:
|
|
233
|
+
'Reboot the worker when no agent turn is running (sudo systemctl reboot on the worker), then confirm it returns to ready with devfleet fleet-status --workers.',
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
package/src/worker-policy.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CAPABILITIES, record } from './config';
|
|
2
2
|
import { FleetUsageError } from './errors';
|
|
3
3
|
import type { Capability } from './types';
|
|
4
|
+
import { TOOL_PROFILES, type ToolProfile } from '../bootstrap/worker-tools';
|
|
4
5
|
|
|
5
6
|
export interface WorkerPolicy {
|
|
6
7
|
requiredCapabilities: Capability[];
|
|
@@ -12,6 +13,10 @@ export interface WorkerPolicy {
|
|
|
12
13
|
unattendedSudo: true;
|
|
13
14
|
persistentT3: true;
|
|
14
15
|
neverSleep: true;
|
|
16
|
+
/** Tool profiles every worker installs on top of the base tools. */
|
|
17
|
+
toolProfiles?: ToolProfile[];
|
|
18
|
+
/** Single tools pinned in dev-fleet's mise.toml every worker installs. */
|
|
19
|
+
tools?: string[];
|
|
15
20
|
}
|
|
16
21
|
const REQUIRED = [
|
|
17
22
|
'github',
|
|
@@ -41,8 +46,11 @@ function validPolicy(value: Record<string, unknown>): boolean {
|
|
|
41
46
|
'unattendedSudo',
|
|
42
47
|
'persistentT3',
|
|
43
48
|
'neverSleep',
|
|
49
|
+
'toolProfiles',
|
|
50
|
+
'tools',
|
|
44
51
|
].includes(key)
|
|
45
52
|
) &&
|
|
53
|
+
validTools(value) &&
|
|
46
54
|
validCapabilities(value['requiredCapabilities']) &&
|
|
47
55
|
typeof value['githubLogin'] === 'string' &&
|
|
48
56
|
/^[a-zA-Z0-9-]{1,39}$/.test(value['githubLogin']) &&
|
|
@@ -55,6 +63,31 @@ function validPolicy(value: Record<string, unknown>): boolean {
|
|
|
55
63
|
)
|
|
56
64
|
);
|
|
57
65
|
}
|
|
66
|
+
/** A list of known tool profiles, each once; null when it is not one. */
|
|
67
|
+
export function toolProfiles(value: unknown): ToolProfile[] | null {
|
|
68
|
+
return Array.isArray(value) &&
|
|
69
|
+
new Set(value).size === value.length &&
|
|
70
|
+
value.every((item) => TOOL_PROFILES.includes(item as ToolProfile))
|
|
71
|
+
? (value as ToolProfile[])
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
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
|
+
function toolNames(value: unknown): boolean {
|
|
83
|
+
return (
|
|
84
|
+
Array.isArray(value) &&
|
|
85
|
+
new Set(value).size === value.length &&
|
|
86
|
+
value.every(
|
|
87
|
+
(item) => typeof item === 'string' && /^[a-z0-9@:/._-]{1,75}$/i.test(item)
|
|
88
|
+
)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
58
91
|
export function parseWorkerPolicy(value: unknown): WorkerPolicy {
|
|
59
92
|
if (!record(value) || !validPolicy(value))
|
|
60
93
|
throw new FleetUsageError(
|
package/src/worker-readiness.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { workerCapacity } from './worker-capacity';
|
|
2
2
|
import { userInfo, hostname } from 'node:os';
|
|
3
3
|
import { hostId } from './topology';
|
|
4
|
-
import { configPath } from './fleet-config';
|
|
4
|
+
import { configPath, fleet } from './fleet-config';
|
|
5
|
+
import { workerSelection } from '../bootstrap/worker-tools';
|
|
6
|
+
import { browserCheck, dockerRunCheck } from './worker-capability-checks';
|
|
7
|
+
import { gitChecks } from './git-identity';
|
|
5
8
|
import type { Check, Runner } from './types';
|
|
6
9
|
import { workerHostChecks } from './worker-host-checks';
|
|
7
10
|
import { workerAccessChecks } from './worker-access-checks';
|
|
@@ -59,6 +62,12 @@ export async function workerReadiness(
|
|
|
59
62
|
...host,
|
|
60
63
|
...access,
|
|
61
64
|
proxy,
|
|
65
|
+
await dockerRunCheck(run),
|
|
66
|
+
await browserCheck(
|
|
67
|
+
run,
|
|
68
|
+
workerSelection(fleet, nodeId).profiles.includes('web')
|
|
69
|
+
),
|
|
70
|
+
...(await gitChecks(fleet.git, run)),
|
|
62
71
|
...(await workerCapacity(root, nodeId, run)),
|
|
63
72
|
];
|
|
64
73
|
}
|