Skip to content

Commit 66bd05a

Browse files
authored
Merge pull request #263 from konard/issue-260-ab5543061ad7
feat(docker-git): make controller limits configurable
2 parents 307b1c2 + 328ea7b commit 66bd05a

12 files changed

Lines changed: 785 additions & 7 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@prover-coder-ai/docker-git": patch
3+
---
4+
5+
feat: cap controller container CPU, memory, and PID consumption
6+
7+
Adds default `cpus`, `mem_limit`, `memswap_limit`, and `pids_limit` to the
8+
`docker-git-api` controller in `docker-compose.yml` and
9+
`docker-compose.api.yml`. Each value is parameterized so operators can
10+
override it via `DOCKER_GIT_CONTROLLER_CPUS`, `DOCKER_GIT_CONTROLLER_MEMORY`,
11+
and `DOCKER_GIT_CONTROLLER_PIDS`, or via `--controller-cpu`,
12+
`--controller-ram`, and `--controller-pids` on the host CLI. Defaults resolve
13+
to 90% CPU, 90% RAM/swap, and 4096 PIDs. This complements the existing
14+
per-project caps so a runaway controller cannot consume the entire host.

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,30 @@ When the CLI cannot acquire Docker access it now prints a message that
113113
names the specific failure mode, restates the host-Docker contract, and
114114
lists remediation steps for that exact mode. Implementation lives in
115115
`packages/app/src/docker-git/controller-docker-diagnostics.ts`.
116+
117+
## Resource limits
118+
119+
`docker-git` caps host resource consumption at two layers so a runaway
120+
project (or the controller itself) cannot consume the entire system.
121+
122+
- **Per-project containers** ship with a default limit of `30%` CPU and
123+
`30%` RAM (resolved against the host on `apply`). Override via
124+
`--cpu` / `--ram` (or per-project `docker-git.json`).
125+
- **Controller container** (`docker-git-api`) is capped in
126+
`docker-compose.yml` and `docker-compose.api.yml`. When started through
127+
`docker-git` or `./ctl`, the default CPU/RAM cap is resolved to `90%` of
128+
host resources. Override with global CLI flags:
129+
130+
```bash
131+
docker-git --controller-cpu 75% --controller-ram 8g --controller-pids 8192 ps
132+
./ctl up --cpu 75% --ram 8g --pids 8192
133+
```
134+
135+
The same values can be provided through env vars before `docker-git` or
136+
`./ctl up`:
137+
138+
| Variable | Default | Purpose |
139+
| ------------------------------ | ------- | ------------------------------------ |
140+
| `DOCKER_GIT_CONTROLLER_CPUS` | `90%` | CPU percent or cores for the controller |
141+
| `DOCKER_GIT_CONTROLLER_MEMORY` | `90%` | RAM percent or size; swap is matched |
142+
| `DOCKER_GIT_CONTROLLER_PIDS` | `4096` | Maximum PIDs inside the controller |

ctl

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,14 @@ API_PORT="${DOCKER_GIT_API_PORT:-3334}"
1818
API_HOST="${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}"
1919
API_BASE_URL="http://127.0.0.1:${API_PORT}"
2020
DOCKER_CMD=()
21+
PARSED_ARGS=()
22+
CONTROLLER_CPU_LIMIT="${DOCKER_GIT_CONTROLLER_CPUS:-}"
23+
CONTROLLER_RAM_LIMIT="${DOCKER_GIT_CONTROLLER_MEMORY:-}"
24+
CONTROLLER_PIDS_LIMIT="${DOCKER_GIT_CONTROLLER_PIDS:-}"
2125

2226
usage() {
2327
cat <<'USAGE'
24-
Usage: ./ctl <command>
28+
Usage: ./ctl <command> [controller options]
2529
2630
Controller:
2731
up Build and start the API controller
@@ -41,6 +45,11 @@ API:
4145
./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}'
4246
./ctl request POST /projects/<projectId>/up
4347
48+
Controller options:
49+
--cpu, --cpus, --controller-cpu <value> CPU cap intent, percent or cores (default: 90%)
50+
--ram, --memory, --controller-ram <value> RAM cap intent, percent or size (default: 90%)
51+
--pids, --controller-pids <n> PID cap (default: 4096)
52+
4453
USAGE
4554
}
4655

@@ -62,6 +71,76 @@ prepare_controller_revision() {
6271
export DOCKER_GIT_CONTROLLER_REV="$revision"
6372
}
6473

