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
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ services:
CODEX_HOME: "/home/dev/.codex"
ports:
- "127.0.0.1:2222:22"
dns:
- 8.8.8.8
- 8.8.4.4
- 1.1.1.1
volumes:
- dev_home:/home/dev
- ./authorized_keys:/authorized_keys:ro
Expand Down
36 changes: 36 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@
# COMPLEXITY: O(network + repo_size)
set -euo pipefail

# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken
docker_git_repair_dns() {
local test_domain="github.com"
local resolv="/etc/resolv.conf"
local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1"

if getent hosts "$test_domain" >/dev/null 2>&1; then
return 0
fi

echo "[dns-repair] DNS resolution failed for $test_domain; attempting repair..."

local has_external=0
for ns in $fallback_dns; do
if grep -q "nameserver $ns" "$resolv" 2>/dev/null; then
has_external=1
fi
done

if [[ "$has_external" -eq 0 ]]; then
for ns in $fallback_dns; do
printf "nameserver %s\n" "$ns" >> "$resolv"
done
echo "[dns-repair] appended fallback nameservers to $resolv"
fi

if getent hosts "$test_domain" >/dev/null 2>&1; then
echo "[dns-repair] DNS resolution restored"
return 0
fi

echo "[dns-repair] WARNING: DNS resolution still failing after repair attempt"
return 1
}
docker_git_repair_dns || true

