@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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { record } from './config';
|
|
2
|
+
import type { FleetDefinition, MachineDefinition } from './definition';
|
|
3
|
+
import { sshArgs } from './ssh';
|
|
4
|
+
import type { Check, FleetNode, Runner } from './types';
|
|
5
|
+
|
|
6
|
+
/** How long a worker has been up and what its agents are doing, as T3
|
|
7
|
+
* records it: the number of agent turns under way and when the last one was
|
|
8
|
+
* requested or ended. Never the content of a session.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkerActivity {
|
|
11
|
+
uptimeSeconds: number | null;
|
|
12
|
+
runningSessions: number | null;
|
|
13
|
+
lastActivityAt: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One worker in fleet-status: active or parked, what it does, and what it
|
|
17
|
+
* costs while it runs (only what fleet.json declares).
|
|
18
|
+
*/
|
|
19
|
+
export interface WorkerUsage extends Partial<WorkerActivity> {
|
|
20
|
+
id: string;
|
|
21
|
+
state: 'active' | 'parked';
|
|
22
|
+
parkedAt?: string;
|
|
23
|
+
reachable?: boolean;
|
|
24
|
+
/** Seconds since the last agent activity, or since boot without any. */
|
|
25
|
+
idleSeconds?: number;
|
|
26
|
+
idle?: boolean;
|
|
27
|
+
hourlyRate?: number;
|
|
28
|
+
currency?: string;
|
|
29
|
+
billingNote?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The installed copy fleet-sync keeps outside every revision
|
|
33
|
+
* (ansible/ubuntu/tasks/recovery.yml).
|
|
34
|
+
*/
|
|
35
|
+
export const ACTIVITY_PROBE =
|
|
36
|
+
'cut -d" " -f1 /proc/uptime; python3 -I -B /usr/local/lib/personal-fleet/t3-idle.py --state "${T3CODE_HOME:-$HOME/.t3}/userdata/statev2.sqlite" --activity';
|
|
37
|
+
|
|
38
|
+
/** Reads a worker's uptime and agent activity over SSH; null when the
|
|
39
|
+
* worker did not answer.
|
|
40
|
+
*/
|
|
41
|
+
export async function workerActivity(
|
|
42
|
+
node: FleetNode,
|
|
43
|
+
run: Runner
|
|
44
|
+
): Promise<WorkerActivity | null> {
|
|
45
|
+
const result = await run([...sshArgs(node), ACTIVITY_PROBE], {
|
|
46
|
+
timeoutMs: 20_000,
|
|
47
|
+
});
|
|
48
|
+
if (!result.ok && !result.stdout) return null;
|
|
49
|
+
return parseActivity(result.stdout);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The JSON line of t3-idle.py --activity, or nothing known. */
|
|
53
|
+
function activityLine(line: string | undefined): Record<string, unknown> {
|
|
54
|
+
let answer: unknown = null;
|
|
55
|
+
try {
|
|
56
|
+
answer = JSON.parse(line ?? '');
|
|
57
|
+
} catch {
|
|
58
|
+
answer = null;
|
|
59
|
+
}
|
|
60
|
+
return record(answer) && answer['version'] === 1 && !answer['unknown']
|
|
61
|
+
? answer
|
|
62
|
+
: {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What the probe printed: the uptime, then the one JSON line of
|
|
66
|
+
* t3-idle.py --activity. Anything unreadable is unknown, never zero.
|
|
67
|
+
*/
|
|
68
|
+
export function parseActivity(stdout: string): WorkerActivity {
|
|
69
|
+
const [uptime, line] = stdout.split('\n');
|
|
70
|
+
const seconds = Number(uptime);
|
|
71
|
+
const known = activityLine(line);
|
|
72
|
+
const running = known['running'];
|
|
73
|
+
const last = known['lastActivity'];
|
|
74
|
+
return {
|
|
75
|
+
uptimeSeconds:
|
|
76
|
+
uptime && Number.isFinite(seconds) && seconds >= 0
|
|
77
|
+
? Math.floor(seconds)
|
|
78
|
+
: null,
|
|
79
|
+
runningSessions:
|
|
80
|
+
Number.isInteger(running) && Number(running) >= 0
|
|
81
|
+
? (running as number)
|
|
82
|
+
: null,
|
|
83
|
+
lastActivityAt:
|
|
84
|
+
typeof last === 'string' && !Number.isNaN(Date.parse(last))
|
|
85
|
+
? new Date(last).toISOString()
|
|
86
|
+
: null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `2d 3h`, `5h 12m`, `40m`: a duration for a person. */
|
|
91
|
+
export function duration(seconds: number): string {
|
|
92
|
+
const minutes = Math.floor(seconds / 60);
|
|
93
|
+
const days = Math.floor(minutes / 1440);
|
|
94
|
+
const hours = Math.floor((minutes % 1440) / 60);
|
|
95
|
+
if (days) return `${days}d ${hours}h`;
|
|
96
|
+
if (hours) return `${hours}h ${minutes % 60}m`;
|
|
97
|
+
return `${minutes}m`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Seconds since the last agent activity, or since boot when there was
|
|
101
|
+
* none; null when it cannot be told.
|
|
102
|
+
*/
|
|
103
|
+
function idleFor(activity: WorkerActivity, now: Date): number | null {
|
|
104
|
+
if (activity.lastActivityAt)
|
|
105
|
+
return Math.max(
|
|
106
|
+
0,
|
|
107
|
+
Math.floor((now.getTime() - Date.parse(activity.lastActivityAt)) / 1000)
|
|
108
|
+
);
|
|
109
|
+
return activity.uptimeSeconds;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `worker:idle`: a warning once a worker has run no agent turn for longer
|
|
113
|
+
* than fleet.json's parking.idleAfterHours. A worker with a turn under way
|
|
114
|
+
* is never idle, and one whose T3 cannot be read is not judged.
|
|
115
|
+
*/
|
|
116
|
+
export function idleCheck(
|
|
117
|
+
id: string,
|
|
118
|
+
activity: WorkerActivity,
|
|
119
|
+
idleAfterHours: number | undefined,
|
|
120
|
+
now: Date
|
|
121
|
+
): { check: Check; idleSeconds?: number; idle: boolean } {
|
|
122
|
+
if (activity.runningSessions === null)
|
|
123
|
+
return {
|
|
124
|
+
idle: false,
|
|
125
|
+
check: {
|
|
126
|
+
id: 'worker:idle',
|
|
127
|
+
status: 'skip',
|
|
128
|
+
summary: `${id}'s agent activity could not be read from its T3.`,
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
if (activity.runningSessions > 0)
|
|
132
|
+
return {
|
|
133
|
+
idle: false,
|
|
134
|
+
idleSeconds: 0,
|
|
135
|
+
check: {
|
|
136
|
+
id: 'worker:idle',
|
|
137
|
+
status: 'pass',
|
|
138
|
+
summary: `${id} runs ${activity.runningSessions} agent turn(s).`,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const seconds = idleFor(activity, now);
|
|
142
|
+
const limit = idleAfterHours === undefined ? null : idleAfterHours * 3600;
|
|
143
|
+
const idle = seconds !== null && limit !== null && seconds > limit;
|
|
144
|
+
return {
|
|
145
|
+
idle,
|
|
146
|
+
...(seconds === null ? {} : { idleSeconds: seconds }),
|
|
147
|
+
check: idle
|
|
148
|
+
? {
|
|
149
|
+
id: 'worker:idle',
|
|
150
|
+
status: 'warn',
|
|
151
|
+
summary: `${id} has run no agent turn for ${duration(seconds!)}, longer than the declared ${idleAfterHours}h.`,
|
|
152
|
+
remedy: {
|
|
153
|
+
action: `Park it while nobody needs it: devfleet fleet-node-park --id ${id}. devfleet fleet-node-resume --id ${id} brings it back.`,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
: {
|
|
157
|
+
id: 'worker:idle',
|
|
158
|
+
status: 'pass',
|
|
159
|
+
summary:
|
|
160
|
+
seconds === null
|
|
161
|
+
? `${id} runs no agent turn.`
|
|
162
|
+
: `${id} runs no agent turn; the last agent activity was ${duration(seconds)} ago.`,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function billing(worker: MachineDefinition | undefined) {
|
|
168
|
+
return worker?.billing
|
|
169
|
+
? {
|
|
170
|
+
hourlyRate: worker.billing.hourlyRate,
|
|
171
|
+
...(worker.billing.currency
|
|
172
|
+
? { currency: worker.billing.currency }
|
|
173
|
+
: {}),
|
|
174
|
+
...(worker.billing.note ? { billingNote: worker.billing.note } : {}),
|
|
175
|
+
}
|
|
176
|
+
: {};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Measures every active enrolled worker and lists the parked ones: the
|
|
180
|
+
* `workers` of fleet-status, and the checks it adds to each worker's report.
|
|
181
|
+
* Parked workers are not contacted.
|
|
182
|
+
*/
|
|
183
|
+
export async function fleetUsage(
|
|
184
|
+
definition: FleetDefinition,
|
|
185
|
+
{
|
|
186
|
+
nodes,
|
|
187
|
+
parked,
|
|
188
|
+
run,
|
|
189
|
+
now,
|
|
190
|
+
}: {
|
|
191
|
+
nodes: FleetNode[];
|
|
192
|
+
parked: Record<string, string>;
|
|
193
|
+
run: Runner;
|
|
194
|
+
now: Date;
|
|
195
|
+
}
|
|
196
|
+
): Promise<{ workers: WorkerUsage[]; checks: Map<string, Check> }> {
|
|
197
|
+
const checks = new Map<string, Check>();
|
|
198
|
+
const workers = await Promise.all(
|
|
199
|
+
definition.workers.map(async (worker): Promise<WorkerUsage> => {
|
|
200
|
+
if (parked[worker.id])
|
|
201
|
+
return {
|
|
202
|
+
id: worker.id,
|
|
203
|
+
state: 'parked',
|
|
204
|
+
parkedAt: parked[worker.id]!,
|
|
205
|
+
...billing(worker),
|
|
206
|
+
};
|
|
207
|
+
const node = nodes.find((entry) => entry.id === worker.id);
|
|
208
|
+
const activity = node ? await workerActivity(node, run) : null;
|
|
209
|
+
if (!activity)
|
|
210
|
+
return {
|
|
211
|
+
id: worker.id,
|
|
212
|
+
state: 'active',
|
|
213
|
+
reachable: false,
|
|
214
|
+
...billing(worker),
|
|
215
|
+
};
|
|
216
|
+
const judged = idleCheck(
|
|
217
|
+
worker.id,
|
|
218
|
+
activity,
|
|
219
|
+
definition.parking?.idleAfterHours,
|
|
220
|
+
now
|
|
221
|
+
);
|
|
222
|
+
checks.set(worker.id, judged.check);
|
|
223
|
+
return {
|
|
224
|
+
id: worker.id,
|
|
225
|
+
state: 'active',
|
|
226
|
+
reachable: true,
|
|
227
|
+
...activity,
|
|
228
|
+
...(judged.idleSeconds === undefined
|
|
229
|
+
? {}
|
|
230
|
+
: { idleSeconds: judged.idleSeconds }),
|
|
231
|
+
idle: judged.idle,
|
|
232
|
+
...billing(worker),
|
|
233
|
+
};
|
|
234
|
+
})
|
|
235
|
+
);
|
|
236
|
+
return { workers, checks };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Workers running, workers idle, and what the idle ones cost per day, by
|
|
240
|
+
* the rates fleet.json declares.
|
|
241
|
+
*/
|
|
242
|
+
export function usageSummary(workers: WorkerUsage[]) {
|
|
243
|
+
const running = workers.filter(
|
|
244
|
+
(worker) => worker.state === 'active' && worker.reachable
|
|
245
|
+
);
|
|
246
|
+
const idle = running.filter((worker) => worker.idle);
|
|
247
|
+
const currencies = [
|
|
248
|
+
...new Set(idle.flatMap((worker) => worker.currency ?? [])),
|
|
249
|
+
];
|
|
250
|
+
return {
|
|
251
|
+
running: running.length,
|
|
252
|
+
idle: idle.length,
|
|
253
|
+
parked: workers.filter((worker) => worker.state === 'parked').length,
|
|
254
|
+
idleCostPerDay:
|
|
255
|
+
Math.round(
|
|
256
|
+
idle.reduce((sum, worker) => sum + (worker.hourlyRate ?? 0) * 24, 0) *
|
|
257
|
+
100
|
|
258
|
+
) / 100,
|
|
259
|
+
...(currencies.length === 1 ? { currency: currencies[0] } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The lines fleet-status prints about each worker and the fleet. */
|
|
264
|
+
export function usageText(workers: WorkerUsage[], now: Date): string {
|
|
265
|
+
const summary = usageSummary(workers);
|
|
266
|
+
const lines = workers.map((worker) => {
|
|
267
|
+
if (worker.state === 'parked')
|
|
268
|
+
return ` ${worker.id}: parked for ${duration(Math.max(0, (now.getTime() - Date.parse(worker.parkedAt!)) / 1000))}`;
|
|
269
|
+
if (!worker.reachable) return ` ${worker.id}: not reached`;
|
|
270
|
+
const parts = [
|
|
271
|
+
worker.uptimeSeconds == null
|
|
272
|
+
? 'uptime unknown'
|
|
273
|
+
: `up ${duration(worker.uptimeSeconds)}`,
|
|
274
|
+
worker.runningSessions == null
|
|
275
|
+
? 'agent turns unknown'
|
|
276
|
+
: `${worker.runningSessions} agent turn(s) running`,
|
|
277
|
+
worker.lastActivityAt
|
|
278
|
+
? `last agent activity ${worker.lastActivityAt}`
|
|
279
|
+
: 'no agent activity recorded',
|
|
280
|
+
];
|
|
281
|
+
if (worker.idle) parts.push('idle');
|
|
282
|
+
if (worker.hourlyRate !== undefined)
|
|
283
|
+
parts.push(
|
|
284
|
+
`${worker.hourlyRate}${worker.currency ? ` ${worker.currency}` : ''}/h`
|
|
285
|
+
);
|
|
286
|
+
return ` ${worker.id}: ${parts.join(', ')}`;
|
|
287
|
+
});
|
|
288
|
+
return [
|
|
289
|
+
'Workers:',
|
|
290
|
+
...lines,
|
|
291
|
+
`${summary.running} running, ${summary.idle} idle, ${summary.parked} parked; idle workers cost ${summary.idleCostPerDay}${summary.currency ? ` ${summary.currency}` : ''} per day as declared.`,
|
|
292
|
+
].join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** A report's text, followed by the worker lines when there are any. */
|
|
296
|
+
export function withUsage(
|
|
297
|
+
text: string,
|
|
298
|
+
usage: { workers: WorkerUsage[] } | null
|
|
299
|
+
): string {
|
|
300
|
+
return usage?.workers.length
|
|
301
|
+
? `${text}\n${usageText(usage.workers, new Date())}`
|
|
302
|
+
: text;
|
|
303
|
+
}
|
|
@@ -54,30 +54,67 @@ function dockerFailure(result: CommandResult): {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** A word the shell reads back unchanged. */
|
|
58
|
+
const quoted = (word: string): string => `'${word.replaceAll("'", `'\\''`)}'`;
|
|
59
|
+
|
|
60
|
+
/** fleet-sync adds the worker's user to the docker group, but a session
|
|
61
|
+
* started before that keeps its old groups: the group database lists the
|
|
62
|
+
* user in docker while this process does not hold the group. A fresh session
|
|
63
|
+
* made with `sg docker` then reads the group from scratch, as the baseline's
|
|
64
|
+
* own smoke test does with `runuser`, so the first sync of a new worker
|
|
65
|
+
* measures the worker and not the age of its session.
|
|
66
|
+
*/
|
|
67
|
+
async function freshDockerSession(run: Runner): Promise<Runner | undefined> {
|
|
68
|
+
const user = await run(['id', '-un']);
|
|
69
|
+
const name = user.stdout.trim();
|
|
70
|
+
if (!user.ok || !name) return undefined;
|
|
71
|
+
const held = await run(['id', '-Gn']);
|
|
72
|
+
const listed = await run(['id', '-Gn', name]);
|
|
73
|
+
const groups = (result: CommandResult): string[] =>
|
|
74
|
+
result.ok ? result.stdout.trim().split(/\s+/) : [];
|
|
75
|
+
if (groups(held).includes('docker') || !groups(listed).includes('docker'))
|
|
76
|
+
return undefined;
|
|
77
|
+
return (command, options) =>
|
|
78
|
+
run(['sg', 'docker', '-c', command.map(quoted).join(' ')], options);
|
|
79
|
+
}
|
|
80
|
+
|
|
57
81
|
/** Runs the pinned image offline, then builds a one-line image from it. */
|
|
58
82
|
export async function dockerRunCheck(run: Runner): Promise<Check> {
|
|
59
83
|
const id = 'worker:docker-run';
|
|
60
84
|
const { image, timeoutSeconds } = policy.dockerSmokeTest;
|
|
61
85
|
const timeoutMs = timeoutSeconds * 1000;
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
const runContainer = (runner: Runner) =>
|
|
87
|
+
runner(
|
|
88
|
+
[
|
|
89
|
+
'docker',
|
|
90
|
+
'run',
|
|
91
|
+
'--rm',
|
|
92
|
+
'--network',
|
|
93
|
+
'none',
|
|
94
|
+
'--pull',
|
|
95
|
+
'never',
|
|
96
|
+
image,
|
|
97
|
+
'true',
|
|
98
|
+
],
|
|
99
|
+
{ timeoutMs }
|
|
100
|
+
);
|
|
101
|
+
let session = run;
|
|
102
|
+
let started = await runContainer(session);
|
|
103
|
+
if (
|
|
104
|
+
!started.ok &&
|
|
105
|
+
dockerFailure(started).code === 'DOCKER_SOCKET_ACCESS_DENIED'
|
|
106
|
+
) {
|
|
107
|
+
const fresh = await freshDockerSession(run);
|
|
108
|
+
if (fresh) {
|
|
109
|
+
session = fresh;
|
|
110
|
+
started = await runContainer(session);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
76
113
|
if (!started.ok) {
|
|
77
114
|
const { code, action } = dockerFailure(started);
|
|
78
115
|
return failure(id, code, action);
|
|
79
116
|
}
|
|
80
|
-
const built = await
|
|
117
|
+
const built = await session(
|
|
81
118
|
['docker', 'build', '--quiet', '--pull=false', '--network', 'none', '-'],
|
|
82
119
|
{ input: `FROM ${image}\nRUN true\n`, timeoutMs }
|
|
83
120
|
);
|
|
@@ -95,7 +132,9 @@ export async function dockerRunCheck(run: Runner): Promise<Check> {
|
|
|
95
132
|
id,
|
|
96
133
|
status: 'pass',
|
|
97
134
|
summary:
|
|
98
|
-
|
|
135
|
+
session === run
|
|
136
|
+
? 'A container of the pinned image ran and a one-line image built, offline, in this session.'
|
|
137
|
+
: 'A container of the pinned image ran and a one-line image built, offline, in a fresh session of the docker group: sessions started before fleet-sync granted it need a new login.',
|
|
99
138
|
};
|
|
100
139
|
}
|
|
101
140
|
|
package/src/worker-capacity.ts
CHANGED
|
@@ -2,6 +2,8 @@ import { join } from 'node:path';
|
|
|
2
2
|
import { clockCheck } from './clock';
|
|
3
3
|
import { healthChecks, parseProbe } from './machine-health';
|
|
4
4
|
import type { Check, Runner } from './types';
|
|
5
|
+
import policy from '../ansible/ubuntu/policy.json';
|
|
6
|
+
import { supportedSessions } from './worker-sizing';
|
|
5
7
|
|
|
6
8
|
export async function workerCapacity(
|
|
7
9
|
root: string,
|
|
@@ -35,6 +37,9 @@ export async function workerCapacity(
|
|
|
35
37
|
summary: check.detail,
|
|
36
38
|
...(check.fix ? { remedy: { action: check.fix } } : {}),
|
|
37
39
|
})),
|
|
40
|
+
...(sample.memTotalMb === undefined
|
|
41
|
+
? []
|
|
42
|
+
: [sessionsCheck(sample.memTotalMb, sample.agentSessions)]),
|
|
38
43
|
clockCheck(
|
|
39
44
|
'worker:clock',
|
|
40
45
|
sample,
|
|
@@ -44,3 +49,29 @@ export async function workerCapacity(
|
|
|
44
49
|
),
|
|
45
50
|
];
|
|
46
51
|
}
|
|
52
|
+
|
|
53
|
+
const SESSIONS_EXHAUSTED = 'AGENT_SESSION_CAPACITY_EXHAUSTED';
|
|
54
|
+
|
|
55
|
+
/** How many agent sessions this worker's memory policy supports, and how
|
|
56
|
+
* many run. The size of a session is an estimate (`sizing.agentSessionMiB`),
|
|
57
|
+
* so more sessions than that only warns.
|
|
58
|
+
*/
|
|
59
|
+
export function sessionsCheck(memoryMiB: number, running?: number): Check {
|
|
60
|
+
const supported = supportedSessions(memoryMiB);
|
|
61
|
+
const each = policy.sizing.agentSessionMiB;
|
|
62
|
+
const now = running === undefined ? 'an unknown number' : String(running);
|
|
63
|
+
const over = running !== undefined && running > supported;
|
|
64
|
+
return {
|
|
65
|
+
id: 'worker:agent-sessions',
|
|
66
|
+
status: over ? 'warn' : 'pass',
|
|
67
|
+
summary: `${over ? `${SESSIONS_EXHAUSTED}: ` : ''}${now} agent sessions running; the memory policy supports ${supported} at once on ${memoryMiB} MiB (an estimated ${each} MiB each).`,
|
|
68
|
+
...(over
|
|
69
|
+
? {
|
|
70
|
+
remedy: {
|
|
71
|
+
action:
|
|
72
|
+
'More agent sessions run than the memory policy supports: each is still capped, so the largest may be stopped under pressure. Finish some, or move work to a worker with more memory.',
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
: {}),
|
|
76
|
+
};
|
|
77
|
+
}
|