74+
parse_controller_limit_args() {
75+
while [[ $# -gt 0 ]]; do
76+
case "$1" in
77+
--cpu|--cpus|--controller-cpu|--controller-cpus)
78+
if [[ $# -lt 2 ]]; then
79+
echo "Missing value for option: $1" >&2
80+
exit 1
81+
fi
82+
CONTROLLER_CPU_LIMIT="$2"
83+
shift 2
84+
;;
85+
--cpu=*|--cpus=*|--controller-cpu=*|--controller-cpus=*)
86+
CONTROLLER_CPU_LIMIT="${1#*=}"
87+
shift
88+
;;
89+
--ram|--memory|--controller-ram|--controller-memory)
90+
if [[ $# -lt 2 ]]; then
91+
echo "Missing value for option: $1" >&2
92+
exit 1
93+
fi
94+
CONTROLLER_RAM_LIMIT="$2"
95+
shift 2
96+
;;
97+
--ram=*|--memory=*|--controller-ram=*|--controller-memory=*)
98+
CONTROLLER_RAM_LIMIT="${1#*=}"
99+
shift
100+
;;
101+
--pids|--controller-pids)
102+
if [[ $# -lt 2 ]]; then
103+
echo "Missing value for option: $1" >&2
104+
exit 1
105+
fi
106+
CONTROLLER_PIDS_LIMIT="$2"
107+
shift 2
108+
;;
109+
--pids=*|--controller-pids=*)
110+
CONTROLLER_PIDS_LIMIT="${1#*=}"
111+
shift
112+
;;
113+
*)
114+
PARSED_ARGS+=("$1")
115+
shift
116+
;;
117+
esac
118+
done
119+
}
120+
121+
prepare_controller_resource_limits() {
122+
local env_output
123+
env_output="$(
124+
DOCKER_GIT_CONTROLLER_CPUS="$CONTROLLER_CPU_LIMIT" \
125+
DOCKER_GIT_CONTROLLER_MEMORY="$CONTROLLER_RAM_LIMIT" \
126+
DOCKER_GIT_CONTROLLER_PIDS="$CONTROLLER_PIDS_LIMIT" \
127+
bun --cwd "$ROOT/packages/app" scripts/print-controller-resource-env.ts
128+
)"
129+
130+
local key
131+
local value
132+
while IFS='=' read -r key value; do
133+
if [[ -z "$key" ]]; then
134+
continue
135+
fi
136+
case "$key" in
137+
DOCKER_GIT_CONTROLLER_CPUS|DOCKER_GIT_CONTROLLER_MEMORY|DOCKER_GIT_CONTROLLER_PIDS)
138+
export "$key=$value"
139+
;;
140+
esac
141+
done <<< "$env_output"
142+
}
143+
65144
require_running() {
66145
if ! "${DOCKER_CMD[@]}" ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then
67146
echo "Controller is not running. Start it with: ./ctl up" >&2
@@ -192,8 +271,12 @@ resolve_docker_cmd() {
192271

193272
resolve_docker_cmd
194273

274+
parse_controller_limit_args "$@"
275+
set -- "${PARSED_ARGS[@]}"
276+
195277
case "${1:-}" in
196278
up)
279+
prepare_controller_resource_limits
197280
prepare_controller_revision
198281
compose up -d --build
199282
wait_for_health
@@ -209,6 +292,7 @@ case "${1:-}" in
209292
compose logs -f --tail=200
210293
;;
211294
restart)
295+
prepare_controller_resource_limits
212296
prepare_controller_revision
213297
compose up -d --build --force-recreate
214298
wait_for_health

docker-compose.api.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ services:
3737
cgroup: host
3838
init: true
3939
restart: unless-stopped
40+
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
41+
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
42+
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
43+
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}
4044

4145
volumes:
4246
docker_git_projects:

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ services:
3939
cgroup: host
4040
init: true
4141
restart: unless-stopped
42+
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
43+
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
44+
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
45+
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}
4246

