Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/cap-controller-resources.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@prover-coder-ai/docker-git": patch
---

feat: cap controller container CPU, memory, and PID consumption

Adds default `cpus`, `mem_limit`, `memswap_limit`, and `pids_limit` to the
`docker-git-api` controller in `docker-compose.yml` and
`docker-compose.api.yml`. Each value is parameterized so operators can
override it via `DOCKER_GIT_CONTROLLER_CPUS`, `DOCKER_GIT_CONTROLLER_MEMORY`,
and `DOCKER_GIT_CONTROLLER_PIDS`, or via `--controller-cpu`,
`--controller-ram`, and `--controller-pids` on the host CLI. Defaults resolve
to 90% CPU, 90% RAM/swap, and 4096 PIDs. This complements the existing
per-project caps so a runaway controller cannot consume the entire host.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,30 @@ When the CLI cannot acquire Docker access it now prints a message that
names the specific failure mode, restates the host-Docker contract, and
lists remediation steps for that exact mode. Implementation lives in
`packages/app/src/docker-git/controller-docker-diagnostics.ts`.

## Resource limits

`docker-git` caps host resource consumption at two layers so a runaway
project (or the controller itself) cannot consume the entire system.

- **Per-project containers** ship with a default limit of `30%` CPU and
`30%` RAM (resolved against the host on `apply`). Override via
`--cpu` / `--ram` (or per-project `docker-git.json`).
- **Controller container** (`docker-git-api`) is capped in
`docker-compose.yml` and `docker-compose.api.yml`. When started through
`docker-git` or `./ctl`, the default CPU/RAM cap is resolved to `90%` of
host resources. Override with global CLI flags:

```bash
docker-git --controller-cpu 75% --controller-ram 8g --controller-pids 8192 ps
./ctl up --cpu 75% --ram 8g --pids 8192
```

The same values can be provided through env vars before `docker-git` or
`./ctl up`:

| Variable | Default | Purpose |
| ------------------------------ | ------- | ------------------------------------ |
| `DOCKER_GIT_CONTROLLER_CPUS` | `90%` | CPU percent or cores for the controller |
| `DOCKER_GIT_CONTROLLER_MEMORY` | `90%` | RAM percent or size; swap is matched |
| `DOCKER_GIT_CONTROLLER_PIDS` | `4096` | Maximum PIDs inside the controller |
86 changes: 85 additions & 1 deletion ctl
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ API_PORT="${DOCKER_GIT_API_PORT:-3334}"
API_HOST="${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}"
API_BASE_URL="http://127.0.0.1:${API_PORT}"
DOCKER_CMD=()
PARSED_ARGS=()
CONTROLLER_CPU_LIMIT="${DOCKER_GIT_CONTROLLER_CPUS:-}"
CONTROLLER_RAM_LIMIT="${DOCKER_GIT_CONTROLLER_MEMORY:-}"
CONTROLLER_PIDS_LIMIT="${DOCKER_GIT_CONTROLLER_PIDS:-}"

usage() {
cat <<'USAGE'
Usage: ./ctl <command>
Usage: ./ctl <command> [controller options]

Controller:
up Build and start the API controller
Expand All @@ -41,6 +45,11 @@ API:
./ctl request POST /projects '{"repoUrl":"https://github.com/org/repo.git"}'
./ctl request POST /projects/<projectId>/up

Controller options:
--cpu, --cpus, --controller-cpu <value> CPU cap intent, percent or cores (default: 90%)
--ram, --memory, --controller-ram <value> RAM cap intent, percent or size (default: 90%)
--pids, --controller-pids <n> PID cap (default: 4096)

USAGE
}

Expand All @@ -62,6 +71,76 @@ prepare_controller_revision() {
export DOCKER_GIT_CONTROLLER_REV="$revision"
}

