@yeaft/webchat-agent 1.0.437 → 1.0.438
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/container-cli.js +61 -2
- package/container-manager.js +185 -5
- package/local-runtime/agent/container-manager.js +185 -5
- package/local-runtime/server/config.js +5 -1
- package/local-runtime/server/container-agent-service.js +1 -0
- package/local-runtime/version.json +1 -1
- package/package.json +1 -1
package/container-cli.js
CHANGED
|
@@ -3,9 +3,15 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import {
|
|
5
5
|
createContainerAgent,
|
|
6
|
+
DEFAULT_AGENT_SLICE,
|
|
7
|
+
detectHostResources,
|
|
8
|
+
ensureAgentSlice,
|
|
6
9
|
inspectContainerAgent,
|
|
10
|
+
isAgentSliceReady,
|
|
7
11
|
logsContainerAgent,
|
|
8
12
|
removeContainerAgent,
|
|
13
|
+
resolveDiskSize,
|
|
14
|
+
runDocker,
|
|
9
15
|
startContainerAgent,
|
|
10
16
|
stopContainerAgent,
|
|
11
17
|
writeAgentSecretFile,
|
|
@@ -14,12 +20,24 @@ import {
|
|
|
14
20
|
function help() {
|
|
15
21
|
console.log(`
|
|
16
22
|
Usage:
|
|
17
|
-
yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>]
|
|
23
|
+
yeaft-agent container install --server <ws-url> --name <name> --secret <secret> [--image <image>] [--cgroup-parent <slice>] [--no-slice] [--disk-size <size>]
|
|
24
|
+
yeaft-agent container setup-limits [--cpu-percent <pct>] [--memory-percent <pct>] [--pids <n>]
|
|
18
25
|
yeaft-agent container start|stop|status|remove|logs --name <name>
|
|
19
26
|
|
|
20
27
|
The container is an ordinary yeaft-agent. This command only manages its Docker lifecycle.
|
|
21
28
|
The secret is passed as an argument, like 'yeaft-agent install', and persisted by this
|
|
22
29
|
command to a private 0600 file before the container is created.
|
|
30
|
+
|
|
31
|
+
Resource protection (default): install attaches the container to the shared
|
|
32
|
+
'${DEFAULT_AGENT_SLICE}' cgroup slice created by 'setup-limits', which caps the sum of all
|
|
33
|
+
Yeaft Agent containers at 90% of host CPU and 70% of host memory. Run setup-limits once
|
|
34
|
+
as root before installing (or pass --no-slice to opt out of protection).
|
|
35
|
+
|
|
36
|
+
--disk-size <size>: per-volume capacity quota for the data and workspace volumes, e.g.
|
|
37
|
+
20G or 80% of the Docker data-root filesystem. Requires Docker overlay2 on xfs with
|
|
38
|
+
project quotas; unsupported filesystems fail docker create. Applies to newly created
|
|
39
|
+
volumes only (Docker ignores size for existing volumes).
|
|
40
|
+
|
|
23
41
|
Use --keep-volumes with remove to preserve its Yeaft data and workspace volumes.
|
|
24
42
|
`);
|
|
25
43
|
}
|
|
@@ -29,7 +47,7 @@ export function parseContainerArgs(args) {
|
|
|
29
47
|
const positionals = [];
|
|
30
48
|
for (let i = 0; i < args.length; i++) {
|
|
31
49
|
const arg = args[i];
|
|
32
|
-
if (arg === '--keep-volumes' || arg === '--follow') {
|
|
50
|
+
if (arg === '--keep-volumes' || arg === '--follow' || arg === '--no-slice') {
|
|
33
51
|
options[arg.slice(2).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = true;
|
|
34
52
|
continue;
|
|
35
53
|
}
|
|
@@ -44,6 +62,12 @@ export function parseContainerArgs(args) {
|
|
|
44
62
|
return { action: positionals[0], options };
|
|
45
63
|
}
|
|
46
64
|
|
|
65
|
+
async function dockerDataRoot() {
|
|
66
|
+
const result = await runDocker(['info', '--format', '{{.DockerRootDir}}']);
|
|
67
|
+
if (!result.stdout) throw new Error('docker info did not report a data-root');
|
|
68
|
+
return result.stdout;
|
|
69
|
+
}
|
|
70
|
+
|
|
47
71
|
export async function runContainerCli(args) {
|
|
48
72
|
if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') return help();
|
|
49
73
|
const { action, options } = parseContainerArgs(args);
|
|
@@ -57,12 +81,47 @@ export async function runContainerCli(args) {
|
|
|
57
81
|
if (!secret) throw new Error('--secret <secret> is required');
|
|
58
82
|
const secretFile = join(homedir(), '.yeaft', 'container-agents', name, 'agent-secret');
|
|
59
83
|
await writeAgentSecretFile(secretFile, secret);
|
|
84
|
+
let cgroupParent;
|
|
85
|
+
if (options.noSlice) {
|
|
86
|
+
cgroupParent = undefined;
|
|
87
|
+
} else if (options.cgroupParent) {
|
|
88
|
+
cgroupParent = String(options.cgroupParent).trim();
|
|
89
|
+
} else if (!(await isAgentSliceReady())) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Agent slice '${DEFAULT_AGENT_SLICE}' is not initialized; ` +
|
|
92
|
+
'run "sudo yeaft-agent container setup-limits" first, ' +
|
|
93
|
+
'or pass --no-slice to install without shared resource limits',
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
cgroupParent = DEFAULT_AGENT_SLICE;
|
|
97
|
+
}
|
|
98
|
+
let diskSizeBytes;
|
|
99
|
+
if (options.diskSize) {
|
|
100
|
+
const value = String(options.diskSize).trim();
|
|
101
|
+
diskSizeBytes = await resolveDiskSize(value, {
|
|
102
|
+
dockerRoot: value.includes('%') ? await dockerDataRoot() : undefined,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
60
105
|
result = await createContainerAgent({
|
|
61
106
|
name,
|
|
62
107
|
serverUrl: options.server,
|
|
63
108
|
secretFile,
|
|
64
109
|
image: options.image,
|
|
110
|
+
cgroupParent,
|
|
111
|
+
diskSizeBytes,
|
|
112
|
+
});
|
|
113
|
+
} else if (action === 'setup-limits') {
|
|
114
|
+
if (typeof process.getuid === 'function' && process.getuid() !== 0) {
|
|
115
|
+
throw new Error('setup-limits must run as root; use sudo');
|
|
116
|
+
}
|
|
117
|
+
const resources = await detectHostResources();
|
|
118
|
+
result = await ensureAgentSlice({
|
|
119
|
+
cpuPercent: Number(options.cpuPercent) || 90,
|
|
120
|
+
memoryPercent: Number(options.memoryPercent) || 70,
|
|
121
|
+
pidsLimit: Number(options.pids) || 4096,
|
|
122
|
+
resources,
|
|
65
123
|
});
|
|
124
|
+
result.resources = resources;
|
|
66
125
|
} else if (action === 'start') {
|
|
67
126
|
result = await startContainerAgent(name);
|
|
68
127
|
} else if (action === 'stop') {
|
package/container-manager.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, readFile, statfs, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { cpus } from 'node:os';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
|
|
7
|
+
export const DEFAULT_AGENT_SLICE = 'yeaft.slice';
|
|
8
|
+
export const SLICE_READY_MARKER = 'yeaft-slice.ready';
|
|
6
9
|
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
10
|
+
const CPU_PERIOD_US = 100_000;
|
|
7
11
|
|
|
8
12
|
export class ContainerAgentError extends Error {
|
|
9
13
|
constructor(code, message = code) {
|
|
@@ -64,6 +68,8 @@ export function buildCreateArgs({
|
|
|
64
68
|
dataVolume,
|
|
65
69
|
workspaceVolume,
|
|
66
70
|
restart = 'unless-stopped',
|
|
71
|
+
cgroupParent,
|
|
72
|
+
diskSizeBytes,
|
|
67
73
|
}) {
|
|
68
74
|
const agentName = normalizeContainerAgentName(name);
|
|
69
75
|
if (!String(serverUrl || '').match(/^wss?:\/\//)) {
|
|
@@ -73,14 +79,29 @@ export function buildCreateArgs({
|
|
|
73
79
|
const containerName = containerNameForAgent(agentName);
|
|
74
80
|
const safeImage = String(image || '').trim();
|
|
75
81
|
if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
|
|
76
|
-
|
|
82
|
+
const args = [
|
|
77
83
|
'create', '--name', containerName,
|
|
78
84
|
'--label', 'io.yeaft.container-agent=true',
|
|
79
85
|
'--label', `io.yeaft.agent-name=${agentName}`,
|
|
80
86
|
'--restart', restart,
|
|
81
87
|
'--init',
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
];
|
|
89
|
+
if (cgroupParent) {
|
|
90
|
+
const slice = String(cgroupParent).trim();
|
|
91
|
+
if (!slice) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_CGROUP_PARENT');
|
|
92
|
+
args.push('--cgroup-parent', slice);
|
|
93
|
+
}
|
|
94
|
+
let diskOpt = '';
|
|
95
|
+
if (diskSizeBytes) {
|
|
96
|
+
const size = Math.floor(Number(diskSizeBytes));
|
|
97
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
98
|
+
throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE');
|
|
99
|
+
}
|
|
100
|
+
diskOpt = `,volume-opt=size=${size}`;
|
|
101
|
+
}
|
|
102
|
+
args.push(
|
|
103
|
+
'--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft${diskOpt}`,
|
|
104
|
+
'--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace${diskOpt}`,
|
|
84
105
|
'--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
|
|
85
106
|
'--env', `SERVER_URL=${serverUrl}`,
|
|
86
107
|
'--env', `AGENT_NAME=${agentName}`,
|
|
@@ -88,7 +109,8 @@ export function buildCreateArgs({
|
|
|
88
109
|
'--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
|
|
89
110
|
'--env', 'WORK_DIR=/workspace',
|
|
90
111
|
safeImage,
|
|
91
|
-
|
|
112
|
+
);
|
|
113
|
+
return args;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
/**
|
|
@@ -182,3 +204,161 @@ export async function logsContainerAgent(name, { follow = false, ...runtime } =
|
|
|
182
204
|
args.push(containerNameForAgent(name));
|
|
183
205
|
return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
|
|
184
206
|
}
|
|
207
|
+
|
|
208
|
+
function clampPercent(value, fallback) {
|
|
209
|
+
const n = Number(value);
|
|
210
|
+
const safe = Number.isFinite(n) ? n : fallback;
|
|
211
|
+
return Math.min(100, Math.max(1, safe));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compute cgroup v2 slice limits from host resources. Pure function so the
|
|
216
|
+
* 90% CPU / 70% memory policy is directly testable.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} policy cpuPercent (default 90), memoryPercent (default 70), pidsLimit (default 4096)
|
|
219
|
+
* @param {object} resources { cpuCores, memTotalBytes } from detectHostResources
|
|
220
|
+
* @returns {{cpuQuotaUs: number, cpuPeriodUs: number, memoryMaxBytes: number, pidsMax: number}}
|
|
221
|
+
*/
|
|
222
|
+
export function buildSliceLimits(
|
|
223
|
+
{ cpuPercent = 90, memoryPercent = 70, pidsLimit = 4096 } = {},
|
|
224
|
+
{ cpuCores = 0, memTotalBytes = 0 } = {},
|
|
225
|
+
) {
|
|
226
|
+
const cores = Math.max(1, Math.floor(Number(cpuCores) || 0) || 1);
|
|
227
|
+
const cpuPct = clampPercent(cpuPercent, 90);
|
|
228
|
+
const memPct = clampPercent(memoryPercent, 70);
|
|
229
|
+
return {
|
|
230
|
+
cpuQuotaUs: Math.floor((cores * cpuPct / 100) * CPU_PERIOD_US),
|
|
231
|
+
cpuPeriodUs: CPU_PERIOD_US,
|
|
232
|
+
memoryMaxBytes: Math.floor((Number(memTotalBytes) || 0) * memPct / 100),
|
|
233
|
+
pidsMax: Math.max(64, Math.floor(Number(pidsLimit) || 0) || 4096),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Detect host resources: logical CPU count plus MemTotal from /proc/meminfo.
|
|
239
|
+
* MemTotal may be 0 on non-Linux or unreadable /proc; callers must treat a
|
|
240
|
+
* zero memory limit as "no memory constraint" rather than OOM-ing containers.
|
|
241
|
+
*
|
|
242
|
+
* @param {object} options { readFileImpl, cpuCount } overrides for tests
|
|
243
|
+
* @returns {Promise<{cpuCores: number, memTotalBytes: number}>}
|
|
244
|
+
*/
|
|
245
|
+
export async function detectHostResources({ readFileImpl = readFile, cpuCount = cpus().length } = {}) {
|
|
246
|
+
let memTotalBytes = 0;
|
|
247
|
+
try {
|
|
248
|
+
const meminfo = await readFileImpl('/proc/meminfo', 'utf8');
|
|
249
|
+
const match = String(meminfo || '').match(/^MemTotal:\s+(\d+)\s*kB/m);
|
|
250
|
+
if (match) memTotalBytes = Number(match[1]) * 1024;
|
|
251
|
+
} catch {
|
|
252
|
+
// Non-Linux or restricted /proc; memory limiting is skipped downstream.
|
|
253
|
+
}
|
|
254
|
+
return { cpuCores: Math.max(1, Math.floor(Number(cpuCount) || 0) || 1), memTotalBytes };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create or refresh a cgroup v2 slice that all Yeaft Agent containers share.
|
|
259
|
+
* Requires root (writes under /sys/fs/cgroup). Writes a ready marker so
|
|
260
|
+
* `container install` can refuse to create unprotected containers by default.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} options slice, cpuPercent, memoryPercent, pidsLimit,
|
|
263
|
+
* resources (pre-detected), fsImpl { mkdir, readFile, writeFile } for tests
|
|
264
|
+
* @returns {Promise<{slice: string, limits: object, controllers: string[]}>}
|
|
265
|
+
*/
|
|
266
|
+
export async function ensureAgentSlice({
|
|
267
|
+
slice = DEFAULT_AGENT_SLICE,
|
|
268
|
+
cpuPercent = 90,
|
|
269
|
+
memoryPercent = 70,
|
|
270
|
+
pidsLimit = 4096,
|
|
271
|
+
resources,
|
|
272
|
+
fsImpl = { mkdir, readFile, writeFile },
|
|
273
|
+
} = {}) {
|
|
274
|
+
const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
|
|
275
|
+
let controllers = [];
|
|
276
|
+
try {
|
|
277
|
+
const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
|
|
278
|
+
controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
279
|
+
} catch {
|
|
280
|
+
// Treat missing controller list as "no controllers writable".
|
|
281
|
+
}
|
|
282
|
+
const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
|
|
283
|
+
if (wanted.length === 0) {
|
|
284
|
+
throw new ContainerAgentError(
|
|
285
|
+
'CONTAINER_AGENT_CGROUP_UNAVAILABLE',
|
|
286
|
+
'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
|
|
290
|
+
await fsImpl.writeFile(
|
|
291
|
+
`${slicePath}/cgroup.subtree_control`,
|
|
292
|
+
wanted.map(name => `+${name}`).join(' '),
|
|
293
|
+
'utf8',
|
|
294
|
+
);
|
|
295
|
+
const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
|
|
296
|
+
const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
|
|
297
|
+
if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
|
|
298
|
+
await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
|
|
299
|
+
}
|
|
300
|
+
if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
|
|
301
|
+
await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
|
|
302
|
+
// No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
|
|
303
|
+
await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
|
|
304
|
+
}
|
|
305
|
+
if (wanted.includes('pids') && limits.pidsMax > 0) {
|
|
306
|
+
await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
|
|
307
|
+
}
|
|
308
|
+
const marker = {
|
|
309
|
+
slice,
|
|
310
|
+
cpuQuotaUs: limits.cpuQuotaUs,
|
|
311
|
+
cpuPeriodUs: limits.cpuPeriodUs,
|
|
312
|
+
memoryMaxBytes: limits.memoryMaxBytes,
|
|
313
|
+
pidsMax: limits.pidsMax,
|
|
314
|
+
updatedAt: new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
317
|
+
return { slice, limits, controllers: wanted };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether `container setup-limits` has initialized the given slice.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} options slice, readFileImpl for tests
|
|
324
|
+
* @returns {Promise<boolean>}
|
|
325
|
+
*/
|
|
326
|
+
export async function isAgentSliceReady({ slice = DEFAULT_AGENT_SLICE, readFileImpl = readFile } = {}) {
|
|
327
|
+
const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
|
|
328
|
+
try {
|
|
329
|
+
await readFileImpl(`${slicePath}/${SLICE_READY_MARKER}`, 'utf8');
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const DISK_SIZE_RE = /^(\d+(?:\.\d+)?)\s*(%|[kmgt](?:i?b)?|b)?$/i;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolve a disk size like "20G" or "80%" to bytes. Percentages are computed
|
|
340
|
+
* against the Docker data-root filesystem and require statfsImpl (node:fs
|
|
341
|
+
* statfs) plus the docker root path.
|
|
342
|
+
*
|
|
343
|
+
* @param {string|number} value
|
|
344
|
+
* @param {object} options { dockerRoot, statfsImpl } for percent resolution and tests
|
|
345
|
+
* @returns {Promise<number>}
|
|
346
|
+
*/
|
|
347
|
+
export async function resolveDiskSize(value, { dockerRoot, statfsImpl = statfs } = {}) {
|
|
348
|
+
const input = String(value ?? '').trim();
|
|
349
|
+
const match = input.match(DISK_SIZE_RE);
|
|
350
|
+
if (!match) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
|
|
351
|
+
const amount = Number(match[1]);
|
|
352
|
+
const unit = (match[2] || 'b').toLowerCase();
|
|
353
|
+
if (unit === 'b') return Math.floor(amount);
|
|
354
|
+
if (unit === '%') {
|
|
355
|
+
if (!dockerRoot) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', 'Percent disk size requires the Docker data-root');
|
|
356
|
+
const info = await statfsImpl(dockerRoot);
|
|
357
|
+
const totalBytes = Number(info.bsize) * Number(info.blocks);
|
|
358
|
+
return Math.floor(totalBytes * amount / 100);
|
|
359
|
+
}
|
|
360
|
+
const multipliers = { k: 1024, kb: 1024, kib: 1024, m: 1024 ** 2, mb: 1024 ** 2, mib: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3, gib: 1024 ** 3, t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4 };
|
|
361
|
+
const multiplier = multipliers[unit];
|
|
362
|
+
if (!multiplier) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
|
|
363
|
+
return Math.floor(amount * multiplier);
|
|
364
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { chmod, mkdir, readFile, statfs, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { cpus } from 'node:os';
|
|
3
4
|
import { dirname, resolve } from 'node:path';
|
|
4
5
|
|
|
5
6
|
export const DEFAULT_AGENT_IMAGE = 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev';
|
|
7
|
+
export const DEFAULT_AGENT_SLICE = 'yeaft.slice';
|
|
8
|
+
export const SLICE_READY_MARKER = 'yeaft-slice.ready';
|
|
6
9
|
const NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
10
|
+
const CPU_PERIOD_US = 100_000;
|
|
7
11
|
|
|
8
12
|
export class ContainerAgentError extends Error {
|
|
9
13
|
constructor(code, message = code) {
|
|
@@ -64,6 +68,8 @@ export function buildCreateArgs({
|
|
|
64
68
|
dataVolume,
|
|
65
69
|
workspaceVolume,
|
|
66
70
|
restart = 'unless-stopped',
|
|
71
|
+
cgroupParent,
|
|
72
|
+
diskSizeBytes,
|
|
67
73
|
}) {
|
|
68
74
|
const agentName = normalizeContainerAgentName(name);
|
|
69
75
|
if (!String(serverUrl || '').match(/^wss?:\/\//)) {
|
|
@@ -73,14 +79,29 @@ export function buildCreateArgs({
|
|
|
73
79
|
const containerName = containerNameForAgent(agentName);
|
|
74
80
|
const safeImage = String(image || '').trim();
|
|
75
81
|
if (!safeImage || safeImage.startsWith('-')) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_IMAGE');
|
|
76
|
-
|
|
82
|
+
const args = [
|
|
77
83
|
'create', '--name', containerName,
|
|
78
84
|
'--label', 'io.yeaft.container-agent=true',
|
|
79
85
|
'--label', `io.yeaft.agent-name=${agentName}`,
|
|
80
86
|
'--restart', restart,
|
|
81
87
|
'--init',
|
|
82
|
-
|
|
83
|
-
|
|
88
|
+
];
|
|
89
|
+
if (cgroupParent) {
|
|
90
|
+
const slice = String(cgroupParent).trim();
|
|
91
|
+
if (!slice) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_CGROUP_PARENT');
|
|
92
|
+
args.push('--cgroup-parent', slice);
|
|
93
|
+
}
|
|
94
|
+
let diskOpt = '';
|
|
95
|
+
if (diskSizeBytes) {
|
|
96
|
+
const size = Math.floor(Number(diskSizeBytes));
|
|
97
|
+
if (!Number.isFinite(size) || size <= 0) {
|
|
98
|
+
throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE');
|
|
99
|
+
}
|
|
100
|
+
diskOpt = `,volume-opt=size=${size}`;
|
|
101
|
+
}
|
|
102
|
+
args.push(
|
|
103
|
+
'--mount', `type=volume,src=${dataVolume || `${containerName}-data`},dst=/home/yeaft/.yeaft${diskOpt}`,
|
|
104
|
+
'--mount', `type=volume,src=${workspaceVolume || `${containerName}-workspace`},dst=/workspace${diskOpt}`,
|
|
84
105
|
'--mount', `type=bind,src=${resolve(secretFile)},dst=/run/yeaft-host-secret,readonly`,
|
|
85
106
|
'--env', `SERVER_URL=${serverUrl}`,
|
|
86
107
|
'--env', `AGENT_NAME=${agentName}`,
|
|
@@ -88,7 +109,8 @@ export function buildCreateArgs({
|
|
|
88
109
|
'--env', 'YEAFT_DIR=/home/yeaft/.yeaft',
|
|
89
110
|
'--env', 'WORK_DIR=/workspace',
|
|
90
111
|
safeImage,
|
|
91
|
-
|
|
112
|
+
);
|
|
113
|
+
return args;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
/**
|
|
@@ -182,3 +204,161 @@ export async function logsContainerAgent(name, { follow = false, ...runtime } =
|
|
|
182
204
|
args.push(containerNameForAgent(name));
|
|
183
205
|
return runDocker(args, { ...runtime, stdout: follow ? 'inherit' : 'pipe' });
|
|
184
206
|
}
|
|
207
|
+
|
|
208
|
+
function clampPercent(value, fallback) {
|
|
209
|
+
const n = Number(value);
|
|
210
|
+
const safe = Number.isFinite(n) ? n : fallback;
|
|
211
|
+
return Math.min(100, Math.max(1, safe));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Compute cgroup v2 slice limits from host resources. Pure function so the
|
|
216
|
+
* 90% CPU / 70% memory policy is directly testable.
|
|
217
|
+
*
|
|
218
|
+
* @param {object} policy cpuPercent (default 90), memoryPercent (default 70), pidsLimit (default 4096)
|
|
219
|
+
* @param {object} resources { cpuCores, memTotalBytes } from detectHostResources
|
|
220
|
+
* @returns {{cpuQuotaUs: number, cpuPeriodUs: number, memoryMaxBytes: number, pidsMax: number}}
|
|
221
|
+
*/
|
|
222
|
+
export function buildSliceLimits(
|
|
223
|
+
{ cpuPercent = 90, memoryPercent = 70, pidsLimit = 4096 } = {},
|
|
224
|
+
{ cpuCores = 0, memTotalBytes = 0 } = {},
|
|
225
|
+
) {
|
|
226
|
+
const cores = Math.max(1, Math.floor(Number(cpuCores) || 0) || 1);
|
|
227
|
+
const cpuPct = clampPercent(cpuPercent, 90);
|
|
228
|
+
const memPct = clampPercent(memoryPercent, 70);
|
|
229
|
+
return {
|
|
230
|
+
cpuQuotaUs: Math.floor((cores * cpuPct / 100) * CPU_PERIOD_US),
|
|
231
|
+
cpuPeriodUs: CPU_PERIOD_US,
|
|
232
|
+
memoryMaxBytes: Math.floor((Number(memTotalBytes) || 0) * memPct / 100),
|
|
233
|
+
pidsMax: Math.max(64, Math.floor(Number(pidsLimit) || 0) || 4096),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Detect host resources: logical CPU count plus MemTotal from /proc/meminfo.
|
|
239
|
+
* MemTotal may be 0 on non-Linux or unreadable /proc; callers must treat a
|
|
240
|
+
* zero memory limit as "no memory constraint" rather than OOM-ing containers.
|
|
241
|
+
*
|
|
242
|
+
* @param {object} options { readFileImpl, cpuCount } overrides for tests
|
|
243
|
+
* @returns {Promise<{cpuCores: number, memTotalBytes: number}>}
|
|
244
|
+
*/
|
|
245
|
+
export async function detectHostResources({ readFileImpl = readFile, cpuCount = cpus().length } = {}) {
|
|
246
|
+
let memTotalBytes = 0;
|
|
247
|
+
try {
|
|
248
|
+
const meminfo = await readFileImpl('/proc/meminfo', 'utf8');
|
|
249
|
+
const match = String(meminfo || '').match(/^MemTotal:\s+(\d+)\s*kB/m);
|
|
250
|
+
if (match) memTotalBytes = Number(match[1]) * 1024;
|
|
251
|
+
} catch {
|
|
252
|
+
// Non-Linux or restricted /proc; memory limiting is skipped downstream.
|
|
253
|
+
}
|
|
254
|
+
return { cpuCores: Math.max(1, Math.floor(Number(cpuCount) || 0) || 1), memTotalBytes };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Create or refresh a cgroup v2 slice that all Yeaft Agent containers share.
|
|
259
|
+
* Requires root (writes under /sys/fs/cgroup). Writes a ready marker so
|
|
260
|
+
* `container install` can refuse to create unprotected containers by default.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} options slice, cpuPercent, memoryPercent, pidsLimit,
|
|
263
|
+
* resources (pre-detected), fsImpl { mkdir, readFile, writeFile } for tests
|
|
264
|
+
* @returns {Promise<{slice: string, limits: object, controllers: string[]}>}
|
|
265
|
+
*/
|
|
266
|
+
export async function ensureAgentSlice({
|
|
267
|
+
slice = DEFAULT_AGENT_SLICE,
|
|
268
|
+
cpuPercent = 90,
|
|
269
|
+
memoryPercent = 70,
|
|
270
|
+
pidsLimit = 4096,
|
|
271
|
+
resources,
|
|
272
|
+
fsImpl = { mkdir, readFile, writeFile },
|
|
273
|
+
} = {}) {
|
|
274
|
+
const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
|
|
275
|
+
let controllers = [];
|
|
276
|
+
try {
|
|
277
|
+
const text = await fsImpl.readFile('/sys/fs/cgroup/cgroup.controllers', 'utf8');
|
|
278
|
+
controllers = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
279
|
+
} catch {
|
|
280
|
+
// Treat missing controller list as "no controllers writable".
|
|
281
|
+
}
|
|
282
|
+
const wanted = ['cpu', 'memory', 'pids'].filter(name => controllers.includes(name));
|
|
283
|
+
if (wanted.length === 0) {
|
|
284
|
+
throw new ContainerAgentError(
|
|
285
|
+
'CONTAINER_AGENT_CGROUP_UNAVAILABLE',
|
|
286
|
+
'cgroup v2 controllers (cpu/memory/pids) are unavailable; setup-limits requires a host running cgroup v2 and root access to /sys/fs/cgroup',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
await fsImpl.mkdir(slicePath, { recursive: true, mode: 0o755 });
|
|
290
|
+
await fsImpl.writeFile(
|
|
291
|
+
`${slicePath}/cgroup.subtree_control`,
|
|
292
|
+
wanted.map(name => `+${name}`).join(' '),
|
|
293
|
+
'utf8',
|
|
294
|
+
);
|
|
295
|
+
const detected = resources || await detectHostResources({ readFileImpl: fsImpl.readFile });
|
|
296
|
+
const limits = buildSliceLimits({ cpuPercent, memoryPercent, pidsLimit }, detected);
|
|
297
|
+
if (wanted.includes('cpu') && limits.cpuQuotaUs > 0) {
|
|
298
|
+
await fsImpl.writeFile(`${slicePath}/cpu.max`, `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`, 'utf8');
|
|
299
|
+
}
|
|
300
|
+
if (wanted.includes('memory') && limits.memoryMaxBytes > 0) {
|
|
301
|
+
await fsImpl.writeFile(`${slicePath}/memory.max`, String(limits.memoryMaxBytes), 'utf8');
|
|
302
|
+
// No swap escape hatch: the 70% RAM hard cap is the whole memory budget.
|
|
303
|
+
await fsImpl.writeFile(`${slicePath}/memory.swap.max`, '0', 'utf8');
|
|
304
|
+
}
|
|
305
|
+
if (wanted.includes('pids') && limits.pidsMax > 0) {
|
|
306
|
+
await fsImpl.writeFile(`${slicePath}/pids.max`, String(limits.pidsMax), 'utf8');
|
|
307
|
+
}
|
|
308
|
+
const marker = {
|
|
309
|
+
slice,
|
|
310
|
+
cpuQuotaUs: limits.cpuQuotaUs,
|
|
311
|
+
cpuPeriodUs: limits.cpuPeriodUs,
|
|
312
|
+
memoryMaxBytes: limits.memoryMaxBytes,
|
|
313
|
+
pidsMax: limits.pidsMax,
|
|
314
|
+
updatedAt: new Date().toISOString(),
|
|
315
|
+
};
|
|
316
|
+
await fsImpl.writeFile(`${slicePath}/${SLICE_READY_MARKER}`, JSON.stringify(marker, null, 2), 'utf8');
|
|
317
|
+
return { slice, limits, controllers: wanted };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether `container setup-limits` has initialized the given slice.
|
|
322
|
+
*
|
|
323
|
+
* @param {object} options slice, readFileImpl for tests
|
|
324
|
+
* @returns {Promise<boolean>}
|
|
325
|
+
*/
|
|
326
|
+
export async function isAgentSliceReady({ slice = DEFAULT_AGENT_SLICE, readFileImpl = readFile } = {}) {
|
|
327
|
+
const slicePath = `/sys/fs/cgroup/${String(slice).replace(/^\/+/, '')}`;
|
|
328
|
+
try {
|
|
329
|
+
await readFileImpl(`${slicePath}/${SLICE_READY_MARKER}`, 'utf8');
|
|
330
|
+
return true;
|
|
331
|
+
} catch {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const DISK_SIZE_RE = /^(\d+(?:\.\d+)?)\s*(%|[kmgt](?:i?b)?|b)?$/i;
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Resolve a disk size like "20G" or "80%" to bytes. Percentages are computed
|
|
340
|
+
* against the Docker data-root filesystem and require statfsImpl (node:fs
|
|
341
|
+
* statfs) plus the docker root path.
|
|
342
|
+
*
|
|
343
|
+
* @param {string|number} value
|
|
344
|
+
* @param {object} options { dockerRoot, statfsImpl } for percent resolution and tests
|
|
345
|
+
* @returns {Promise<number>}
|
|
346
|
+
*/
|
|
347
|
+
export async function resolveDiskSize(value, { dockerRoot, statfsImpl = statfs } = {}) {
|
|
348
|
+
const input = String(value ?? '').trim();
|
|
349
|
+
const match = input.match(DISK_SIZE_RE);
|
|
350
|
+
if (!match) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
|
|
351
|
+
const amount = Number(match[1]);
|
|
352
|
+
const unit = (match[2] || 'b').toLowerCase();
|
|
353
|
+
if (unit === 'b') return Math.floor(amount);
|
|
354
|
+
if (unit === '%') {
|
|
355
|
+
if (!dockerRoot) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', 'Percent disk size requires the Docker data-root');
|
|
356
|
+
const info = await statfsImpl(dockerRoot);
|
|
357
|
+
const totalBytes = Number(info.bsize) * Number(info.blocks);
|
|
358
|
+
return Math.floor(totalBytes * amount / 100);
|
|
359
|
+
}
|
|
360
|
+
const multipliers = { k: 1024, kb: 1024, kib: 1024, m: 1024 ** 2, mb: 1024 ** 2, mib: 1024 ** 2, g: 1024 ** 3, gb: 1024 ** 3, gib: 1024 ** 3, t: 1024 ** 4, tb: 1024 ** 4, tib: 1024 ** 4 };
|
|
361
|
+
const multiplier = multipliers[unit];
|
|
362
|
+
if (!multiplier) throw new ContainerAgentError('CONTAINER_AGENT_INVALID_DISK_SIZE', `Invalid disk size: ${input}`);
|
|
363
|
+
return Math.floor(amount * multiplier);
|
|
364
|
+
}
|
|
@@ -140,7 +140,11 @@ export const CONFIG = {
|
|
|
140
140
|
enabled: process.env.SANDBOX_ENABLED === 'true',
|
|
141
141
|
image: process.env.SANDBOX_AGENT_IMAGE || 'ghcr.io/yeaft/yeaft-web-code-agent-agent:dev',
|
|
142
142
|
serverUrl: process.env.SANDBOX_SERVER_URL || '',
|
|
143
|
-
stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents')
|
|
143
|
+
stateDir: process.env.SANDBOX_STATE_DIR || join(homedir(), '.yeaft', 'container-agents'),
|
|
144
|
+
// Shared cgroup slice (created by `yeaft-agent container setup-limits` on the
|
|
145
|
+
// Host) that caps the sum of all sandbox containers' CPU/memory usage. Empty
|
|
146
|
+
// means no cgroup-parent is passed and sandbox containers are unprotected.
|
|
147
|
+
cgroupParent: process.env.SANDBOX_CGROUP_PARENT || ''
|
|
144
148
|
},
|
|
145
149
|
|
|
146
150
|
// File upload settings
|
|
@@ -105,6 +105,7 @@ export class ContainerAgentService {
|
|
|
105
105
|
serverUrl: this.config.serverUrl,
|
|
106
106
|
secretFile,
|
|
107
107
|
image: this.config.image,
|
|
108
|
+
...(this.config.cgroupParent ? { cgroupParent: this.config.cgroupParent } : {}),
|
|
108
109
|
});
|
|
109
110
|
return { snapshot: await this.snapshot(user.id), replayed: false };
|
|
110
111
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.438"}
|
package/package.json
CHANGED