Skip to content

Commit 67102ec

Browse files
authored
fix(docker): allow swap and network configuration (#308)
* fix(docker): allow swap and network configuration - resolves memswap_limit from RAM as a finite RAM+swap ceiling - passes configurable Docker network settings into build and auth run commands - normalizes CRLF in skiller patch matching * fix(docker): support Windows auth and network flows * fix(core): address Windows path review feedback * test(core): cover project root path invariants
1 parent d2884b1 commit 67102ec

48 files changed

Lines changed: 1117 additions & 177 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,13 @@ project (or the controller itself) cannot consume the entire system.
147147
- **Per-project containers** ship with a default limit of `30%` CPU and
148148
`30%` RAM (resolved against the host on `apply`). Override via
149149
`--cpu` / `--ram` (or per-project `docker-git.json`).
150+
Docker Compose `memswap_limit` is resolved separately as the total
151+
RAM+swap ceiling, defaulting to twice the resolved RAM limit.
150152
- **Controller container** (`docker-git-api`) is capped in
151153
`docker-compose.yml` and `docker-compose.api.yml`. When started through
152154
`docker-git` or `./ctl`, the default CPU/RAM cap is resolved to `90%` of
153-
host resources. Override with global CLI flags:
155+
host resources and memory swap defaults to twice the resolved RAM limit.
156+
Override with global CLI flags:
154157

155158
```bash
156159
docker-git --controller-cpu 75% --controller-ram 8g --controller-pids 8192 ps
@@ -163,5 +166,6 @@ project (or the controller itself) cannot consume the entire system.
163166
| Variable | Default | Purpose |
164167
| ------------------------------ | ------- | ------------------------------------ |
165168
| `DOCKER_GIT_CONTROLLER_CPUS` | `90%` | CPU percent or cores for the controller |
166-
| `DOCKER_GIT_CONTROLLER_MEMORY` | `90%` | RAM percent or size; swap is matched |
169+
| `DOCKER_GIT_CONTROLLER_MEMORY` | `90%` | RAM percent or size for `mem_limit` |
170+
| `DOCKER_GIT_CONTROLLER_MEMORY_SWAP` | derived from RAM | Total RAM+swap size for `memswap_limit`; use Docker size units such as `16g` |
167171
| `DOCKER_GIT_CONTROLLER_PIDS` | `4096` | Maximum PIDs inside the controller |

docker-compose.api.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ services:
3939
restart: unless-stopped
4040
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
4141
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
42-
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
42+
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY_SWAP:-1842m}
4343
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}
4444

4545
volumes:

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ services:
4141
restart: unless-stopped
4242
cpus: ${DOCKER_GIT_CONTROLLER_CPUS:-0.9}
4343
mem_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
44-
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY:-921m}
44+
memswap_limit: ${DOCKER_GIT_CONTROLLER_MEMORY_SWAP:-1842m}
4545
pids_limit: ${DOCKER_GIT_CONTROLLER_PIDS:-4096}
4646

4747
volumes:

packages/app/src/docker-git/controller-resource-limits-shell.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Effect, Either } from "effect"
33
import {
44
controllerCpuLimitEnvKey,
55
controllerMemoryLimitEnvKey,
6+
controllerMemorySwapLimitEnvKey,
67
controllerPidsLimitEnvKey,
78
controllerResourceLimitsForceRecreateEnvKey,
89
resolveControllerResourceLimitEnv
@@ -78,6 +79,7 @@ export const prepareControllerResourceLimitEnv = (): Effect.Effect<void, Control
7879
Effect.sync(() => {
7980
process.env[controllerCpuLimitEnvKey] = resolved.right.cpus
8081
process.env[controllerMemoryLimitEnvKey] = resolved.right.memory
82+
process.env[controllerMemorySwapLimitEnvKey] = resolved.right.memorySwap
8183
process.env[controllerPidsLimitEnvKey] = resolved.right.pids
8284
})
8385
)

packages/app/src/docker-git/controller-resource-limits.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99

1010
export const controllerCpuLimitEnvKey = "DOCKER_GIT_CONTROLLER_CPUS"
1111
export const controllerMemoryLimitEnvKey = "DOCKER_GIT_CONTROLLER_MEMORY"
12+
export const controllerMemorySwapLimitEnvKey = "DOCKER_GIT_CONTROLLER_MEMORY_SWAP"
1213
export const controllerPidsLimitEnvKey = "DOCKER_GIT_CONTROLLER_PIDS"
1314
export const controllerResourceLimitsForceRecreateEnvKey = "DOCKER_GIT_CONTROLLER_RESOURCE_LIMITS_FORCE_RECREATE"
1415

@@ -34,6 +35,7 @@ export type ControllerResourceLimitIntent = {
3435
export type ControllerResourceLimitEnv = {
3536
readonly cpus: string
3637
readonly memory: string
38+
readonly memorySwap: string
3739
readonly pids: string
3840
}
3941

@@ -305,6 +307,7 @@ export const resolveControllerResourceLimitEnv = (
305307
return {
306308
cpus: String(resolved.cpuLimit),
307309
memory: resolved.ramLimit,
310+
memorySwap: resolved.swapLimit,
308311
pids: pidsLimit
309312
}
310313
})

packages/app/src/docker-git/frontend-lib/core/command-builders-shared.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,46 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3030
return Either.right(parsed)
3131
}
3232

33+
const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
34+
35+
const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\"
36+
37+
const rootPathLength = (value: string): number => {
38+
if (isPathSeparator(value[0])) {
39+
return 1
40+
}
41+
if (
42+
value.length >= 3 &&
43+
isAsciiLetterCode(value.codePointAt(0) ?? 0) &&
44+
value[1] === ":" &&
45+
isPathSeparator(value[2])
46+
) {
47+
return 3
48+
}
49+
return 0
50+
}
51+
52+
/**
53+
* Removes redundant trailing path separators while preserving filesystem roots.
54+
*
55+
* @param value - Path text decoded from CLI/config input.
56+
* @returns The input without trailing `/` or `\\` separators unless the input is a root path.
57+
* @pure true
58+
* @effect none; CORE helper only scans the provided string.
59+
* @invariant roots `/`, `\\`, `C:\\`, and `C:/` remain non-empty root paths.
60+
* @precondition value is a string and may be empty or contain mixed separators.
61+
* @postcondition non-root results do not end with `/` or `\\`; root results are preserved.
62+
* @complexity O(n) time / O(1) space where n = |value|.
63+
*/
64+
export const trimTrailingPathSeparators = (value: string): string => {
65+
let end = value.length
66+
const minEnd = rootPathLength(value)
67+
while (end > minEnd && isPathSeparator(value[end - 1])) {
68+
end -= 1
69+
}
70+
return value.slice(0, end)
71+
}
72+
3373
/**
3474
* Parses a raw SSH port value into the valid Docker host-port range.
3575
*

packages/app/src/docker-git/frontend-lib/core/command-builders.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
parseDockerNetworkMode,
99
parseGpuMode,
1010
parseSshPort,
11-
parseSshUser
11+
parseSshUser,
12+
trimTrailingPathSeparators
1213
} from "./command-builders-shared.js"
1314
import { buildTemplateConfig } from "./command-builders-template.js"
1415
import { type RawOptions } from "./command-options.js"
@@ -21,12 +22,11 @@ import {
2122
resolveRepoInput
2223
} from "./domain.js"
2324
import { resolveResourceLimitsIntent } from "./resource-limits.js"
24-
import { trimRightChar } from "./strings.js"
2525
import { normalizeAuthLabel, normalizeGitTokenLabel } from "./token-labels.js"
2626

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

29-
const normalizeSecretsRoot = (value: string): string => trimRightChar(value, "/")
29+
const normalizeSecretsRoot = trimTrailingPathSeparators
3030

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

118+
const joinSecretsRootPath = (root: string, child: string): string =>
119+
root.endsWith("/") || root.endsWith("\\") ? `${root}${child}` : `${root}/${child}`
120+
118121
const buildDefaultPathConfig = (
119122
normalizedSecretsRoot: string | undefined
120123
): DefaultPathConfig =>
@@ -133,11 +136,11 @@ const buildDefaultPathConfig = (
133136
// `.cache/git-mirrors` remain outside the secrets dir.
134137
dockerGitPath: defaultTemplateConfig.dockerGitPath,
135138
authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath,
136-
envGlobalPath: `${normalizedSecretsRoot}/global.env`,
139+
envGlobalPath: joinSecretsRootPath(normalizedSecretsRoot, "global.env"),
137140
envProjectPath: defaultTemplateConfig.envProjectPath,
138-
codexAuthPath: `${normalizedSecretsRoot}/codex`,
139-
geminiAuthPath: `${normalizedSecretsRoot}/gemini`,
140-
grokAuthPath: `${normalizedSecretsRoot}/grok`
141+
codexAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "codex"),
142+
geminiAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "gemini"),
143+
grokAuthPath: joinSecretsRootPath(normalizedSecretsRoot, "grok")
141144
}
142145

143146
const resolvePaths = (

packages/app/src/docker-git/frontend-lib/core/resource-limits.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
const mebibyte = 1024 ** 2
1515
const minimumResolvedCpuLimit = 0.25
1616
const minimumResolvedRamLimitMib = 512
17+
const minimumResolvedSwapLimitMib = 1
1718
const precisionScale = 100
1819

1920
type HostResources = {
@@ -24,12 +25,26 @@ type HostResources = {
2425
export type ResolvedComposeResourceLimits = {
2526
readonly cpuLimit: number
2627
readonly ramLimit: string
28+
readonly swapLimit: string
2729
}
2830

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

36+
const ramUnitMibFactors: Readonly<Record<string, number>> = {
37+
b: 1 / mebibyte,
38+
k: 1 / 1024,
39+
kb: 1 / 1024,
40+
m: 1,
41+
mb: 1,
42+
g: 1024,
43+
gb: 1024,
44+
t: 1024 * 1024,
45+
tb: 1024 * 1024
46+
}
47+
3348
const normalizePrecision = (value: number): number => Math.round(value * precisionScale) / precisionScale
3449

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

152+
const parseRamLimitMib = (value: string): number | null => {
153+
const match = ramLimitPattern.exec(value)
154+
if (match === null) {
155+
return null
156+
}
157+
158+
const amount = Number(match[1] ?? "0")
159+
const unit = (match[2] ?? "m").toLowerCase()
160+
const factor = ramUnitMibFactors[unit]
161+
return !Number.isFinite(amount) || amount <= 0 || factor === undefined
162+
? null
163+
: amount * factor
164+
}
165+
166+
// CHANGE: allow project containers to use WSL swap without removing hard RAM limits
167+
// WHY: Docker Compose `memswap_limit` is RAM+swap total; setting it equal to RAM disables extra swap
168+
// SOURCE: n/a
169+
// FORMAT THEOREM: forall r: valid_ram(r) -> swap_limit(r) >= 2 * ram_limit(r)
170+
// PURITY: CORE
171+
// INVARIANT: generated containers keep a finite memory+swap ceiling
172+
// COMPLEXITY: O(1)/O(1)
173+
const resolveSwapLimit = (ramLimit: string): string => {
174+
const ramMib = parseRamLimitMib(ramLimit)
175+
return ramMib === null
176+
? ramLimit
177+
: `${Math.max(minimumResolvedSwapLimitMib, Math.ceil(ramMib * 2))}m`
178+
}
179+
137180
export const resolveComposeResourceLimits = (
138181
template: Pick<TemplateConfig, "cpuLimit" | "ramLimit">,
139182
hostResources: HostResources
@@ -143,13 +186,16 @@ export const resolveComposeResourceLimits = (
143186
const cpuPercent = parsePercent(cpuLimitIntent)
144187
const ramPercent = parsePercent(ramLimitIntent)
145188

189+
const ramLimit = ramPercent === null
190+
? ramLimitIntent
191+
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
192+
146193
return {
147194
cpuLimit: cpuPercent === null
148195
? Number(cpuLimitIntent)
149196
: resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount),
150-
ramLimit: ramPercent === null
151-
? ramLimitIntent
152-
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
197+
ramLimit,
198+
swapLimit: resolveSwapLimit(ramLimit)
153199
}
154200
}
155201

packages/app/src/docker-git/frontend-lib/usecases/path-helpers.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ const expandHome = (value: string, home: string | null): string => {
3535
const trimTrailingSlash = (value: string): string => {
3636
let end = value.length
3737
while (end > 0) {
38+
if (end === 1 && value[0] === "/") {
39+
break
40+
}
41+
if (end === 3 && /^[a-z]:[\\/]/iu.test(value.slice(0, end))) {
42+
break
43+
}
3844
const char = value[end - 1]
3945
if (char !== "/" && char !== "\\") {
4046
break
@@ -44,14 +50,23 @@ const trimTrailingSlash = (value: string): string => {
4450
return value.slice(0, end)
4551
}
4652

53+
const homePathSeparator = (home: string): string => home.includes("\\") && !home.includes("/") ? "\\" : "/"
54+
55+
const joinHomePath = (home: string, child: string): string => {
56+
const root = trimTrailingSlash(home)
57+
return root.endsWith("/") || root.endsWith("\\")
58+
? `${root}${child}`
59+
: `${root}${homePathSeparator(root)}${child}`
60+
}
61+
4762
export const defaultProjectsRoot = (cwd: string): string => {
4863
const home = resolveHomeDir()
4964
const explicit = process.env["DOCKER_GIT_PROJECTS_ROOT"]?.trim()
5065
if (explicit && explicit.length > 0) {
5166
return expandHome(explicit, home)
5267
}
5368
if (home !== null) {
54-
return `${trimTrailingSlash(home)}/.docker-git`
69+
return joinHomePath(home, ".docker-git")
5570
}
5671
return `${cwd}/.docker-git`
5772
}

packages/app/src/lib/core/command-builders-shared.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,46 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3030
return Either.right(parsed)
3131
}
3232

33+
const isAsciiLetterCode = (code: number): boolean => (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
34+
35+
const isPathSeparator = (value: string | undefined): boolean => value === "/" || value === "\\"
36+
37+
const rootPathLength = (value: string): number => {
38+
if (isPathSeparator(value[0])) {
39+
return 1
40+
}
41+
if (
42+
value.length >= 3 &&
43+
isAsciiLetterCode(value.codePointAt(0) ?? 0) &&
44+
value[1] === ":" &&
45+
isPathSeparator(value[2])
46+
) {
47+
return 3
48+
}
49+
return 0
50+
}
51+
52+
/**
53+
* Removes redundant trailing path separators while preserving filesystem roots.
54+
*
55+
* @param value - Path text decoded from CLI/config input.
56+
* @returns The input without trailing `/` or `\\` separators unless the input is a root path.
57+
* @pure true
58+
* @effect none; CORE helper only scans the provided string.
59+
* @invariant roots `/`, `\\`, `C:\\`, and `C:/` remain non-empty root paths.
60+
* @precondition value is a string and may be empty or contain mixed separators.
61+
* @postcondition non-root results do not end with `/` or `\\`; root results are preserved.
62+
* @complexity O(n) time / O(1) space where n = |value|.
63+
*/
64+
export const trimTrailingPathSeparators = (value: string): string => {
65+
let end = value.length
66+
const minEnd = rootPathLength(value)
67+
while (end > minEnd && isPathSeparator(value[end - 1])) {
68+
end -= 1
69+
}
70+
return value.slice(0, end)
71+
}
72+
3373
/**
3474
* Parses a raw SSH port value into the valid Docker host-port range.
3575
*

0 commit comments

Comments
 (0)