parse_controller_limit_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--cpu|--cpus|--controller-cpu|--controller-cpus)
if [[ $# -lt 2 ]]; then
echo "Missing value for option: $1" >&2
exit 1
fi
CONTROLLER_CPU_LIMIT="$2"
shift 2
;;
--cpu=*|--cpus=*|--controller-cpu=*|--controller-cpus=*)
CONTROLLER_CPU_LIMIT="${1#*=}"
shift
;;
--ram|--memory|--controller-ram|--controller-memory)
if [[ $# -lt 2 ]]; then
echo "Missing value for option: $1" >&2
exit 1
fi
CONTROLLER_RAM_LIMIT="$2"
shift 2
;;
--ram=*|--memory=*|--controller-ram=*|--controller-memory=*)
CONTROLLER_RAM_LIMIT="${1#*=}"
shift
;;
--pids|--controller-pids)
if [[ $# -lt 2 ]]; then
echo "Missing value for option: $1" >&2
exit 1
fi
CONTROLLER_PIDS_LIMIT="$2"
shift 2
;;
--pids=*|--controller-pids=*)
CONTROLLER_PIDS_LIMIT="${1#*=}"
shift
;;
*)
PARSED_ARGS+=("$1")
shift
;;
esac
done
}

prepare_controller_resource_limits() {
local env_output
env_output="$(
DOCKER_GIT_CONTROLLER_CPUS="$CONTROLLER_CPU_LIMIT" \
DOCKER_GIT_CONTROLLER_MEMORY="$CONTROLLER_RAM_LIMIT" \
DOCKER_GIT_CONTROLLER_PIDS="$CONTROLLER_PIDS_LIMIT" \
bun --cwd "$ROOT/packages/app" scripts/print-controller-resource-env.ts
)"

local key
local value
while IFS='=' read -r key value; do
if [[ -z "$key" ]]; then
continue
fi
case "$key" in
DOCKER_GIT_CONTROLLER_CPUS|DOCKER_GIT_CONTROLLER_MEMORY|DOCKER_GIT_CONTROLLER_PIDS)
export "$key=$value"
;;
esac
done <<< "$env_output"
}

require_running() {
if ! "${DOCKER_CMD[@]}" ps --format '{{.Names}}' | grep -Fxq "$CONTAINER_NAME"; then
echo "Controller is not running. Start it with: ./ctl up" >&2
Expand Down Expand Up @@ -192,8 +271,12 @@ resolve_docker_cmd() {

resolve_docker_cmd

parse_controller_limit_args "$@"
set -- "${PARSED_ARGS[@]}"

case "${1:-}" in
up)
prepare_controller_resource_limits
prepare_controller_revision
compose up -d --build
wait_for_health
Expand All @@ -209,6 +292,7 @@ case "${1:-}" in
compose logs -f --tail=200
;;
restart)
prepare_controller_resource_limits
prepare_controller_revision
compose up -d --build --force-recreate
wait_for_health
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ services:
cgroup: host
init: true
restart: unless-stopped
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}

volumes:
docker_git_projects:
Expand Down
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ services:
cgroup: host
init: true
restart: unless-stopped
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}

volumes:
docker_git_projects:
Expand Down
67 changes: 67 additions & 0 deletions packages/app/scripts/print-controller-resource-env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NodeRuntime } from "@effect/platform-node"
import { Effect, Either } from "effect"

import {
controllerCpuLimitEnvKey,
controllerMemoryLimitEnvKey,
controllerPidsLimitEnvKey,
resolveControllerResourceLimitEnv
} from "../src/docker-git/controller-resource-limits.js"
import { formatParseError } from "../src/docker-git/frontend-lib/core/parse-errors.js"

const fallbackControllerHostResources = {
cpuCount: 1,
totalMemoryBytes: 1024 ** 3
}

const loadControllerHostResources = Effect.tryPromise({
try: () => import("node:os"),
catch: (error) => new Error(String(error))
}).pipe(
Effect.map((os) => ({
cpuCount: os.availableParallelism(),
totalMemoryBytes: os.totalmem()
})),
Effect.match({
onFailure: () => fallbackControllerHostResources,
onSuccess: (value) => value
})
)

