|
| 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 | +} |
0 commit comments