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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,13 @@ 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`).
Docker Compose `memswap_limit` is resolved separately as the total
RAM+swap ceiling, defaulting to twice the resolved RAM limit.
- **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:
host resources and memory swap defaults to twice the resolved RAM limit.
Override with global CLI flags:

```bash
docker-git --controller-cpu 75% --controller-ram 8g --controller-pids 8192 ps
Expand All @@ -163,5 +166,6 @@ project (or the controller itself) cannot consume the entire system.
| 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_MEMORY` | `90%` | RAM percent or size for `mem_limit` |
| `DOCKER_GIT_CONTROLLER_MEMORY_SWAP` | derived from RAM | Total RAM+swap size for `memswap_limit`; use Docker size units such as `16g` |
| `DOCKER_GIT_CONTROLLER_PIDS` | `4096` | Maximum PIDs inside the controller |
2 changes: 1 addition & 1 deletion docker-compose.api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ services:
restart: unless-stopped
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY_SWAP:-1842m}
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}

volumes:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ services:
restart: unless-stopped
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY_SWAP:-1842m}
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}

volumes:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Effect, Either } from "effect"
import {
controllerCpuLimitEnvKey,
controllerMemoryLimitEnvKey,
controllerMemorySwapLimitEnvKey,
controllerPidsLimitEnvKey,
controllerResourceLimitsForceRecreateEnvKey,
resolveControllerResourceLimitEnv
Expand Down Expand Up @@ -78,6 +79,7 @@ export const prepareControllerResourceLimitEnv = (): Effect.Effect<void, Control
Effect.sync(() => {
process.env[controllerCpuLimitEnvKey] = resolved.right.cpus
process.env[controllerMemoryLimitEnvKey] = resolved.right.memory
process.env[controllerMemorySwapLimitEnvKey] = resolved.right.memorySwap
process.env[controllerPidsLimitEnvKey] = resolved.right.pids
})
)
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/docker-git/controller-resource-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {

export const controllerCpuLimitEnvKey = "DOCKER_GIT_CONTROLLER_CPUS"
export const controllerMemoryLimitEnvKey = "DOCKER_GIT_CONTROLLER_MEMORY"
export const controllerMemorySwapLimitEnvKey = "DOCKER_GIT_CONTROLLER_MEMORY_SWAP"
export const controllerPidsLimitEnvKey = "DOCKER_GIT_CONTROLLER_PIDS"
export const controllerResourceLimitsForceRecreateEnvKey = "DOCKER_GIT_CONTROLLER_RESOURCE_LIMITS_FORCE_RECREATE"

Expand All @@ -34,6 +35,7 @@ export type ControllerResourceLimitIntent = {
export type ControllerResourceLimitEnv = {
readonly cpus: string
readonly memory: string
readonly memorySwap: string
readonly pids: string
}

Expand Down Expand Up @@ -305,6 +307,7 @@ export const resolveControllerResourceLimitEnv = (
return {
cpus: String(resolved.cpuLimit),
memory: resolved.ramLimit,
memorySwap: resolved.swapLimit,
pids: pidsLimit
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,46 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
return Either.right(parsed)
}

const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122)

const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\"

const rootPathLength = (value: string): number => {
if (isPathSeparator(value[0])) {
return 1
}
if (
value.length >= 3 &&
isAsciiLetterCode(value.codePointAt(0) ?? 0) &&
value[1] === ":" &&
isPathSeparator(value[2])
) {
return 3
}
return 0
}

/**
* Removes redundant trailing path separators while preserving filesystem roots.
*
* @param value - Path text decoded from CLI/config input.
* @returns The input without trailing `/` or `\\` separators unless the input is a root path.
* @pure true
* @effect none; CORE helper only scans the provided string.
* @invariant roots `/`, `\\`, `C:\\`, and `C:/` remain non-empty root paths.
* @precondition value is a string and may be empty or contain mixed separators.
* @postcondition non-root results do not end with `/` or `\\`; root results are preserved.
* @complexity O(n) time / O(1) space where n = |value|.
*/
export const trimTrailingPathSeparators = (value: string): string => {
let end = value.length
const minEnd = rootPathLength(value)
while (end > minEnd && isPathSeparator(value[end - 1])) {
end -= 1
}
return value.slice(0, end)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Parses a raw SSH port value into the valid Docker host-port range.
*
Expand Down
17 changes: 10 additions & 7 deletions packages/app/src/docker-git/frontend-lib/core/command-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
parseDockerNetworkMode,
parseGpuMode,
parseSshPort,
parseSshUser
parseSshUser,
trimTrailingPathSeparators
} from "./command-builders-shared.js"
import { buildTemplateConfig } from "./command-builders-template.js"
import { type RawOptions } from "./command-options.js"
Expand All @@ -21,12 +22,11 @@ import {
resolveRepoInput
} from "./domain.js"
import { resolveResourceLimitsIntent } from "./resource-limits.js"
import { trimRightChar } from "./strings.js"
import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js"

export { nonEmpty } from "./command-builders-shared.js"

const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/")
const normalizeSecretsRoot = trimTrailingPathSeparators

export type RepoBasics = {
readonly repoUrl: string
Expand Down Expand Up @@ -115,6 +115,9 @@ const resolveNormalizedSecretsRoot = (value: string | undefined): string | undef
return trimmed.length === 0 ? undefined : normalizeSecretsRoot(trimmed)
}

const joinSecretsRootPath = (root: string, child: string): string =>
root.endsWith("/") || root.endsWith("\\") ? `${root}${child}` : `${root}/${child}`

const buildDefaultPathConfig = (
normalizedSecretsRoot: string | undefined
): DefaultPathConfig =>
Expand All @@ -133,11 +136,11 @@ const buildDefaultPathConfig = (
// `.cache/git-mirrors` remain outside the secrets dir.
dockerGitPath: defaultTemplateConfig.dockerGitPath,
authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath,
envGlobalPath: `${normalizedSecretsRoot}/global.env`,
envGlobalPath: joinSecretsRootPath(normalizedSecretsRoot, "global.env"),
envProjectPath: defaultTemplateConfig.envProjectPath,
codexAuthPath: `${normalizedSecretsRoot}/codex`,
geminiAuthPath: `${normalizedSecretsRoot}/gemini`,
grokAuthPath: `${normalizedSecretsRoot}/grok`
codexAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "codex"),
geminiAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "gemini"),
grokAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "grok")
}

const resolvePaths = (
Expand Down
52 changes: 49 additions & 3 deletions packages/app/src/docker-git/frontend-lib/core/resource-limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
const mebibyte = 1024 ** 2
const minimumResolvedCpuLimit = 0.25
const minimumResolvedRamLimitMib = 512
const minimumResolvedSwapLimitMib = 1
const precisionScale = 100

type HostResources = {
Expand All @@ -24,12 +25,26 @@ type HostResources = {
export type ResolvedComposeResourceLimits = {
readonly cpuLimit: number
readonly ramLimit: string
readonly swapLimit: string
}

const cpuAbsolutePattern = /^\d+(?:\.\d+)?$/u
const ramLimitPattern = /^(\d+(?:\.\d+)?)(b|k|kb|m|mb|g|gb|t|tb)$/iu
const ramAbsolutePattern = /^\d+(?:\.\d+)?(?:b|k|kb|m|mb|g|gb|t|tb)$/iu
const percentPattern = /^\d+(?:\.\d+)?%$/u

const ramUnitMibFactors: Readonly<Record<string, number>> = {
b: 1 / mebibyte,
k: 1 / 1024,
kb: 1 / 1024,
m: 1,
mb: 1,
g: 1024,
gb: 1024,
t: 1024 * 1024,
tb: 1024 * 1024
}

const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale

const missingLimit = (): string | undefined => undefined
Expand Down Expand Up @@ -134,6 +149,34 @@ const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): stri
return `${targetMib}m`
}

const parseRamLimitMib = (value: string): number | null => {
const match = ramLimitPattern.exec(value)
if (match === null) {
return null
}

const amount = Number(match[1] ?? "0")
const unit = (match[2] ?? "m").toLowerCase()
const factor = ramUnitMibFactors[unit]
return !Number.isFinite(amount) || amount <= 0 || factor === undefined
? null
: amount * factor
}

// CHANGE: allow project containers to use WSL swap without removing hard RAM limits
// WHY: Docker Compose `memswap_limit` is RAM+swap total; setting it equal to RAM disables extra swap
// SOURCE: n/a
// FORMAT THEOREM: forall r: valid_ram(r) -> swap_limit(r) >= 2 * ram_limit(r)
// PURITY: CORE
// INVARIANT: generated containers keep a finite memory+swap ceiling
// COMPLEXITY: O(1)/O(1)
const resolveSwapLimit = (ramLimit: string): string => {
const ramMib = parseRamLimitMib(ramLimit)
return ramMib === null
? ramLimit
: `${Math.max(minimumResolvedSwapLimitMib, Math.ceil(ramMib * 2))}m`
}

export const resolveComposeResourceLimits = (
template: Pick<TemplateConfig, "cpuLimit" | "ramLimit">,
hostResources: HostResources
Expand All @@ -143,13 +186,16 @@ export const resolveComposeResourceLimits = (
const cpuPercent = parsePercent(cpuLimitIntent)
const ramPercent = parsePercent(ramLimitIntent)

const ramLimit = ramPercent === null
? ramLimitIntent
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)

return {
cpuLimit: cpuPercent === null
? Number(cpuLimitIntent)
: resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount),
ramLimit: ramPercent === null
? ramLimitIntent
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
ramLimit,
swapLimit: resolveSwapLimit(ramLimit)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ const expandHome = (value: string, home: string | null): string => {
const trimTrailingSlash = (value: string): string => {
let end = value.length
while (end > 0) {
if (end === 1 && value[0] === "/") {
break
}
if (end === 3 && /^[a-z]:[\\/]/iu.test(value.slice(0, end))) {
break
}
const char = value[end - 1]
if (char !== "/" && char !== "\\") {
break
Expand All @@ -44,14 +50,23 @@ const trimTrailingSlash = (value: string): string => {
return value.slice(0, end)
}

const homePathSeparator = (home: string): string => home.includes("\\") && !home.includes("/") ? "\\" : "/"

const joinHomePath = (home: string, child: string): string => {
const root = trimTrailingSlash(home)
return root.endsWith("/") || root.endsWith("\\")
? `${root}${child}`
: `${root}${homePathSeparator(root)}${child}`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export const defaultProjectsRoot = (cwd: string): string => {
const home = resolveHomeDir()
const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim()
if (explicit && explicit.length > 0) {
return expandHome(explicit, home)
}
if (home !== null) {
return `${trimTrailingSlash(home)}/.docker-git`
return joinHomePath(home, ".docker-git")
}
return `${cwd}/.docker-git`
}
Expand Down
40 changes: 40 additions & 0 deletions packages/app/src/lib/core/command-builders-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,46 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
return Either.right(parsed)
}

const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122)

const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\"

const rootPathLength = (value: string): number => {
if (isPathSeparator(value[0])) {
return 1
}
if (
value.length >= 3 &&
isAsciiLetterCode(value.codePointAt(0) ?? 0) &&
value[1] === ":" &&
isPathSeparator(value[2])
) {
return 3
}
return 0
}

/**
* Removes redundant trailing path separators while preserving filesystem roots.
*
* @param value - Path text decoded from CLI/config input.
* @returns The input without trailing `/` or `\\` separators unless the input is a root path.
* @pure true
* @effect none; CORE helper only scans the provided string.
* @invariant roots `/`, `\\`, `C:\\`, and `C:/` remain non-empty root paths.
* @precondition value is a string and may be empty or contain mixed separators.
* @postcondition non-root results do not end with `/` or `\\`; root results are preserved.
* @complexity O(n) time / O(1) space where n = |value|.
*/
export const trimTrailingPathSeparators = (value: string): string => {
let end = value.length
const minEnd = rootPathLength(value)
while (end > minEnd && isPathSeparator(value[end - 1])) {
end -= 1
}
return value.slice(0, end)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Parses a raw SSH port value into the valid Docker host-port range.
*
Expand Down
Loading
Loading