const renderEnv = (
env: {
readonly cpus: string
readonly memory: string
readonly pids: string
}
): string =>
[
`${controllerCpuLimitEnvKey}=${env.cpus}`,
`${controllerMemoryLimitEnvKey}=${env.memory}`,
`${controllerPidsLimitEnvKey}=${env.pids}`
].join("\n")

const program = Effect.gen(function*(_) {
const hostResources = yield* _(loadControllerHostResources)
const resolved = resolveControllerResourceLimitEnv(
{
cpuLimit: process.env[controllerCpuLimitEnvKey],
ramLimit: process.env[controllerMemoryLimitEnvKey],
pidsLimit: process.env[controllerPidsLimitEnvKey]
},
hostResources
)

if (Either.isLeft(resolved)) {
return yield* _(Effect.fail(new Error(formatParseError(resolved.left))))
}

yield* _(
Effect.sync(() => {
process.stdout.write(renderEnv(resolved.right))
process.stdout.write("\n")
})
)
})

NodeRuntime.runMain(program)
37 changes: 37 additions & 0 deletions packages/app/src/docker-git/cli/read-command.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
import { Effect, Either, pipe } from "effect"

import {
controllerCpuLimitEnvKey,
controllerMemoryLimitEnvKey,
controllerPidsLimitEnvKey,
type ControllerResourceLimitArgParse,
controllerResourceLimitEnvAssignments,
controllerResourceLimitsForceRecreateEnvKey,
shouldForceRecreateForControllerResourceLimitIntent,
stripControllerResourceLimitArgs
} from "../controller-resource-limits.js"
import { type Command, type ParseError } from "../frontend-lib/core/domain.js"

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

const applyControllerResourceLimitEnv = (
parsed: ControllerResourceLimitArgParse
): Effect.Effect<ReadonlyArray<string>> =>
Effect.sync(() => {
const assignments = controllerResourceLimitEnvAssignments(parsed.controllerResourceLimits)
const forceRecreate = shouldForceRecreateForControllerResourceLimitIntent(parsed.controllerResourceLimits, {
cpuLimit: process.env[controllerCpuLimitEnvKey],
ramLimit: process.env[controllerMemoryLimitEnvKey],
pidsLimit: process.env[controllerPidsLimitEnvKey]
})

for (const assignment of assignments) {
process.env[assignment.key] = assignment.value
}

process.env[controllerResourceLimitsForceRecreateEnvKey] = forceRecreate ? "1" : "0"

return parsed.args
})

// CHANGE: read and parse CLI arguments from process.argv
// WHY: keep IO at the boundary and delegate parsing to CORE
// QUOTE(ТЗ): "Надо написать CLI команду"
Expand All @@ -16,6 +46,13 @@ import { parseArgs } from "./parser.js"
// COMPLEXITY: O(n) where n = |argv|
export const readCommand: Effect.Effect<Command, ParseError> = pipe(
Effect.sync(() => process.argv.slice(2)),
Effect.map((args) => stripControllerResourceLimitArgs(args)),
Effect.flatMap((result) =>
Either.match(result, {
onLeft: (error) => Effect.fail(error),
onRight: (parsed) => applyControllerResourceLimitEnv(parsed)
})
),
Effect.map((args) => parseArgs(args)),
Effect.flatMap((result) =>
Either.match(result, {
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ Options:
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
--cpu <value> CPU limit: percent or cores (examples: 30%, 1.5; default: 30%)
--ram <value> RAM limit: percent or size (examples: 30%, 512m, 4g; default: 30%)
--controller-cpu <value> Controller CPU cap intent: percent or cores (default: 90%)
--controller-ram <value> Controller RAM cap intent: percent or size (default: 90%)
--controller-pids <n> Controller PID cap (default: 4096)
--playwright-cpu <value> CPU limit for the MCP Playwright browser sidecar (default: 30% or --cpu when set)
--playwright-ram <value> RAM limit for the MCP Playwright browser sidecar (default: 30% or --ram when set)
--network-mode <mode> Compose network mode: shared|project (default: shared)
Expand Down
Loading