4347
volumes:
4448
docker_git_projects:
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NodeRuntime } from "@effect/platform-node"
2+
import { Effect, Either } from "effect"
3+
4+
import {
5+
controllerCpuLimitEnvKey,
6+
controllerMemoryLimitEnvKey,
7+
controllerPidsLimitEnvKey,
8+
resolveControllerResourceLimitEnv
9+
} from "../src/docker-git/controller-resource-limits.js"
10+
import { formatParseError } from "../src/docker-git/frontend-lib/core/parse-errors.js"
11+
12+
const fallbackControllerHostResources = {
13+
cpuCount: 1,
14+
totalMemoryBytes: 1024 ** 3
15+
}
16+
17+
const loadControllerHostResources = Effect.tryPromise({
18+
try: () => import("node:os"),
19+
catch: (error) => new Error(String(error))
20+
}).pipe(
21+
Effect.map((os) => ({
22+
cpuCount: os.availableParallelism(),
23+
totalMemoryBytes: os.totalmem()
24+
})),
25+
Effect.match({
26+
onFailure: () => fallbackControllerHostResources,
27+
onSuccess: (value) => value
28+
})
29+
)
30+
31+
const renderEnv = (
32+
env: {
33+
readonly cpus: string
34+
readonly memory: string
35+
readonly pids: string
36+
}
37+
): string =>
38+
[
39+
`${controllerCpuLimitEnvKey}=${env.cpus}`,
40+
`${controllerMemoryLimitEnvKey}=${env.memory}`,
41+
`${controllerPidsLimitEnvKey}=${env.pids}`
42+
].join("\n")
43+
44+
const program = Effect.gen(function*(_) {
45+
const hostResources = yield* _(loadControllerHostResources)
46+
const resolved = resolveControllerResourceLimitEnv(
47+
{
48+
cpuLimit: process.env[controllerCpuLimitEnvKey],
49+
ramLimit: process.env[controllerMemoryLimitEnvKey],
50+
pidsLimit: process.env[controllerPidsLimitEnvKey]
51+
},
52+
hostResources
53+
)
54+
55+
if (Either.isLeft(resolved)) {
56+
return yield* _(Effect.fail(new Error(formatParseError(resolved.left))))
57+
}
58+
59+
yield* _(
60+
Effect.sync(() => {
61+
process.stdout.write(renderEnv(resolved.right))
62+
process.stdout.write("\n")
63+
})
64+
)
65+
})
66+
67+
NodeRuntime.runMain(program)

packages/app/src/docker-git/cli/read-command.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,39 @@
11
import { Effect, Either, pipe } from "effect"
22

3+
import {
4+
controllerCpuLimitEnvKey,
5+
controllerMemoryLimitEnvKey,
6+
controllerPidsLimitEnvKey,
7+
type ControllerResourceLimitArgParse,
8+
controllerResourceLimitEnvAssignments,
9+
controllerResourceLimitsForceRecreateEnvKey,
10+
shouldForceRecreateForControllerResourceLimitIntent,
11+
stripControllerResourceLimitArgs
12+
} from "../controller-resource-limits.js"
313
import { type Command, type ParseError } from "../frontend-lib/core/domain.js"
414

515
import { parseArgs } from "./parser.js"
616

17+
const applyControllerResourceLimitEnv = (
18+
parsed: ControllerResourceLimitArgParse
19+
): Effect.Effect<ReadonlyArray<string>> =>
20+
Effect.sync(() => {
21+
const assignments = controllerResourceLimitEnvAssignments(parsed.controllerResourceLimits)
22+
const forceRecreate = shouldForceRecreateForControllerResourceLimitIntent(parsed.controllerResourceLimits, {
23+
cpuLimit: process.env[controllerCpuLimitEnvKey],
24+
ramLimit: process.env[controllerMemoryLimitEnvKey],
25+
pidsLimit: process.env[controllerPidsLimitEnvKey]
26+
})
27+
28+
for (const assignment of assignments) {
29+
process.env[assignment.key] = assignment.value
30+
}
31+
32+
process.env[controllerResourceLimitsForceRecreateEnvKey] = forceRecreate ? "1" : "0"
33+
34+
return parsed.args
35+
})
36+
737
// CHANGE: read and parse CLI arguments from process.argv
838
// WHY: keep IO at the boundary and delegate parsing to CORE
939
// QUOTE(ТЗ): "Надо написать CLI команду"
@@ -16,6 +46,13 @@ import { parseArgs } from "./parser.js"
1646
// COMPLEXITY: O(n) where n = |argv|
1747
export const readCommand: Effect.Effect<Command, ParseError> = pipe(
1848
Effect.sync(() => process.argv.slice(2)),
49+
Effect.map((args) => stripControllerResourceLimitArgs(args)),
50+
Effect.flatMap((result) =>
51+
Either.match(result, {
52+
onLeft: (error) => Effect.fail(error),
53+
onRight: (parsed) => applyControllerResourceLimitEnv(parsed)
54+
})
55+
),
1956
Effect.map((args) => parseArgs(args)),
2057
Effect.flatMap((result) =>
2158
Either.match(result, {

packages/app/src/docker-git/cli/usage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ Options:
5454
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
5555
--cpu <value> CPU limit: percent or cores (examples: 30%, 1.5; default: 30%)
5656
--ram <value> RAM limit: percent or size (examples: 30%, 512m, 4g; default: 30%)
57+
--controller-cpu <value> Controller CPU cap intent: percent or cores (default: 90%)
58+
--controller-ram <value> Controller RAM cap intent: percent or size (default: 90%)
59+
--controller-pids <n> Controller PID cap (default: 4096)
5760
--playwright-cpu <value> CPU limit for the MCP Playwright browser sidecar (default: 30% or --cpu when set)
5861
--playwright-ram <value> RAM limit for the MCP Playwright browser sidecar (default: 30% or --ram when set)
5962
--gpu <mode> GPU mode for the workspace container: none|all (default: none)

0 commit comments

Comments
 (0)