Skip to content
Open
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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
27 changes: 27 additions & 0 deletions packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
148 changes: 148 additions & 0 deletions packages/app/src/docker-git/controller-docker-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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<string>): boolean =>
needles.some((needle) => haystack.includes(needle))

const isCliMissingExitCode = (exitCode: number): boolean => exitCode === 127

const cliMissingMarkers: ReadonlyArray<string> = [
"command not found",
"not found",
"no such file or directory"
]

const permissionMarkers: ReadonlyArray<string> = [
"permission denied",
"access is denied",
"got permission denied"
]

const daemonDownMarkers: ReadonlyArray<string> = [
"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<string> => {
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<string> => [
"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<string> => [
"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<string> => [
"Install Docker Engine or Docker Desktop and ensure `docker` is on PATH.",
apiHint
]),
Match.when("unknown", (): ReadonlyArray<string> => [
"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")
}
80 changes: 58 additions & 22 deletions packages/app/src/docker-git/controller-docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -66,14 +73,6 @@ const currentProcessEnv = (): Readonly<Record<string, string>> =>
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<string>
Expand All @@ -89,25 +88,62 @@ const runExitCode = (
})
)

type ProbeFailure = {
readonly _tag: "ProbeFailure"
readonly outcome: DockerProbeOutcome
}

const captureProbeOutcome = (
command: string,
args: ReadonlyArray<string>
): Effect.Effect<DockerProbeOutcome, never, CommandExecutor.CommandExecutor> =>
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<string>,
ControllerBootstrapError,
CommandExecutor.CommandExecutor
> =>
runExitCode("docker", ["info"]).pipe(
Effect.flatMap((dockerInfoExit) => {
if (dockerInfoExit === 0) {
return Effect.succeed<ReadonlyArray<string>>(["docker"])
}
return runExitCode("sudo", ["-n", "docker", "info"]).pipe(
Effect.flatMap((sudoDockerInfoExit) =>
sudoDockerInfoExit === 0
? Effect.succeed<ReadonlyArray<string>>(["sudo", "-n", "docker"])
: Effect.fail(controllerBootstrapError(renderDockerAccessDeniedMessage()))
Effect.gen(function*(_) {
const directProbe = yield* _(captureProbeOutcome("docker", ["info"]))
if (directProbe.exitCode === 0) {
return ["docker"]
}

const sudoProbe = yield* _(captureProbeOutcome("sudo", ["-n", "docker", "info"]))
if (sudoProbe.exitCode === 0) {
return ["sudo", "-n", "docker"]
}

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
Expand Down
Loading