diff --git a/.changeset/cap-controller-resources.md b/.changeset/cap-controller-resources.md new file mode 100644 index 00000000..d313529e --- /dev/null +++ b/.changeset/cap-controller-resources.md @@ -0,0 +1,13 @@ +--- +"@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`. Defaults: 2 CPUs, 4 GiB RAM/swap, 4096 PIDs. +This complements the existing per-project caps so a runaway controller +cannot consume the entire host. diff --git a/README.md b/README.md index 44c37fe3..a8d2d7cd 100644 --- a/README.md +++ b/README.md @@ -113,3 +113,21 @@ 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`. Override via + environment variables before `./ctl up`: + + | Variable | Default | Purpose | + | ------------------------------ | ------- | ------------------------------------ | + | `DOCKER_GIT_CONTROLLER_CPUS` | `2.0` | Maximum CPU cores for the controller | + | `DOCKER_GIT_CONTROLLER_MEMORY` | `4g` | Memory + swap cap (matched values) | + | `DOCKER_GIT_CONTROLLER_PIDS` | `4096` | Maximum PIDs inside the controller | diff --git a/docker-compose.api.yml b/docker-compose.api.yml index bee3eb1b..f2031bb8 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -37,6 +37,10 @@ services: cgroup: host init: true restart: unless-stopped + cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-2.0} + mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-4g} + memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-4g} + pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096} volumes: docker_git_projects: diff --git a/docker-compose.yml b/docker-compose.yml index e955aff2..ae608961 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,10 @@ services: cgroup: host init: true restart: unless-stopped + cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-2.0} + mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-4g} + memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-4g} + pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096} volumes: docker_git_projects: diff --git a/packages/app/tests/docker-git/controller-resource-limits.test.ts b/packages/app/tests/docker-git/controller-resource-limits.test.ts new file mode 100644 index 00000000..36e01579 --- /dev/null +++ b/packages/app/tests/docker-git/controller-resource-limits.test.ts @@ -0,0 +1,42 @@ +import { NodeContext } from "@effect/platform-node" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +const composeFiles: ReadonlyArray = ["docker-compose.yml", "docker-compose.api.yml"] + +const readComposeFile = (relativePath: string): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + return yield* _(fs.readFileString(path.join("..", "..", relativePath))) + }).pipe( + Effect.provide(NodeContext.layer), + Effect.orDie + ) + +describe("controller compose resource limits", () => { + for (const composeFile of composeFiles) { + describe(composeFile, () => { + it.effect("caps controller CPU usage", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toMatch(/cpus: \$\{DOCKER_GIT_CONTROLLER_CPUS:-\d+(?:\.\d+)?\}/u) + })) + + it.effect("caps controller memory and swap together", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toMatch(/mem_limit: \$\{DOCKER_GIT_CONTROLLER_MEMORY:-\d+[a-zA-Z]+\}/u) + expect(contents).toMatch(/memswap_limit: \$\{DOCKER_GIT_CONTROLLER_MEMORY:-\d+[a-zA-Z]+\}/u) + })) + + it.effect("caps controller PIDs to prevent fork bombs", () => + Effect.gen(function*(_) { + const contents = yield* _(readComposeFile(composeFile)) + expect(contents).toMatch(/pids_limit: \$\{DOCKER_GIT_CONTROLLER_PIDS:-\d+\}/u) + })) + }) + } +})