@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
|
@@ -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
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { fleet } from './fleet-config';
|
|
2
|
+
import { resolveVaultConfig, type VaultConfig } from './vault-config';
|
|
3
|
+
import type { Check, Runner } from './types';
|
|
4
|
+
|
|
5
|
+
/** Each worker's own Vault token is kept in a document of its own, beside
|
|
6
|
+
* the fleet's shared one: `<project>/workers/<id>`, field `token`, with the
|
|
7
|
+
* token's `accessor` so the manager can revoke it without holding it. A
|
|
8
|
+
* policy that grants one worker its document grants it nothing of another's.
|
|
9
|
+
*/
|
|
10
|
+
export interface WorkerTokenLocation {
|
|
11
|
+
mount: string;
|
|
12
|
+
path: string;
|
|
13
|
+
field: 'token';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function workerTokenLocation(
|
|
17
|
+
node: string,
|
|
18
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
19
|
+
): WorkerTokenLocation {
|
|
20
|
+
return {
|
|
21
|
+
mount: vault.mount,
|
|
22
|
+
path: `${vault.project}/workers/${node}`,
|
|
23
|
+
field: 'token',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The Vault policy each worker token is issued with. */
|
|
28
|
+
export const workerPolicyName = (node: string): string =>
|
|
29
|
+
`fleet-worker-${node}`;
|
|
30
|
+
|
|
31
|
+
/** The least a worker needs: the fleet's shared document (the proxy key and
|
|
32
|
+
* what the providers read) and its own token document. Nothing grants a
|
|
33
|
+
* worker the documents of the others, so one worker's token cannot read
|
|
34
|
+
* another's. Token renewal and lookup come from Vault's default policy.
|
|
35
|
+
*/
|
|
36
|
+
export function workerVaultPolicy(
|
|
37
|
+
node: string,
|
|
38
|
+
vault: VaultConfig = resolveVaultConfig(),
|
|
39
|
+
shared: string = sharedDocument()
|
|
40
|
+
): string {
|
|
41
|
+
const own = workerTokenLocation(node, vault);
|
|
42
|
+
return [
|
|
43
|
+
`# dev-fleet: the policy of the worker ${node}. Written by dev-fleet; do not edit.`,
|
|
44
|
+
`path "${vault.mount}/data/${shared}" {`,
|
|
45
|
+
' capabilities = ["read"]',
|
|
46
|
+
'}',
|
|
47
|
+
`path "${own.mount}/data/${own.path}" {`,
|
|
48
|
+
' capabilities = ["read"]',
|
|
49
|
+
'}',
|
|
50
|
+
'',
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sharedDocument(): string {
|
|
55
|
+
const secrets = fleet.secrets;
|
|
56
|
+
return secrets.provider === 'vault'
|
|
57
|
+
? secrets.locations['proxy-client-key'].path
|
|
58
|
+
: '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const TIMEOUT = { timeoutMs: 20_000 };
|
|
62
|
+
|
|
63
|
+
/** Issues a worker its own periodic, orphan token under its own policy and
|
|
64
|
+
* keeps it in its own document. The token reaches `vault kv put` on standard
|
|
65
|
+
* input, never as an argument. Run on the manager, logged in to Vault.
|
|
66
|
+
*/
|
|
67
|
+
export async function issueWorkerToken(
|
|
68
|
+
node: string,
|
|
69
|
+
run: Runner,
|
|
70
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
71
|
+
): Promise<Check> {
|
|
72
|
+
const address = `-address=${vault.addr}`;
|
|
73
|
+
const policy = await run(
|
|
74
|
+
['vault', 'policy', 'write', address, workerPolicyName(node), '-'],
|
|
75
|
+
{ ...TIMEOUT, input: workerVaultPolicy(node, vault) }
|
|
76
|
+
);
|
|
77
|
+
if (!policy.ok) return credentialFailure('VAULT_WORKER_POLICY_FAILED');
|
|
78
|
+
const created = await run(
|
|
79
|
+
[
|
|
80
|
+
'vault',
|
|
81
|
+
'token',
|
|
82
|
+
'create',
|
|
83
|
+
address,
|
|
84
|
+
`-policy=${workerPolicyName(node)}`,
|
|
85
|
+
'-orphan',
|
|
86
|
+
'-period=768h',
|
|
87
|
+
`-display-name=${workerPolicyName(node)}`,
|
|
88
|
+
'-format=json',
|
|
89
|
+
],
|
|
90
|
+
TIMEOUT
|
|
91
|
+
);
|
|
92
|
+
const issued = created.ok ? parseIssued(created.stdout) : null;
|
|
93
|
+
if (!issued) return credentialFailure('VAULT_WORKER_TOKEN_NOT_ISSUED');
|
|
94
|
+
const location = workerTokenLocation(node, vault);
|
|
95
|
+
const stored = await run(
|
|
96
|
+
[
|
|
97
|
+
'vault',
|
|
98
|
+
'kv',
|
|
99
|
+
'put',
|
|
100
|
+
address,
|
|
101
|
+
`-mount=${location.mount}`,
|
|
102
|
+
location.path,
|
|
103
|
+
'token=-',
|
|
104
|
+
`accessor=${issued.accessor}`,
|
|
105
|
+
],
|
|
106
|
+
{ ...TIMEOUT, input: issued.token }
|
|
107
|
+
);
|
|
108
|
+
if (!stored.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_STORED');
|
|
109
|
+
return {
|
|
110
|
+
id: 'auth:worker-token',
|
|
111
|
+
status: 'pass',
|
|
112
|
+
summary: `${node} has a token of its own, readable by nothing but itself and the manager.`,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseIssued(
|
|
117
|
+
stdout: string
|
|
118
|
+
): { token: string; accessor: string } | null {
|
|
119
|
+
try {
|
|
120
|
+
const auth = (JSON.parse(stdout) as { auth?: Record<string, unknown> })
|
|
121
|
+
.auth;
|
|
122
|
+
const token = auth?.['client_token'];
|
|
123
|
+
const accessor = auth?.['accessor'];
|
|
124
|
+
return typeof token === 'string' &&
|
|
125
|
+
typeof accessor === 'string' &&
|
|
126
|
+
/^[\x21-\x7e]{16,4096}$/.test(token) &&
|
|
127
|
+
/^[A-Za-z0-9._-]{8,256}$/.test(accessor)
|
|
128
|
+
? { token, accessor }
|
|
129
|
+
: null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Revokes a worker's token by its accessor (the manager never holds the
|
|
136
|
+
* token to do so), then removes its document and its policy. A worker that
|
|
137
|
+
* has none already is revoked: running it twice changes nothing.
|
|
138
|
+
*/
|
|
139
|
+
export async function revokeWorkerToken(
|
|
140
|
+
node: string,
|
|
141
|
+
run: Runner,
|
|
142
|
+
vault: VaultConfig = resolveVaultConfig()
|
|
143
|
+
): Promise<Check> {
|
|
144
|
+
const address = `-address=${vault.addr}`;
|
|
145
|
+
const location = workerTokenLocation(node, vault);
|
|
146
|
+
const read = await run(
|
|
147
|
+
[
|
|
148
|
+
'vault',
|
|
149
|
+
'kv',
|
|
150
|
+
'get',
|
|
151
|
+
address,
|
|
152
|
+
`-mount=${location.mount}`,
|
|
153
|
+
'-field=accessor',
|
|
154
|
+
location.path,
|
|
155
|
+
],
|
|
156
|
+
TIMEOUT
|
|
157
|
+
);
|
|
158
|
+
const accessor = read.ok ? read.stdout.trim() : '';
|
|
159
|
+
if (accessor) {
|
|
160
|
+
if (!/^[A-Za-z0-9._-]{8,256}$/.test(accessor))
|
|
161
|
+
return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
162
|
+
const revoked = await run(
|
|
163
|
+
['vault', 'token', 'revoke', address, '-accessor', accessor],
|
|
164
|
+
TIMEOUT
|
|
165
|
+
);
|
|
166
|
+
if (!revoked.ok) return credentialFailure('VAULT_WORKER_TOKEN_NOT_REVOKED');
|
|
167
|
+
}
|
|
168
|
+
await run(
|
|
169
|
+
[
|
|
170
|
+
'vault',
|
|
171
|
+
'kv',
|
|
172
|
+
'metadata',
|
|
173
|
+
'delete',
|
|
174
|
+
address,
|
|
175
|
+
`-mount=${location.mount}`,
|
|
176
|
+
location.path,
|
|
177
|
+
],
|
|
178
|
+
TIMEOUT
|
|
179
|
+
);
|
|
180
|
+
await run(
|
|
181
|
+
['vault', 'policy', 'delete', address, workerPolicyName(node)],
|
|
182
|
+
TIMEOUT
|
|
183
|
+
);
|
|
184
|
+
return {
|
|
185
|
+
id: 'auth:worker-token',
|
|
186
|
+
status: 'pass',
|
|
187
|
+
summary: `${node}'s token is revoked and its document and policy removed.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function credentialFailure(summary: string): Check {
|
|
192
|
+
return {
|
|
193
|
+
id: 'auth:worker-token',
|
|
194
|
+
status: 'fail',
|
|
195
|
+
summary,
|
|
196
|
+
remedy: {
|
|
197
|
+
action:
|
|
198
|
+
'Log the manager in to Vault with a token that may write policies, issue tokens and write the fleet mount, then run the command again.',
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|