REPO_URL="${REPO_URL:-}"
REPO_REF="${REPO_REF:-}"
TARGET_DIR="${TARGET_DIR:-/work/app}"
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/templates-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
renderEntrypointZshShell,
renderEntrypointZshUserRc
} from "./templates-entrypoint/base.js"
import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js"
import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js"
import {
renderEntrypointAgentsNotice,
Expand All @@ -34,6 +35,7 @@ import {
export const renderEntrypoint = (config: TemplateConfig): string =>
[
renderEntrypointHeader(config),
renderEntrypointDnsRepair(),
renderEntrypointPackageCache(config),
renderEntrypointAuthorizedKeys(config),
renderEntrypointCodexHome(config),
Expand Down
49 changes: 49 additions & 0 deletions packages/lib/src/core/templates-entrypoint/dns-repair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// CHANGE: add automatic DNS repair at container startup
// WHY: Docker internal DNS (127.0.0.11) intermittently loses external nameservers,
// causing domain resolution to fail inside containers
// QUOTE(ТЗ): "При запуске контейнера он всегда исправляет интернет соединение потому что оно время от времени ложится"
// REF: issue-168
// SOURCE: n/a
// FORMAT THEOREM: ∀container: startup(container) → dns_healthy(container) ∨ dns_repaired(container)
// PURITY: SHELL
// EFFECT: Effect<void, DnsRepairError, Env>
// INVARIANT: after execution, at least one nameserver in /etc/resolv.conf resolves external domains
// COMPLEXITY: O(1) per probe attempt, O(max_attempts) worst case
export const renderEntrypointDnsRepair = (): string =>
`# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken
docker_git_repair_dns() {
local test_domain="github.com"
local resolv="/etc/resolv.conf"
local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1"

if getent hosts "$test_domain" >/dev/null 2>&1; then
return 0
fi

echo "[dns-repair] DNS resolution failed for $test_domain; attempting repair..."

# Preserve Docker internal resolver but append external fallbacks
local has_external=0
for ns in $fallback_dns; do
if grep -q "nameserver $ns" "$resolv" 2>/dev/null; then
has_external=1
fi
done

if [[ "$has_external" -eq 0 ]]; then
for ns in $fallback_dns; do
printf "nameserver %s\\n" "$ns" >> "$resolv"
done
echo "[dns-repair] appended fallback nameservers to $resolv"
fi

# Verify fix
if getent hosts "$test_domain" >/dev/null 2>&1; then
echo "[dns-repair] DNS resolution restored"
return 0
fi

echo "[dns-repair] WARNING: DNS resolution still failing after repair attempt"
return 1
}
docker_git_repair_dns || true`
6 changes: 5 additions & 1 deletion packages/lib/src/core/templates/docker-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const buildPlaywrightFragments = (
maybeBrowserService:
`\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${
renderResourceLimits(resourceLimits)
} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
} environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`,
maybeBrowserVolume: ` ${browserVolumeName}:\n`
}
}
Expand Down Expand Up @@ -153,6 +153,10 @@ ${renderResourceLimits(resourceLimits)} volumes:
- ${config.codexAuthPath}:${config.codexHome}
- ${renderSharedCodexHostMount(config.dockerGitPath)}:${config.codexHome}-shared
- /var/run/docker.sock:/var/run/docker.sock
dns:
- 8.8.8.8
- 8.8.4.4
- 1.1.1.1
networks:
- ${fragments.networkName}
${fragments.maybeBrowserService}`
Expand Down
81 changes: 81 additions & 0 deletions packages/lib/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from "@effect/vitest"

import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js"
import { renderDockerCompose } from "../../src/core/templates/docker-compose.js"
import { renderEntrypoint } from "../../src/core/templates-entrypoint.js"
import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js"

const makeTemplateConfig = (overrides: Partial<TemplateConfig> = {}): TemplateConfig => ({
...defaultTemplateConfig,
repoUrl: "https://github.com/org/repo.git",
containerName: "dg-test",
serviceName: "dg-test",
sshUser: "dev",
targetDir: "/home/dev/org/repo",
volumeName: "dg-test-home",
dockerGitPath: "/workspace/.docker-git",
authorizedKeysPath: "/workspace/authorized_keys",
envGlobalPath: "/workspace/.orch/env/global.env",
envProjectPath: "/workspace/.orch/env/project.env",
codexAuthPath: "/workspace/.orch/auth/codex",
codexSharedAuthPath: "/workspace/.orch/auth/codex-shared",
geminiAuthPath: "/workspace/.orch/auth/gemini",
...overrides
})

describe("renderEntrypointDnsRepair", () => {
it("renders the fallback nameserver repair block", () => {
const dnsRepair = renderEntrypointDnsRepair()

expect(dnsRepair).toContain('local test_domain="github.com"')
expect(dnsRepair).toContain('local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1"')
expect(dnsRepair).toContain('printf "nameserver %s\\n" "$ns" >> "$resolv"')
expect(dnsRepair).toContain('echo "[dns-repair] WARNING: DNS resolution still failing after repair attempt"')
expect(dnsRepair).toContain("docker_git_repair_dns || true")
})

it("injects DNS repair before the package cache setup in the full entrypoint", () => {
const entrypoint = renderEntrypoint(makeTemplateConfig())
const dnsRepair = renderEntrypointDnsRepair()
const dnsRepairIndex = entrypoint.indexOf(dnsRepair)
const packageCacheIndex = entrypoint.indexOf(
"# Share package manager caches across all docker-git containers"
)

expect(dnsRepairIndex).toBeGreaterThanOrEqual(0)
expect(packageCacheIndex).toBeGreaterThan(dnsRepairIndex)
})
})

describe("renderDockerCompose", () => {
it("renders fallback DNS servers for the main container even without Playwright", () => {
const compose = renderDockerCompose(makeTemplateConfig())

expect(compose).toContain("container_name: dg-test")
expect(compose).toContain(" dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n networks:")
expect(compose).not.toContain("dg-test-browser")
expect((compose.match(/\n dns:\n/g) ?? []).length).toBe(1)
})

it("renders fallback DNS servers for the browser sidecar when Playwright is enabled", () => {
const compose = renderDockerCompose(
makeTemplateConfig({
enableMcpPlaywright: true
}),
{
cpuLimit: 1.5,
ramLimit: "2g"
}
)
const browserServiceIndex = compose.indexOf("\n dg-test-browser:\n")
const browserDnsIndex = compose.indexOf(
' dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - dg-test-home-browser:/data\n',
browserServiceIndex
)

expect(compose).toContain('MCP_PLAYWRIGHT_CDP_ENDPOINT: "http://dg-test-browser:9223"')
expect(browserServiceIndex).toBeGreaterThanOrEqual(0)
expect(browserDnsIndex).toBeGreaterThan(browserServiceIndex)
expect((compose.match(/\n dns:\n/g) ?? []).length).toBe(2)
})
})
11 changes: 11 additions & 0 deletions packages/lib/tests/usecases/prepare-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ const readEnableMcpPlaywrightFlag = (value: unknown): boolean | undefined => {
return typeof flag === "boolean" ? flag : undefined
}

const countOccurrences = (source: string, fragment: string): number =>
source.split(fragment).length - 1

describe("prepareProjectFiles", () => {
it.effect("force-env refresh rewrites managed templates", () =>
withTempDir((root) =>
Expand All @@ -147,6 +150,7 @@ describe("prepareProjectFiles", () => {
const entrypointPath = path.join(outDir, "entrypoint.sh")
const entrypoint = yield* _(fs.readFileString(entrypointPath))
const composeBefore = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
const dnsBlock = " dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1"
const entrypointSyntaxExitCode = yield* _(
runCommandExitCode({
cwd: outDir,
Expand All @@ -171,6 +175,11 @@ describe("prepareProjectFiles", () => {
expect(entrypoint).toContain('. /etc/profile 2>/dev/null || true;')
expect(entrypoint).toContain("codex exec")
expect(entrypoint).not.toContain("codex --approval-mode full-auto")
expect(entrypoint).toContain("docker_git_repair_dns() {")
expect(entrypoint).toContain('local test_domain="github.com"')
expect(entrypoint).toContain('local fallback_dns="8.8.8.8 8.8.4.4 1.1.1.1"')
expect(entrypoint).toContain('printf "nameserver %s\\n" "$ns" >> "$resolv"')
expect(entrypoint).toContain("docker_git_repair_dns || true")
expect(entrypoint).toContain('"plugin": ["oh-my-opencode"]')
expect(entrypoint).toContain("branch '$REPO_REF' missing; retrying without --branch")
expect(entrypoint).not.toContain("git ls-remote --symref")
Expand All @@ -185,6 +194,7 @@ describe("prepareProjectFiles", () => {
expect(composeBefore).not.toContain("dg-test-browser")
expect(composeBefore).toContain("docker-git-shared")
expect(composeBefore).toContain("external: true")
expect(countOccurrences(composeBefore, dnsBlock)).toBe(1)

yield* _(
prepareProjectFiles(outDir, root, globalConfig, withMcp, {
Expand All @@ -208,6 +218,7 @@ describe("prepareProjectFiles", () => {
expect(composeAfter).toContain("container_name: dg-test-browser\n restart: unless-stopped")
expect(composeAfter).toContain("docker-git-shared")
expect(composeAfter).toContain("external: true")
expect(countOccurrences(composeAfter, dnsBlock)).toBe(2)
expect(readEnableMcpPlaywrightFlag(configAfter)).toBe(true)
expect(configAfterText).toContain('"cpuLimit": "30%"')
expect(configAfterText).toContain('"ramLimit": "30%"')
Expand Down
Loading