Skip to content

Commit aba08da

Browse files
committed
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
1 parent 02916b9 commit aba08da

14 files changed

Lines changed: 200 additions & 37 deletions

File tree

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/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/lib/core/templates/docker-compose.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const renderAgentAutoEnv = (agentAuto: boolean | undefined): string =>
7373
const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string =>
7474
resourceLimits === undefined
7575
? ""
76-
: ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n`
76+
: ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.swapLimit}"\n`
7777

7878
const renderGpu = (gpu: TemplateConfig["gpu"]): string =>
7979
gpu === "all"
@@ -121,7 +121,7 @@ const buildPlaywrightFragments = (
121121

122122
const isResolvedComposeResourceLimits = (
123123
value: ResolvedComposeResourceLimits | ComposeResourceLimits
124-
): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value
124+
): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value
125125

126126
const normalizeComposeResourceLimits = (
127127
resourceLimits: ResolvedComposeResourceLimits | ComposeResourceLimits | undefined

packages/app/src/lib/shell/docker-auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export type DockerAuthSpec = {
1414
readonly cwd: string
1515
readonly image: string
1616
readonly volume: DockerVolume
17+
readonly network?: string
1718
readonly entrypoint?: string
1819
readonly user?: string
1920
readonly env?: string | ReadonlyArray<string>
@@ -197,6 +198,10 @@ const appendEnvArgs = (base: Array<string>, env: string | ReadonlyArray<string>)
197198

198199
const buildDockerArgs = (spec: DockerAuthSpec): ReadonlyArray<string> => {
199200
const base: Array<string> = ["run", "--rm"]
201+
const dockerNetwork = (spec.network ?? resolveDockerEnvValue("DOCKER_GIT_AUTH_DOCKER_NETWORK") ?? "host").trim()
202+
if (dockerNetwork.length > 0) {
203+
base.push("--network", dockerNetwork)
204+
}
200205
const dockerUser = (spec.user ?? "").trim() || resolveDefaultDockerUser()
201206
if (dockerUser !== null) {
202207
base.push("--user", dockerUser)

packages/app/src/lib/usecases/docker-image.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type DockerImageSpec = {
1515
readonly imageDir: string
1616
readonly dockerfile: string
1717
readonly buildLabel: string
18+
readonly buildNetwork?: string
1819
}
1920

2021
// CHANGE: ensure a docker image is available locally
@@ -66,9 +67,10 @@ export const ensureDockerImage = (
6667
yield* _(fs.makeDirectory(imagePath, { recursive: true }))
6768
yield* _(fs.writeFileString(dockerfilePath, spec.dockerfile))
6869
yield* _(Effect.log(`Building ${spec.buildLabel} image (${spec.imageName})...`))
70+
const networkArgs = spec.buildNetwork === undefined ? [] : ["--network", spec.buildNetwork]
6971
yield* _(
7072
runCommandWithExitCodes(
71-
{ cwd, command: "docker", args: ["build", "-t", spec.imageName, imagePath] },
73+
{ cwd, command: "docker", args: ["build", ...networkArgs, "-t", spec.imageName, imagePath] },
7274
[0],
7375
(exitCode) => new CommandFailedError({ command: `docker build (${spec.buildLabel})`, exitCode })
7476
)

packages/app/src/lib/usecases/github-auth-image.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,16 @@ export const ghImageDir = ".docker-git/.orch/auth/gh/.image"
1616
export const renderGhDockerfile = (): string =>
1717
String.raw`FROM ubuntu:24.04
1818
ENV DEBIAN_FRONTEND=noninteractive
19-
RUN apt-get update \
20-
&& apt-get install -y --no-install-recommends ca-certificates curl gnupg bsdutils \
19+
RUN apt-get -o Acquire::Retries=3 update \
20+
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl gnupg bsdutils \
2121
&& mkdir -p /etc/apt/keyrings \
22-
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
22+
&& curl -fsSL --retry 5 --retry-all-errors --retry-delay 2 https://cli.github.com/packages/githubcli-archive-keyring.gpg \
2323
| gpg --dearmor -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
2424
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
2525
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
2626
> /etc/apt/sources.list.d/github-cli.list \
27-
&& apt-get update \
28-
&& apt-get install -y --no-install-recommends gh git \
27+
&& apt-get -o Acquire::Retries=3 update \
28+
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends gh git \
2929
&& rm -rf /var/lib/apt/lists/*
3030
ENTRYPOINT ["gh"]
3131
`
@@ -50,6 +50,7 @@ export const ensureGhAuthImage = (
5050
imageName: ghImageName,
5151
imageDir: ghImageDir,
5252
dockerfile: renderGhDockerfile(),
53-
buildLabel
53+
buildLabel,
54+
buildNetwork: "host"
5455
})
5556
/* jscpd:ignore-end */

packages/app/src/lib/usecases/gitlab-auth-image.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const glabPackageBaseUrl = `https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/
1818
export const renderGlabDockerfile = (): string =>
1919
String.raw`FROM ubuntu:24.04
2020
ENV DEBIAN_FRONTEND=noninteractive
21-
RUN apt-get update \
22-
&& apt-get install -y --no-install-recommends ca-certificates curl git bsdutils \
21+
RUN apt-get -o Acquire::Retries=3 update \
22+
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl git bsdutils \
2323
&& ARCH="$(dpkg --print-architecture)" \
2424
&& case "$ARCH" in \
2525
amd64) GLAB_ARCH="amd64" ;; \
@@ -52,5 +52,6 @@ export const ensureGlabAuthImage = (
5252
imageName: gitlabImageName,
5353
imageDir: gitlabImageDir,
5454
dockerfile: renderGlabDockerfile(),
55-
buildLabel
55+
buildLabel,
56+
buildNetwork: "host"
5657
})

packages/lib/src/core/resource-limits.ts

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

1819
type HostResources = {
@@ -23,12 +24,26 @@ type HostResources = {
2324
export type ResolvedComposeResourceLimits = {
2425
readonly cpuLimit: number
2526
readonly ramLimit: string
27+
readonly swapLimit: string
2628
}
2729

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

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

3449
const missingLimit = (): string | undefined => undefined
@@ -133,6 +148,34 @@ const resolvePercentRamLimit = (percent: number, totalMemoryBytes: number): stri
133148
return `${targetMib}m`
134149
}
135150

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

188+
const ramLimit = ramPercent === null
189+
? ramLimitIntent
190+
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
191+
145192
return {
146193
cpuLimit: cpuPercent === null
147194
? Number(cpuLimitIntent)
148195
: resolvePercentCpuLimit(cpuPercent, hostResources.cpuCount),
149-
ramLimit: ramPercent === null
150-
? ramLimitIntent
151-
: resolvePercentRamLimit(ramPercent, hostResources.totalMemoryBytes)
196+
ramLimit,
197+
swapLimit: resolveSwapLimit(ramLimit)
152198
}
153199
}
154200

packages/lib/src/core/templates/docker-compose.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const renderAgentAutoEnv = (agentAuto: boolean | undefined): string =>
7272
const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string =>
7373
resourceLimits === undefined
7474
? ""
75-
: ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.ramLimit}"\n`
75+
: ` cpus: ${resourceLimits.cpuLimit}\n mem_limit: "${resourceLimits.ramLimit}"\n memswap_limit: "${resourceLimits.swapLimit}"\n`
7676

7777
const renderGpu = (gpu: TemplateConfig["gpu"]): string =>
7878
gpu === "all"
@@ -120,7 +120,7 @@ const buildPlaywrightFragments = (
120120

121121
const isResolvedComposeResourceLimits = (
122122
value: ResolvedComposeResourceLimits | ComposeResourceLimits
123-
): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value
123+
): value is ResolvedComposeResourceLimits => "cpuLimit" in value && "ramLimit" in value && "swapLimit" in value
124124

125125
const normalizeComposeResourceLimits = (
126126
resourceLimits: ResolvedComposeResourceLimits | ComposeResourceLimits | undefined

0 commit comments

Comments
 (0)