Skip to content

Commit fa86ac1

Browse files
committed
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).
1 parent 467ffd2 commit fa86ac1

6 files changed

Lines changed: 399 additions & 23 deletions

File tree

.gitkeep

Lines changed: 0 additions & 1 deletion
This file was deleted.

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,34 @@ API - Просто апи сервер поднятный над LIB
8282

8383
APP работает только с API, и не имеет доступа к LIB
8484
API работает только с LIB
85+
86+
## Runtime contract: host-Docker-backed
87+
88+
`docker-git` is host-Docker-backed by design. The controller container
89+
(`docker-git-api`) talks to the host Docker daemon via the bind-mounted
90+
`/var/run/docker.sock`, which is how it creates and manages per-project
91+
containers. There is no isolated Docker-in-Docker runtime.
92+
93+
This means the user that runs the host CLI (`bun run docker-git ...`) needs
94+
to be able to talk to that same socket directly. Three failure modes can
95+
look superficially identical and are diagnosed separately by the CLI:
96+
97+
1. **Host Docker daemon is not reachable**`docker info` fails with
98+
"Cannot connect to the Docker daemon". Start Docker (e.g.
99+
`sudo systemctl start docker`) or set `DOCKER_HOST` to a reachable
100+
endpoint.
101+
2. **Host Docker socket rejected this user**`docker info` fails with
102+
"permission denied" while talking to `/var/run/docker.sock`. This is a
103+
host configuration issue, *not* a `docker-git` outage. Add the user to
104+
the `docker` group, switch to rootless Docker, or fix the socket
105+
ownership (`root:docker`, mode `660`). After changing groups, log out
106+
and back in (or run `newgrp docker`).
107+
3. **Controller container not running** – the host CLI cannot reach
108+
`docker-git-api` on its API port. Bring the controller up via
109+
`docker compose up -d --build`, or point the CLI at an existing
110+
controller using `DOCKER_GIT_API_URL`.
111+
112+
When the CLI cannot acquire Docker access it now prints a message that
113+
names the specific failure mode, restates the host-Docker contract, and
114+
lists remediation steps for that exact mode. Implementation lives in
115+
`packages/app/src/docker-git/controller-docker-diagnostics.ts`.

packages/api/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,33 @@ This is now the intended controller plane:
88
- the API talks to Docker through `/var/run/docker.sock`
99
- child project containers no longer depend on host bind mounts for bootstrap auth/env
1010

11+
## Runtime contract: host-Docker-backed
12+
13+
`docker-git` is host-Docker-backed, not isolated. The controller container
14+
created from this package binds the host socket
15+
(`/var/run/docker.sock:/var/run/docker.sock`, see `docker-compose.yml`) and
16+
uses it to spawn per-project containers. There is no Docker-in-Docker
17+
runtime; the daemon is always the host's daemon.
18+
19+
The host CLI (`packages/app`) also talks to that same daemon directly when
20+
it bootstraps the controller. Three failure modes look identical at first
21+
glance and the CLI now distinguishes them in its error output:
22+
23+
- **Host daemon down**`docker info` cannot connect. Start the host
24+
Docker daemon or set `DOCKER_HOST`.
25+
- **Host socket permission mismatch**`docker info` returns
26+
`permission denied` on `/var/run/docker.sock`. Fix host group membership
27+
(`docker` group / rootless Docker / socket ownership). This is a host
28+
configuration problem, not a `docker-git` outage.
29+
- **Controller container not running / unreachable** – the API at
30+
`DOCKER_GIT_API_URL` (default `http://127.0.0.1:3334`) does not answer.
31+
Bring the controller up with `docker compose up -d --build` or point the
32+
CLI at an existing controller via `DOCKER_GIT_API_URL`.
33+
34+
Diagnostic classification + remediation messages live in
35+
`packages/app/src/docker-git/controller-docker-diagnostics.ts` and are
36+
covered by `packages/app/tests/docker-git/controller-docker-diagnostics.test.ts`.
37+
1138
## UI wrapper
1239

