From 467ffd2b45025c1a3f94fa3c335fa5276e8a77e0 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 20:56:19 +0000 Subject: [PATCH 1/5] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/ProverCoderAI/docker-git/issues/215 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..0dbb9d3 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-05-05T20:56:19.006Z for PR creation at branch issue-215-36d0d217ca4a for issue https://github.com/ProverCoderAI/docker-git/issues/215 \ No newline at end of file From fa86ac124c46bf233f68abf049fe8e1d268a0df4 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 21:22:33 +0000 Subject: [PATCH 2/5] feat(app,docs): clarify host-Docker runtime contract and probe diagnostics Make the docker-git runtime contract explicit (host-Docker-backed via /var/run/docker.sock) and replace the generic "cannot access Docker" error with a classifier that names the actual failure mode (host daemon down, host socket permission denied, docker CLI missing) and prints contract-aware remediation steps. - New pure module controller-docker-diagnostics.ts classifies docker probe outcomes by exit code + stderr and renders a message that restates the host-Docker contract and lists per-mode fixes. - controller-docker.ts now captures stderr from direct + sudo probes (not just exit codes) and feeds them into the diagnostic renderer. - README.md and packages/api/README.md add a "Runtime contract" section distinguishing the three failure modes the issue called out. - Tests cover classifier (5 cases) and message renderer (6 cases). --- .gitkeep | 1 - README.md | 31 ++++ packages/api/README.md | 27 ++++ .../controller-docker-diagnostics.ts | 148 ++++++++++++++++++ .../app/src/docker-git/controller-docker.ts | 80 +++++++--- .../controller-docker-diagnostics.test.ts | 135 ++++++++++++++++ 6 files changed, 399 insertions(+), 23 deletions(-) delete mode 100644 .gitkeep create mode 100644 packages/app/src/docker-git/controller-docker-diagnostics.ts create mode 100644 packages/app/tests/docker-git/controller-docker-diagnostics.test.ts diff --git a/.gitkeep b/.gitkeep deleted file mode 100644 index 0dbb9d3..0000000 --- a/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -# .gitkeep file auto-generated at 2026-05-05T20:56:19.006Z for PR creation at branch issue-215-36d0d217ca4a for issue https://github.com/ProverCoderAI/docker-git/issues/215 \ No newline at end of file diff --git a/README.md b/README.md index 8fcce95..c535deb 100644 --- a/README.md +++ b/README.md @@ -82,3 +82,34 @@ API - Просто апи сервер поднятный над LIB APP работает только с API, и не имеет доступа к LIB API работает только с LIB + +## Runtime contract: host-Docker-backed + +`docker-git` is host-Docker-backed by design. The controller container +(`docker-git-api`) talks to the host Docker daemon via the bind-mounted +`/var/run/docker.sock`, which is how it creates and manages per-project +containers. There is no isolated Docker-in-Docker runtime. + +This means the user that runs the host CLI (`bun run docker-git ...`) needs +to be able to talk to that same socket directly. Three failure modes can +look superficially identical and are diagnosed separately by the CLI: + +1. **Host Docker daemon is not reachable** – `docker info` fails with + "Cannot connect to the Docker daemon". Start Docker (e.g. + `sudo systemctl start docker`) or set `DOCKER_HOST` to a reachable + endpoint. +2. **Host Docker socket rejected this user** – `docker info` fails with + "permission denied" while talking to `/var/run/docker.sock`. This is a + host configuration issue, *not* a `docker-git` outage. Add the user to + the `docker` group, switch to rootless Docker, or fix the socket + ownership (`root:docker`, mode `660`). After changing groups, log out + and back in (or run `newgrp docker`). +3. **Controller container not running** – the host CLI cannot reach + `docker-git-api` on its API port. Bring the controller up via + `docker compose up -d --build`, or point the CLI at an existing + controller using `DOCKER_GIT_API_URL`. + +When the CLI cannot acquire Docker access it now prints a message that +names the specific failure mode, restates the host-Docker contract, and +lists remediation steps for that exact mode. Implementation lives in +`packages/app/src/docker-git/controller-docker-diagnostics.ts`. diff --git a/packages/api/README.md b/packages/api/README.md index c40f509..a42f9bd 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -8,6 +8,33 @@ This is now the intended controller plane: - the API talks to Docker through `/var/run/docker.sock` - child project containers no longer depend on host bind mounts for bootstrap auth/env +## Runtime contract: host-Docker-backed + +`docker-git` is host-Docker-backed, not isolated. The controller container +created from this package binds the host socket +(`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and +uses it to spawn per-project containers. There is no Docker-in-Docker +runtime; the daemon is always the host's daemon. + +The host CLI (`packages/app`) also talks to that same daemon directly when +it bootstraps the controller. Three failure modes look identical at first +glance and the CLI now distinguishes them in its error output: + +- **Host daemon down** – `docker info` cannot connect. Start the host + Docker daemon or set `DOCKER_HOST`. +- **Host socket permission mismatch** – `docker info` returns + `permission denied` on `/var/run/docker.sock`. Fix host group membership + (`docker` group / rootless Docker / socket ownership). This is a host + configuration problem, not a `docker-git` outage. +- **Controller container not running / unreachable** – the API at + `DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer. + Bring the controller up with `docker compose up -d --build` or point the + CLI at an existing controller via `DOCKER_GIT_API_URL`. + +Diagnostic classification + remediation messages live in +`packages/app/src/docker-git/controller-docker-diagnostics.ts` and are +covered by `packages/app/tests/docker-git/controller-docker-diagnostics.test.ts`. + ## UI wrapper After API startup open: diff --git a/packages/app/src/docker-git/controller-docker-diagnostics.ts b/packages/app/src/docker-git/controller-docker-diagnostics.ts new file mode 100644 index 0000000..06d558d --- /dev/null +++ b/packages/app/src/docker-git/controller-docker-diagnostics.ts @@ -0,0 +1,148 @@ +import { Match } from "effect" + +// PURITY: CORE +// EFFECT: pure functions; no IO, no process, no time +// INVARIANT: classification depends only on the supplied probe output and exit code + +export type DockerProbeFailureKind = + | "docker-cli-missing" + | "socket-permission-denied" + | "daemon-unreachable" + | "unknown" + +export type DockerProbeOutcome = { + readonly exitCode: number + readonly stderr: string +} + +const lowercase = (text: string): string => text.toLowerCase() + +const containsAny = (haystack: string, needles: ReadonlyArray): boolean => + needles.some((needle) => haystack.includes(needle)) + +const isCliMissingExitCode = (exitCode: number): boolean => exitCode === 127 + +const cliMissingMarkers: ReadonlyArray = [ + "command not found", + "not found", + "no such file or directory" +] + +const permissionMarkers: ReadonlyArray = [ + "permission denied", + "access is denied", + "got permission denied" +] + +const daemonDownMarkers: ReadonlyArray = [ + "cannot connect to the docker daemon", + "is the docker daemon running", + "no such file or directory", + "connection refused" +] + +export const classifyDockerProbeFailure = (outcome: DockerProbeOutcome): DockerProbeFailureKind => { + const normalized = lowercase(outcome.stderr) + + if (containsAny(normalized, permissionMarkers)) { + return "socket-permission-denied" + } + + if (isCliMissingExitCode(outcome.exitCode) && containsAny(normalized, cliMissingMarkers)) { + return "docker-cli-missing" + } + + if (containsAny(normalized, daemonDownMarkers)) { + return "daemon-unreachable" + } + + return "unknown" +} + +export type DockerAccessDeniedContext = { + readonly directProbe: DockerProbeOutcome + readonly sudoProbe: DockerProbeOutcome | null + readonly apiBaseUrl: string + readonly dockerHost: string | null +} + +const firstNonEmptyLine = (text: string): string => { + for (const line of text.split("\n")) { + const trimmed = line.trim() + if (trimmed.length > 0) { + return trimmed + } + } + return "" +} + +const renderProbeLine = (label: string, probe: DockerProbeOutcome | null): string => { + if (probe === null) { + return `${label}: skipped` + } + const stderrSummary = firstNonEmptyLine(probe.stderr) + const summaryText = stderrSummary.length > 0 ? stderrSummary : "no stderr" + return `${label}: exit=${probe.exitCode}; ${summaryText}` +} + +const renderHeadlineForKind = (kind: DockerProbeFailureKind): string => + Match.value(kind).pipe( + Match.when( + "socket-permission-denied", + () => "Host Docker socket rejected this user (socket permission mismatch, not a docker-git outage)." + ), + Match.when( + "daemon-unreachable", + () => "Host Docker daemon is not reachable from this user (daemon down or wrong DOCKER_HOST)." + ), + Match.when("docker-cli-missing", () => "docker CLI was not found on this machine."), + Match.when("unknown", () => "docker-git host CLI cannot access Docker from the client process."), + Match.exhaustive + ) + +const renderRemediationForKind = (kind: DockerProbeFailureKind, apiBaseUrl: string): ReadonlyArray => { + const apiHint = + `Or keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL (default ${apiBaseUrl}).` + return Match.value(kind).pipe( + Match.when("socket-permission-denied", (): ReadonlyArray => [ + "docker-git is intentionally backed by the host Docker daemon via /var/run/docker.sock.", + "Add this user to the docker group, switch to rootless Docker, or fix /var/run/docker.sock ownership (root:docker, mode 660).", + "After changing groups, log out and back in (or run `newgrp docker`) so the new group membership applies.", + apiHint + ]), + Match.when("daemon-unreachable", (): ReadonlyArray => [ + "Start the Docker daemon (e.g. `sudo systemctl start docker`) or set DOCKER_HOST to a reachable endpoint.", + apiHint + ]), + Match.when("docker-cli-missing", (): ReadonlyArray => [ + "Install Docker Engine or Docker Desktop and ensure `docker` is on PATH.", + apiHint + ]), + Match.when("unknown", (): ReadonlyArray => [ + "Tried direct Docker and passwordless sudo Docker; both probes failed.", + "Grant this user direct Docker access (docker group/rootless Docker), configure passwordless sudo for docker, or", + apiHint + ]), + Match.exhaustive + ) +} + +// PURITY: CORE +// EFFECT: pure function over diagnostic context +// INVARIANT: emitted message names the failure mode, the contract, and the next action +export const renderDockerAccessDeniedMessage = (context: DockerAccessDeniedContext): string => { + const directKind = classifyDockerProbeFailure(context.directProbe) + const dockerHostLine = context.dockerHost !== null && context.dockerHost.length > 0 + ? `DOCKER_HOST: ${context.dockerHost}` + : "DOCKER_HOST: unset (defaults to unix:///var/run/docker.sock)" + + return [ + renderHeadlineForKind(directKind), + "Runtime contract: docker-git is host-Docker-backed; the controller container talks to the daemon via /var/run/docker.sock.", + ...renderRemediationForKind(directKind, context.apiBaseUrl), + "Probe commands: docker info; sudo -n docker info", + renderProbeLine("Direct probe", context.directProbe), + renderProbeLine("Sudo probe", context.sudoProbe), + dockerHostLine + ].join("\n") +} diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 30c63cb..5b122b4 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -7,10 +7,17 @@ import { Effect } from "effect" import { runCommandCapture, runCommandExitCode, - runCommandExitCodeStreaming + runCommandExitCodeStreaming, + runCommandWithCapturedOutput } from "./frontend-lib/shell/command-runner.js" -import { type DockerNetworkIps, parseDockerNetworkIps, uniqueStrings } from "./controller-reachability.js" +import { type DockerProbeOutcome, renderDockerAccessDeniedMessage } from "./controller-docker-diagnostics.js" +import { + type DockerNetworkIps, + parseDockerNetworkIps, + resolveConfiguredApiBaseUrl, + uniqueStrings +} from "./controller-reachability.js" import { computeLocalControllerRevision, controllerRevisionEnvKey, @@ -66,14 +73,6 @@ const currentProcessEnv = (): Readonly> => Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined) ) -const renderDockerAccessDeniedMessage = (): string => - [ - "docker-git host CLI cannot access Docker from the client process.", - "Tried direct Docker and passwordless sudo Docker.", - "Keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL or the default local API port, grant this user direct Docker access (docker group/rootless Docker), or configure passwordless sudo for docker.", - "Probe commands: docker info; sudo -n docker info" - ].join("\n") - const runExitCode = ( command: string, args: ReadonlyArray @@ -89,25 +88,62 @@ const runExitCode = ( }) ) +type ProbeFailure = { + readonly _tag: "ProbeFailure" + readonly outcome: DockerProbeOutcome +} + +const captureProbeOutcome = ( + command: string, + args: ReadonlyArray +): Effect.Effect => + runCommandWithCapturedOutput( + { cwd: process.cwd(), command, args }, + [0], + (exitCode, output): ProbeFailure => ({ + _tag: "ProbeFailure", + outcome: { exitCode, stderr: output } + }) + ).pipe( + Effect.match({ + onFailure: (error) => + "outcome" in error + ? error.outcome + : { exitCode: 127, stderr: String(error) }, + onSuccess: () => ({ exitCode: 0, stderr: "" }) + }) + ) + export const resolveDockerCommand = (): Effect.Effect< ReadonlyArray, ControllerBootstrapError, CommandExecutor.CommandExecutor > => - runExitCode("docker", ["info"]).pipe( - Effect.flatMap((dockerInfoExit) => { - if (dockerInfoExit === 0) { - return Effect.succeed>(["docker"]) - } - return runExitCode("sudo", ["-n", "docker", "info"]).pipe( - Effect.flatMap((sudoDockerInfoExit) => - sudoDockerInfoExit === 0 - ? Effect.succeed>(["sudo", "-n", "docker"]) - : Effect.fail(controllerBootstrapError(renderDockerAccessDeniedMessage())) + Effect.gen(function*(_) { + const directProbe = yield* _(captureProbeOutcome("docker", ["info"])) + if (directProbe.exitCode === 0) { + return ["docker"] as ReadonlyArray + } + + const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"])) + if (sudoProbe.exitCode === 0) { + return ["sudo", "-n", "docker"] as ReadonlyArray + } + + const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? "" + return yield* _( + Effect.fail( + controllerBootstrapError( + renderDockerAccessDeniedMessage({ + directProbe, + sudoProbe, + apiBaseUrl: resolveConfiguredApiBaseUrl(), + dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null + }) ) ) - }) - ) + ) + }) type DockerInvocation = { readonly command: string diff --git a/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts b/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts new file mode 100644 index 0000000..6491fda --- /dev/null +++ b/packages/app/tests/docker-git/controller-docker-diagnostics.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + classifyDockerProbeFailure, + type DockerAccessDeniedContext, + renderDockerAccessDeniedMessage +} from "../../src/docker-git/controller-docker-diagnostics.js" + +const apiBaseUrl = "http://127.0.0.1:3334" + +const buildContext = (overrides: Partial = {}): DockerAccessDeniedContext => ({ + directProbe: { exitCode: 1, stderr: "" }, + sudoProbe: null, + apiBaseUrl, + dockerHost: null, + ...overrides +}) + +describe("classifyDockerProbeFailure", () => { + it.effect("classifies socket permission denied", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: + "Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock" + }) + expect(kind).toBe("socket-permission-denied") + })) + + it.effect("classifies daemon unreachable when stderr says cannot connect", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" + }) + expect(kind).toBe("daemon-unreachable") + })) + + it.effect("classifies missing CLI when exit code is 127 and stderr says not found", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 127, + stderr: "docker: command not found" + }) + expect(kind).toBe("docker-cli-missing") + })) + + it.effect("returns unknown for empty stderr and non-recognised exit code", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ exitCode: 1, stderr: "" }) + expect(kind).toBe("unknown") + })) + + it.effect("prefers permission denied over daemon unreachable when both markers appear", () => + Effect.sync(() => { + const kind = classifyDockerProbeFailure({ + exitCode: 1, + stderr: "permission denied: Cannot connect to the Docker daemon" + }) + expect(kind).toBe("socket-permission-denied") + })) +}) + +describe("renderDockerAccessDeniedMessage", () => { + it.effect("explains permission mismatch and mentions the contract", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { + exitCode: 1, + stderr: "Got permission denied while trying to connect to the Docker daemon socket" + }, + sudoProbe: { exitCode: 1, stderr: "sudo: a password is required" } + }) + ) + + expect(message).toContain("Host Docker socket rejected this user") + expect(message).toContain("Runtime contract: docker-git is host-Docker-backed") + expect(message).toContain("docker group") + expect(message).toContain(apiBaseUrl) + expect(message).toContain("Direct probe: exit=1; Got permission denied") + expect(message).toContain("Sudo probe: exit=1; sudo: a password is required") + })) + + it.effect("explains daemon-down case differently", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?" + }, + sudoProbe: { + exitCode: 1, + stderr: "Cannot connect to the Docker daemon" + } + }) + ) + + expect(message).toContain("Host Docker daemon is not reachable") + expect(message).toContain("systemctl start docker") + expect(message).toContain("DOCKER_HOST: unset") + })) + + it.effect("renders DOCKER_HOST when provided", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + dockerHost: "tcp://docker.example:2376" + }) + ) + + expect(message).toContain("DOCKER_HOST: tcp://docker.example:2376") + })) + + it.effect("marks sudo probe as skipped when not provided", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage(buildContext({ sudoProbe: null })) + expect(message).toContain("Sudo probe: skipped") + })) + + it.effect("recommends installing Docker when CLI is missing", () => + Effect.sync(() => { + const message = renderDockerAccessDeniedMessage( + buildContext({ + directProbe: { exitCode: 127, stderr: "docker: command not found" }, + sudoProbe: { exitCode: 127, stderr: "sudo: docker: command not found" } + }) + ) + + expect(message).toContain("docker CLI was not found") + expect(message).toContain("Install Docker Engine") + })) +}) From 332cd8593a774dd7cf8ea13d5fd2012c8c8c3b84 Mon Sep 17 00:00:00 2001 From: konard Date: Tue, 5 May 2026 23:14:06 +0000 Subject: [PATCH 3/5] fix: drop redundant readonly array casts in docker probe --- packages/app/src/docker-git/controller-docker.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/docker-git/controller-docker.ts b/packages/app/src/docker-git/controller-docker.ts index 5b122b4..13dd1e3 100644 --- a/packages/app/src/docker-git/controller-docker.ts +++ b/packages/app/src/docker-git/controller-docker.ts @@ -122,12 +122,12 @@ export const resolveDockerCommand = (): Effect.Effect< Effect.gen(function*(_) { const directProbe = yield* _(captureProbeOutcome("docker", ["info"])) if (directProbe.exitCode === 0) { - return ["docker"] as ReadonlyArray + return ["docker"] } const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"])) if (sudoProbe.exitCode === 0) { - return ["sudo", "-n", "docker"] as ReadonlyArray + return ["sudo", "-n", "docker"] } const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? "" From 6249943950b6965df4c16716351f3ba5b56147d0 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 6 May 2026 00:13:37 +0000 Subject: [PATCH 4/5] ci: re-trigger after transient E2E hang From 330214b39550be5be55f29b4c0838831d93bb628 Mon Sep 17 00:00:00 2001 From: konard Date: Wed, 6 May 2026 00:13:38 +0000 Subject: [PATCH 5/5] ci: retrigger workflows after flaky E2E timeout