Skip to content

Commit 9aeaf0c

Browse files
committed
feat(shell): stabilize docker-git networking with shared mode
- add typed network mode config and CLI flags - ensure shared compose network exists before up - add safe gc for detached project networks - extend docs/errors/tests for subnet exhaustion recovery
1 parent 9e4906f commit 9aeaf0c

23 files changed

Lines changed: 678 additions & 11 deletions

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,19 @@ Common toggles:
122122
- `NPM_CONFIG_CACHE=/home/dev/.docker-git/.cache/packages/npm` (default shared cache)
123123
- `YARN_CACHE_FOLDER=/home/dev/.docker-git/.cache/packages/yarn` (default shared cache)
124124

125+
## Compose Network Mode
126+
127+
Default mode is shared:
128+
- `--network-mode shared` (default)
129+
- Shared compose network name: `--shared-network docker-git-shared`
130+
131+
Shared mode keeps one external Docker network for all docker-git projects, which reduces address pool pressure when many projects are created.
132+
133+
If you need strict per-project isolation:
134+
- `--network-mode project`
135+
136+
In project mode, each project uses `<service>-net` (Docker-managed bridge network).
137+
125138
## Troubleshooting
126139

127140
MCP errors in `codex` UI:
@@ -158,6 +171,17 @@ Docker permission error (`/var/run/docker.sock`):
158171
- Note:
159172
- Do not run `pnpm run docker-git ...` with `sudo`.
160173

174+
Docker network pool exhausted (`all predefined address pools have been fully subnetted`):
175+
- Symptom:
176+
- `failed to create network ... all predefined address pools have been fully subnetted`
177+
- Quick recovery:
178+
```bash
179+
docker network prune -f
180+
```
181+
- Long-term fix:
182+
- Configure Docker daemon `default-address-pools` in `/etc/docker/daemon.json`.
183+
- Prefer `docker-git` shared network mode (`--network-mode shared`).
184+
161185
Clone auth error (`Invalid username or token`):
162186
- Symptom:
163187
- `remote: Invalid username or token. Password authentication is not supported for Git operations.`

packages/app/src/docker-git/cli/parser-options.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ interface ValueOptionSpec {
2020
| "envProjectPath"
2121
| "codexAuthPath"
2222
| "codexHome"
23+
| "dockerNetworkMode"
24+
| "dockerSharedNetworkName"
2325
| "archivePath"
2426
| "scrapMode"
2527
| "label"
@@ -51,6 +53,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
5153
{ flag: "--env-project", key: "envProjectPath" },
5254
{ flag: "--codex-auth", key: "codexAuthPath" },
5355
{ flag: "--codex-home", key: "codexHome" },
56+
{ flag: "--network-mode", key: "dockerNetworkMode" },
57+
{ flag: "--shared-network", key: "dockerSharedNetworkName" },
5458
{ flag: "--archive", key: "archivePath" },
5559
{ flag: "--mode", key: "scrapMode" },
5660
{ flag: "--label", key: "label" },
@@ -102,6 +106,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
102106
envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
103107
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
104108
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
109+
dockerNetworkMode: (raw, value) => ({ ...raw, dockerNetworkMode: value }),
110+
dockerSharedNetworkName: (raw, value) => ({ ...raw, dockerSharedNetworkName: value }),
105111
archivePath: (raw, value) => ({ ...raw, archivePath: value }),
106112
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
107113
label: (raw, value) => ({ ...raw, label: value }),

packages/app/src/docker-git/cli/usage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Options:
4747
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
4848
--codex-auth <path> Host path for Codex auth cache (default: <projectsRoot>/.orch/auth/codex)
4949
--codex-home <path> Container path for Codex auth (default: /home/dev/.codex)
50+
--network-mode <mode> Compose network mode: shared|project (default: shared)
51+
--shared-network <name> Shared Docker network name when network-mode=shared (default: docker-git-shared)
5052
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
5153
--project-dir <path> Project directory for attach (default: .)
5254
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)