1340
After API startup open:
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { Match } from "effect"
2+
3+
// PURITY: CORE
4+
// EFFECT: pure functions; no IO, no process, no time
5+
// INVARIANT: classification depends only on the supplied probe output and exit code
6+
7+
export type DockerProbeFailureKind =
8+
| "docker-cli-missing"
9+
| "socket-permission-denied"
10+
| "daemon-unreachable"
11+
| "unknown"
12+
13+
export type DockerProbeOutcome = {
14+
readonly exitCode: number
15+
readonly stderr: string
16+
}
17+
18+
const lowercase = (text: string): string => text.toLowerCase()
19+
20+
const containsAny = (haystack: string, needles: ReadonlyArray<string>): boolean =>
21+
needles.some((needle) => haystack.includes(needle))
22+
23+
const isCliMissingExitCode = (exitCode: number): boolean => exitCode === 127
24+
25+
const cliMissingMarkers: ReadonlyArray<string> = [
26+
"command not found",
27+
"not found",
28+
"no such file or directory"
29+
]
30+
31+
const permissionMarkers: ReadonlyArray<string> = [
32+
"permission denied",
33+
"access is denied",
34+
"got permission denied"
35+
]
36+
37+
const daemonDownMarkers: ReadonlyArray<string> = [
38+
"cannot connect to the docker daemon",
39+
"is the docker daemon running",
40+
"no such file or directory",
41+
"connection refused"
42+
]
43+
44+
export const classifyDockerProbeFailure = (outcome: DockerProbeOutcome): DockerProbeFailureKind => {
45+
const normalized = lowercase(outcome.stderr)
46+
47+
if (containsAny(normalized, permissionMarkers)) {
48+
return "socket-permission-denied"
49+
}
50+
51+
if (isCliMissingExitCode(outcome.exitCode) && containsAny(normalized, cliMissingMarkers)) {
52+
return "docker-cli-missing"
53+
}
54+
55+
if (containsAny(normalized, daemonDownMarkers)) {
56+
return "daemon-unreachable"
57+
}
58+
59+
return "unknown"
60+
}
61+
62+
export type DockerAccessDeniedContext = {
63+
readonly directProbe: DockerProbeOutcome
64+
readonly sudoProbe: DockerProbeOutcome | null
65+
readonly apiBaseUrl: string
66+
readonly dockerHost: string | null
67+
}
68+
69+
const firstNonEmptyLine = (text: string): string => {
70+
for (const line of text.split("\n")) {
71+
const trimmed = line.trim()
72+
if (trimmed.length > 0) {
73+
return trimmed
74+
}
75+
}
76+
return ""
77+
}
78+
79+
const renderProbeLine = (label: string, probe: DockerProbeOutcome | null): string => {
80+
if (probe === null) {
81+
return `${label}: skipped`
82+
}
83+
const stderrSummary = firstNonEmptyLine(probe.stderr)
84+
const summaryText = stderrSummary.length > 0 ? stderrSummary : "no stderr"
85+
return `${label}: exit=${probe.exitCode}; ${summaryText}`
86+
}
87+
88+
const renderHeadlineForKind = (kind: DockerProbeFailureKind): string =>
89+
Match.value(kind).pipe(
90+
Match.when(
91+
"socket-permission-denied",
92+
() => "Host Docker socket rejected this user (socket permission mismatch, not a docker-git outage)."
93+
),
94+
Match.when(
95+
"daemon-unreachable",
96+
() => "Host Docker daemon is not reachable from this user (daemon down or wrong DOCKER_HOST)."
97+
),
98+
Match.when("docker-cli-missing", () => "docker CLI was not found on this machine."),
99+
Match.when("unknown", () => "docker-git host CLI cannot access Docker from the client process."),
100+
Match.exhaustive
101+
)
102+
103+
const renderRemediationForKind = (kind: DockerProbeFailureKind, apiBaseUrl: string): ReadonlyArray<string> => {
104+
const apiHint =
105+
`Or keep the docker-git backend container running and reach it via DOCKER_GIT_API_URL (default ${apiBaseUrl}).`
106+
return Match.value(kind).pipe(
107+
Match.when("socket-permission-denied", (): ReadonlyArray<string> => [
108+
"docker-git is intentionally backed by the host Docker daemon via /var/run/docker.sock.",
109+
"Add this user to the docker group, switch to rootless Docker, or fix /var/run/docker.sock ownership (root:docker, mode 660).",
110+
"After changing groups, log out and back in (or run `newgrp docker`) so the new group membership applies.",
111+
apiHint
112+
]),
113+
Match.when("daemon-unreachable", (): ReadonlyArray<string> => [
114+
"Start the Docker daemon (e.g. `sudo systemctl start docker`) or set DOCKER_HOST to a reachable endpoint.",
115+
apiHint
116+
]),
117+
Match.when("docker-cli-missing", (): ReadonlyArray<string> => [
118+
"Install Docker Engine or Docker Desktop and ensure `docker` is on PATH.",
119+
apiHint
120+
]),
121+
Match.when("unknown", (): ReadonlyArray<string> => [
122+
"Tried direct Docker and passwordless sudo Docker; both probes failed.",
123+
"Grant this user direct Docker access (docker group/rootless Docker), configure passwordless sudo for docker, or",
124+
apiHint
125+
]),
126+
Match.exhaustive
127+
)
128+
}
129+
130+
// PURITY: CORE
131+
// EFFECT: pure function over diagnostic context
132+
// INVARIANT: emitted message names the failure mode, the contract, and the next action
133+
export const renderDockerAccessDeniedMessage = (context: DockerAccessDeniedContext): string => {
134+
const directKind = classifyDockerProbeFailure(context.directProbe)
135+
const dockerHostLine = context.dockerHost !== null && context.dockerHost.length > 0
136+
? `DOCKER_HOST: ${context.dockerHost}`
137+
: "DOCKER_HOST: unset (defaults to unix:///var/run/docker.sock)"
138+
139+
return [
140+
renderHeadlineForKind(directKind),
141+
"Runtime contract: docker-git is host-Docker-backed; the controller container talks to the daemon via /var/run/docker.sock.",
142+
...renderRemediationForKind(directKind, context.apiBaseUrl),
143+
"Probe commands: docker info; sudo -n docker info",
144+
renderProbeLine("Direct probe", context.directProbe),
145+
renderProbeLine("Sudo probe", context.sudoProbe),
146+
dockerHostLine
147+
].join("\n")
148+
}

