diff --git a/docker-compose.yml b/docker-compose.yml index 768e63cb..e297fde4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index b31accdd..8024b912 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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}" diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 4825da0a..8c13276b 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -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, @@ -34,6 +35,7 @@ import { export const renderEntrypoint = (config: TemplateConfig): string => [ renderEntrypointHeader(config), + renderEntrypointDnsRepair(), renderEntrypointPackageCache(config), renderEntrypointAuthorizedKeys(config), renderEntrypointCodexHome(config), diff --git a/packages/lib/src/core/templates-entrypoint/dns-repair.ts b/packages/lib/src/core/templates-entrypoint/dns-repair.ts new file mode 100644 index 00000000..b4a44ead --- /dev/null +++ b/packages/lib/src/core/templates-entrypoint/dns-repair.ts @@ -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 +// 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` diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index e8657cc2..473bd613 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -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` } } @@ -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}` diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts new file mode 100644 index 00000000..866c13b5 --- /dev/null +++ b/packages/lib/tests/core/templates.test.ts @@ -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 => ({ + ...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) + }) +}) diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index 916beed2..ec6a23d7 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -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) => @@ -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, @@ -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") @@ -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, { @@ -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%"')