packages/app/src/docker-git/menu-actions.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
listProjectStatus,
1010
listRunningProjectItems
1111
} from "@effect-template/lib/usecases/projects"
12+
import { gcProjectNetworkByTemplate } from "@effect-template/lib/usecases/docker-network-gc"
1213
import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
1314
import { Effect, Match, pipe } from "effect"
1415

@@ -149,8 +150,10 @@ const handleMenuAction = (
149150
withProjectConfig(state, setMessage, () =>
150151
runDockerComposeLogs(state.activeDir ?? state.cwd))),
151152
Match.when({ _tag: "Down" }, () =>
152-
withProjectConfig(state, setMessage, () =>
153-
runDockerComposeDown(state.activeDir ?? state.cwd))),
153+
withProjectConfig(state, setMessage, (config) =>
154+
runDockerComposeDown(state.activeDir ?? state.cwd).pipe(
155+
Effect.zipRight(gcProjectNetworkByTemplate(state.activeDir ?? state.cwd, config.template))
156+
))),
154157
Match.when({ _tag: "DownAll" }, () =>
155158
pipe(
156159
downAllDockerGitProjects,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { Effect, Either } from "effect"
3+
4+
import { parseArgs } from "../../src/docker-git/cli/parser.js"
5+
6+
describe("parseArgs network options", () => {
7+
it.effect("parses create network mode options", () =>
8+
Effect.sync(() => {
9+
const parsed = parseArgs([
10+
"create",
11+
"--repo-url",
12+
"https://github.com/org/repo.git",
13+
"--network-mode",
14+
"project",
15+
"--shared-network",
16+
"ignored-shared-network"
17+
])
18+
if (Either.isLeft(parsed)) {
19+
throw new Error(`unexpected parse error: ${parsed.left._tag}`)
20+
}
21+
const command = parsed.right
22+
if (command._tag !== "Create") {
23+
throw new Error("expected Create command")
24+
}
25+
expect(command.config.dockerNetworkMode).toBe("project")
26+
expect(command.config.dockerSharedNetworkName).toBe("ignored-shared-network")
27+
}))
28+
29+
it.effect("fails on invalid network mode", () =>
30+
Effect.sync(() => {
31+
const command = parseArgs([
32+
"create",
33+
"--repo-url",
34+
"https://github.com/org/repo.git",
35+
"--network-mode",
36+
"invalid"
37+
])
38+
Either.match(command, {
39+
onLeft: (error) => {
40+
expect(error._tag).toBe("InvalidOption")
41+
},
42+
onRight: () => {
43+
throw new Error("expected parse error")
44+
}
45+
})
46+
}))
47+
})

packages/app/tests/docker-git/parser.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ const expectCreateDefaults = (command: CreateCommand) => {
7171
expect(command.outDir).toBe(".docker-git/org/repo")
7272
expect(command.runUp).toBe(true)
7373
expect(command.forceEnv).toBe(false)
74+
expect(command.config.dockerNetworkMode).toBe("shared")
75+
expect(command.config.dockerSharedNetworkName).toBe("docker-git-shared")
7476
}
7577

7678
const expandDefaultTargetDir = (path: string): string => expandContainerHome(defaultTemplateConfig.sshUser, path)

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
defaultTemplateConfig,
88
deriveRepoPathParts,
99
deriveRepoSlug,
10+
isDockerNetworkMode,
1011
type ParseError,
1112
resolveRepoInput
1213
} from "./domain.js"
@@ -32,6 +33,20 @@ const parsePort = (value: string): Either.Either<number, ParseError> => {
3233
return Either.right(parsed)
3334
}
3435

36+
const parseDockerNetworkMode = (
37+
value: string | undefined
38+
): Either.Either<CreateCommand["config"]["dockerNetworkMode"], ParseError> => {
39+
const candidate = value?.trim() ?? defaultTemplateConfig.dockerNetworkMode
40+
if (isDockerNetworkMode(candidate)) {
41+
return Either.right(candidate)
42+
}
43+
return Either.left({
44+
_tag: "InvalidOption",
45+
option: "--network-mode",
46+
reason: "expected one of: shared, project"
47+
})
48+
}
49+
3550
export const nonEmpty = (
3651
option: string,
3752
value: string | undefined,
@@ -210,6 +225,10 @@ export const buildCreateCommand = (
210225
const gitTokenLabel = normalizeGitTokenLabel(raw.gitTokenLabel)
211226
const codexAuthLabel = normalizeAuthLabel(raw.codexTokenLabel)
212227
const claudeAuthLabel = normalizeAuthLabel(raw.claudeTokenLabel)
228+
const dockerNetworkMode = yield* _(parseDockerNetworkMode(raw.dockerNetworkMode))
229+
const dockerSharedNetworkName = yield* _(
230+
nonEmpty("--shared-network", raw.dockerSharedNetworkName, defaultTemplateConfig.dockerSharedNetworkName)
231+
)
213232

214233
return {
215234
_tag: "Create",
@@ -238,6 +257,8 @@ export const buildCreateCommand = (
238257
codexAuthPath: paths.codexAuthPath,
239258
codexSharedAuthPath: paths.codexSharedAuthPath,
240259
codexHome: paths.codexHome,
260+
dockerNetworkMode,
261+
dockerSharedNetworkName,
241262
enableMcpPlaywright,
242263
pnpmVersion: defaultTemplateConfig.pnpmVersion
243264
}

packages/lib/src/core/command-options.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export interface RawOptions {
2525
readonly envProjectPath?: string
2626
readonly codexAuthPath?: string
2727
readonly codexHome?: string
28+
readonly dockerNetworkMode?: string
29+
readonly dockerSharedNetworkName?: string
2830
readonly enableMcpPlaywright?: boolean
2931
readonly archivePath?: string
3032
readonly scrapMode?: string

packages/lib/src/core/domain.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ export type { MenuAction, ParseError } from "./menu.js"
22
export { parseMenuSelection } from "./menu.js"
33
export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js"
44

5+
export type DockerNetworkMode = "shared" | "project"
6+
7+
export const defaultDockerNetworkMode: DockerNetworkMode = "shared"
8+
9+
export const defaultDockerSharedNetworkName = "docker-git-shared"
10+
511
export interface TemplateConfig {
612
readonly containerName: string
713
readonly serviceName: string
@@ -22,6 +28,8 @@ export interface TemplateConfig {
2228
readonly codexAuthPath: string
2329
readonly codexSharedAuthPath: string
2430
readonly codexHome: string
31+
readonly dockerNetworkMode: DockerNetworkMode
32+
readonly dockerSharedNetworkName: string
2533
readonly enableMcpPlaywright: boolean
2634
readonly pnpmVersion: string
2735
}
@@ -262,6 +270,36 @@ export type Command =
262270
| StateCommand
263271
| AuthCommand
264272

273+
// CHANGE: validate docker network mode values at the CLI/config boundary
274+
// WHY: keep compose network behavior explicit and type-safe
275+
// QUOTE(ТЗ): "Что бы среды были изолированы?"
276+
// REF: user-request-2026-02-20-networks
277+
// SOURCE: n/a
278+
// FORMAT THEOREM: ∀x: isDockerNetworkMode(x) -> x ∈ {"shared","project"}
279+
// PURITY: CORE
280+
// EFFECT: n/a
281+
// INVARIANT: returns true only for known modes
282+
// COMPLEXITY: O(1)
283+
export const isDockerNetworkMode = (value: string): value is DockerNetworkMode =>
284+
value === "shared" || value === "project"
285+
286+
// CHANGE: derive compose network name from typed template config
287+
// WHY: keep network naming deterministic across template generation and runtime checks
288+
// QUOTE(ТЗ): "Если я хочу уникальную сеть на каждый контейнер?"
289+
// REF: user-request-2026-02-20-networks
290+
// SOURCE: n/a
291+
// FORMAT THEOREM: ∀cfg: resolveComposeNetworkName(cfg) = n -> deterministic(n)
292+
// PURITY: CORE
293+
// EFFECT: n/a
294+
// INVARIANT: shared mode always resolves to dockerSharedNetworkName; project mode to "<service>-net"
295+
// COMPLEXITY: O(1)
296+
export const resolveComposeNetworkName = (
297+
config: Pick<TemplateConfig, "serviceName" | "dockerNetworkMode" | "dockerSharedNetworkName">
298+
): string =>
299+
config.dockerNetworkMode === "shared"
300+
? config.dockerSharedNetworkName
301+
: `${config.serviceName}-net`
302+
265303
export const defaultTemplateConfig = {
266304
containerName: "dev-ssh",
267305
serviceName: "dev",
@@ -277,6 +315,8 @@ export const defaultTemplateConfig = {
277315
codexAuthPath: "./.docker-git/.orch/auth/codex",
278316
codexSharedAuthPath: "./.docker-git/.orch/auth/codex",
279317
codexHome: "/home/dev/.codex",
318+
dockerNetworkMode: defaultDockerNetworkMode,
319+
dockerSharedNetworkName: defaultDockerSharedNetworkName,
280320
enableMcpPlaywright: false,
281321
pnpmVersion: "10.27.0"
282322
}

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

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { TemplateConfig } from "../domain.js"
1+
import { resolveComposeNetworkName, type TemplateConfig } from "../domain.js"
22

33
type ComposeFragments = {
4+
readonly networkMode: TemplateConfig["dockerNetworkMode"]
45
readonly networkName: string
56
readonly maybeGitTokenLabelEnv: string
67
readonly maybeCodexAuthLabelEnv: string
@@ -62,7 +63,8 @@ const buildPlaywrightFragments = (
6263
}
6364

6465
const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
65-
const networkName = `${config.serviceName}-net`
66+
const networkMode = config.dockerNetworkMode
67+
const networkName = resolveComposeNetworkName(config)
6668
const forkRepoUrl = config.forkRepoUrl ?? ""
6769
const gitTokenLabel = config.gitTokenLabel?.trim() ?? ""
6870
const codexAuthLabel = config.codexAuthLabel?.trim() ?? ""
@@ -73,6 +75,7 @@ const buildComposeFragments = (config: TemplateConfig): ComposeFragments => {
7375
const playwright = buildPlaywrightFragments(config, networkName)
7476

7577
return {
78+
networkMode,
7679
networkName,
7780
maybeGitTokenLabelEnv,
7881
maybeCodexAuthLabelEnv,
@@ -115,8 +118,15 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file:
115118
- ${fragments.networkName}
116119
${fragments.maybeBrowserService}`
117120

118-
const renderComposeNetworks = (networkName: string): string =>
119-
`networks:
121+
const renderComposeNetworks = (
122+
networkMode: TemplateConfig["dockerNetworkMode"],
123+
networkName: string
124+
): string =>
125+
networkMode === "shared"
126+
? `networks:
127+
${networkName}:
128+
external: true`
129+
: `networks:
120130
${networkName}:
121131
driver: bridge`
122132

@@ -129,7 +139,7 @@ export const renderDockerCompose = (config: TemplateConfig): string => {
129139
const fragments = buildComposeFragments(config)
130140
return [
131141
renderComposeServices(config, fragments),
132-
renderComposeNetworks(fragments.networkName),
142+
renderComposeNetworks(fragments.networkMode, fragments.networkName),
133143
renderComposeVolumes(config, fragments.maybeBrowserVolume)
134144
].join("\n\n")
135145
}

0 commit comments

Comments
 (0)