packages/app/src/docker-git/controller-docker.ts

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,17 @@ import { Effect } from "effect"
77
import {
88
runCommandCapture,
99
runCommandExitCode,
10-
runCommandExitCodeStreaming
10+
runCommandExitCodeStreaming,
11+
runCommandWithCapturedOutput
1112
} from "./frontend-lib/shell/command-runner.js"
1213

13-
import { type DockerNetworkIps, parseDockerNetworkIps, uniqueStrings } from "./controller-reachability.js"
14+
import { type DockerProbeOutcome, renderDockerAccessDeniedMessage } from "./controller-docker-diagnostics.js"
15+
import {
16+
type DockerNetworkIps,
17+
parseDockerNetworkIps,
18+
resolveConfiguredApiBaseUrl,
19+
uniqueStrings
20+
} from "./controller-reachability.js"
1421
import {
1522
computeLocalControllerRevision,
1623
controllerRevisionEnvKey,
@@ -66,14 +73,6 @@ const currentProcessEnv = (): Readonly<Record<string, string>> =>
6673
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined)
6774
)
6875

69-
const renderDockerAccessDeniedMessage = (): string =>
70-
[
71-
"docker-git host CLI cannot access Docker from the client process.",
72-
"Tried direct Docker and passwordless sudo Docker.",
73-
"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.",
74-
"Probe commands: docker info; sudo -n docker info"
75-
].join("\n")
76-
7776
const runExitCode = (
7877
command: string,
7978
args: ReadonlyArray<string>
@@ -89,25 +88,62 @@ const runExitCode = (
8988
})
9089
)
9190

91+
type ProbeFailure = {
92+
readonly _tag: "ProbeFailure"
93+
readonly outcome: DockerProbeOutcome
94+
}
95+
96+
const captureProbeOutcome = (
97+
command: string,
98+
args: ReadonlyArray<string>
99+
): Effect.Effect<DockerProbeOutcome, never, CommandExecutor.CommandExecutor> =>
100+
runCommandWithCapturedOutput(
101+
{ cwd: process.cwd(), command, args },
102+
[0],
103+
(exitCode, output): ProbeFailure => ({
104+
_tag: "ProbeFailure",
105+
outcome: { exitCode, stderr: output }
106+
})
107+
).pipe(
108+
Effect.match({
109+
onFailure: (error) =>
110+
"outcome" in error
111+
? error.outcome
112+
: { exitCode: 127, stderr: String(error) },
113+
onSuccess: () => ({ exitCode: 0, stderr: "" })
114+
})
115+
)
116+
92117
export const resolveDockerCommand = (): Effect.Effect<
93118
ReadonlyArray<string>,
94119
ControllerBootstrapError,
95120
CommandExecutor.CommandExecutor
96121
> =>
97-
runExitCode("docker", ["info"]).pipe(
98-
Effect.flatMap((dockerInfoExit) => {
99-
if (dockerInfoExit === 0) {
100-
return Effect.succeed<ReadonlyArray<string>>(["docker"])
101-
}
102-
return runExitCode("sudo", ["-n", "docker", "info"]).pipe(
103-
Effect.flatMap((sudoDockerInfoExit) =>
104-
sudoDockerInfoExit === 0
105-
? Effect.succeed<ReadonlyArray<string>>(["sudo", "-n", "docker"])
106-
: Effect.fail(controllerBootstrapError(renderDockerAccessDeniedMessage()))
122+
Effect.gen(function*(_) {
123+
const directProbe = yield* _(captureProbeOutcome("docker", ["info"]))
124+
if (directProbe.exitCode === 0) {
125+
return ["docker"] as ReadonlyArray<string>
126+
}
127+
128+
const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"]))
129+
if (sudoProbe.exitCode === 0) {
130+
return ["sudo", "-n", "docker"] as ReadonlyArray<string>
131+
}
132+
133+
const dockerHostRaw = process.env["DOCKER_HOST"]?.trim() ?? ""
134+
return yield* _(
135+
Effect.fail(
136+
controllerBootstrapError(
137+
renderDockerAccessDeniedMessage({
138+
directProbe,
139+
sudoProbe,
140+
apiBaseUrl: resolveConfiguredApiBaseUrl(),
141+
dockerHost: dockerHostRaw.length > 0 ? dockerHostRaw : null
142+
})
107143
)
108144
)
109-
})
110-
)
145+
)
146+
})
111147

112148
type DockerInvocation = {
113149
readonly command: string

0 commit comments

Comments
 (0)