Skip to content

Commit abe81ca

Browse files
authored
fix: handle docker socket permission failures early (#20)
* fix(shell): fail fast on docker socket permission errors * fix(lib): reduce error renderer complexity --------- Co-authored-by: skulidropek <skulidropek@users.noreply.github.com>
1 parent 0328a83 commit abe81ca

7 files changed

Lines changed: 150 additions & 4 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export const program = pipe(
117117
Effect.logWarning(renderError(error)),
118118
Effect.asVoid
119119
)),
120+
Effect.catchTag("DockerAccessError", logWarningAndExit),
120121
Effect.catchTag("DockerCommandError", logWarningAndExit),
121122
Effect.catchTag("AuthError", logWarningAndExit),
122123
Effect.catchTag("CommandFailedError", logWarningAndExit),

packages/lib/src/shell/docker.ts

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import * as Command from "@effect/platform/Command"
2-
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import * as CommandExecutor from "@effect/platform/CommandExecutor"
33
import { ExitCode } from "@effect/platform/CommandExecutor"
44
import type { PlatformError } from "@effect/platform/Error"
55
import { Effect, pipe } from "effect"
6+
import * as Chunk from "effect/Chunk"
7+
import * as Stream from "effect/Stream"
68

79
import { runCommandCapture, runCommandWithExitCodes } from "./command-runner.js"
8-
import { CommandFailedError, DockerCommandError } from "./errors.js"
10+
import { CommandFailedError, DockerAccessError, type DockerAccessIssue, DockerCommandError } from "./errors.js"
911

1012
const composeSpec = (cwd: string, args: ReadonlyArray<string>) => ({
1113
cwd,
@@ -27,6 +29,79 @@ const parseInspectNetworkEntry = (line: string): ReadonlyArray<readonly [string,
2729
return [entry]
2830
}
2931

32+
const collectUint8Array = (chunks: Chunk.Chunk<Uint8Array>): Uint8Array =>
33+
Chunk.reduce(chunks, new Uint8Array(), (acc, curr) => {
34+
const next = new Uint8Array(acc.length + curr.length)
35+
next.set(acc)
36+
next.set(curr, acc.length)
37+
return next
38+
})
39+
40+
const permissionDeniedPattern = /permission denied/i
41+
42+
// CHANGE: classify docker daemon access failure into deterministic typed reasons
43+
// WHY: allow callers to render actionable recovery guidance for socket permission issues
44+
// QUOTE(ТЗ): "docker-git handles Docker socket permission problems predictably"
45+
// REF: issue-11
46+
// SOURCE: n/a
47+
// FORMAT THEOREM: ∀m: classify(m) ∈ {"PermissionDenied","DaemonUnavailable"}
48+
// PURITY: CORE
49+
// EFFECT: Effect<DockerAccessIssue, never, never>
50+
// INVARIANT: classification is stable for equal input
51+
// COMPLEXITY: O(|m|)
52+
export const classifyDockerAccessIssue = (message: string): DockerAccessIssue =>
53+
permissionDeniedPattern.test(message) ? "PermissionDenied" : "DaemonUnavailable"
54+
55+
// CHANGE: verify docker daemon access before compose/auth flows
56+
// WHY: fail fast on socket permission errors instead of cascading into opaque command failures
57+
// QUOTE(ТЗ): "permission denied to /var/run/docker.sock"
58+
// REF: issue-11
59+
// SOURCE: n/a
60+
// FORMAT THEOREM: ∀cwd: access(cwd)=ok ∨ DockerAccessError
61+
// PURITY: SHELL
62+
// EFFECT: Effect<void, DockerAccessError | PlatformError, CommandExecutor>
63+
// INVARIANT: non-zero docker info exit always maps to DockerAccessError
64+
// COMPLEXITY: O(command)
65+
export const ensureDockerDaemonAccess = (
66+
cwd: string
67+
): Effect.Effect<void, DockerAccessError | PlatformError, CommandExecutor.CommandExecutor> =>
68+
Effect.scoped(
69+
Effect.gen(function*(_) {
70+
const executor = yield* _(CommandExecutor.CommandExecutor)
71+
const process = yield* _(
72+
executor.start(
73+
pipe(
74+
Command.make("docker", "info"),
75+
Command.workingDirectory(cwd),
76+
Command.stdin("pipe"),
77+
Command.stdout("pipe"),
78+
Command.stderr("pipe")
79+
)
80+
)
81+
)
82+
83+
const stderrBytes = yield* _(
84+
pipe(process.stderr, Stream.runCollect, Effect.map((chunks) => collectUint8Array(chunks)))
85+
)
86+
const exitCode = Number(yield* _(process.exitCode))
87+
88+
if (exitCode === 0) {
89+
return
90+
}
91+
92+
const stderr = new TextDecoder("utf-8").decode(stderrBytes).trim()
93+
const details = stderr.length > 0 ? stderr : `docker info failed with exit code ${exitCode}`
94+
return yield* _(
95+
Effect.fail(
96+
new DockerAccessError({
97+
issue: classifyDockerAccessIssue(details),
98+
details
99+
})
100+
)
101+
)
102+
})
103+
)
104+
30105
const runCompose = (
31106
cwd: string,
32107
args: ReadonlyArray<string>,

packages/lib/src/shell/errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ export class DockerCommandError extends Data.TaggedError("DockerCommandError")<{
2525
readonly exitCode: number
2626
}> {}
2727

28+
export type DockerAccessIssue = "PermissionDenied" | "DaemonUnavailable"
29+
30+
export class DockerAccessError extends Data.TaggedError("DockerAccessError")<{
31+
readonly issue: DockerAccessIssue
32+
readonly details: string
33+
}> {}
34+
2835
export class CloneFailedError extends Data.TaggedError("CloneFailedError")<{
2936
readonly repoUrl: string
3037
readonly repoRef: string

packages/lib/src/usecases/actions/create-project.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { Effect } from "effect"
66

77
import type { CreateCommand } from "../../core/domain.js"
88
import { deriveRepoPathParts } from "../../core/domain.js"
9-
import type { CloneFailedError, DockerCommandError, FileExistsError, PortProbeError } from "../../shell/errors.js"
9+
import { ensureDockerDaemonAccess } from "../../shell/docker.js"
10+
import type {
11+
CloneFailedError,
12+
DockerAccessError,
13+
DockerCommandError,
14+
FileExistsError,
15+
PortProbeError
16+
} from "../../shell/errors.js"
1017
import { logDockerAccessInfo } from "../access-log.js"
1118
import { applyGithubForkConfig } from "../github-fork.js"
1219
import { defaultProjectsRoot } from "../menu-helpers.js"
@@ -21,6 +28,7 @@ type CreateProjectRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.
2128
type CreateProjectError =
2229
| FileExistsError
2330
| CloneFailedError
31+
| DockerAccessError
2432
| DockerCommandError
2533
| PortProbeError
2634
| PlatformError
@@ -76,6 +84,10 @@ const runCreateProject = (
7684
command: CreateCommand
7785
): Effect.Effect<void, CreateProjectError, CreateProjectRuntime> =>
7886
Effect.gen(function*(_) {
87+
if (command.runUp) {
88+
yield* _(ensureDockerDaemonAccess(process.cwd()))
89+
}
90+
7991
const ctx = makeCreateContext(path, process.cwd())
8092
const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir))
8193

packages/lib/src/usecases/errors.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
CommandFailedError,
88
ConfigDecodeError,
99
ConfigNotFoundError,
10+
DockerAccessError,
1011
DockerCommandError,
1112
FileExistsError,
1213
InputCancelledError,
@@ -18,6 +19,7 @@ export type AppError =
1819
| ParseError
1920
| FileExistsError
2021
| CloneFailedError
22+
| DockerAccessError
2123
| DockerCommandError
2224
| ConfigNotFoundError
2325
| ConfigDecodeError
@@ -38,6 +40,11 @@ const isParseError = (error: AppError): error is ParseError =>
3840
error._tag === "InvalidOption" ||
3941
error._tag === "UnexpectedArgument"
4042

43+
const renderDockerAccessHeadline = (issue: DockerAccessError["issue"]): string =>
44+
issue === "PermissionDenied"
45+
? "Cannot access Docker daemon socket: permission denied."
46+
: "Cannot connect to Docker daemon."
47+
4148
const renderPrimaryError = (error: NonParseError): string | null => {
4249
if (error._tag === "FileExistsError") {
4350
return `File already exists: ${error.path} (use --force to overwrite)`
@@ -50,6 +57,15 @@ const renderPrimaryError = (error: NonParseError): string | null => {
5057
].join("\n")
5158
}
5259

60+
if (error._tag === "DockerAccessError") {
61+
return [
62+
renderDockerAccessHeadline(error.issue),
63+
"Hint: ensure Docker daemon is running and current user can access the docker socket.",
64+
"Hint: if you use rootless Docker, set DOCKER_HOST to your user socket (for example unix:///run/user/$UID/docker.sock).",
65+
`Details: ${error.details}`
66+
].join("\n")
67+
}
68+
5369
if (error._tag === "CloneFailedError") {
5470
return `Clone failed for ${error.repoUrl} (${error.repoRef}) into ${error.targetDir}`
5571
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import { classifyDockerAccessIssue } from "../../src/shell/docker.js"
4+
5+
describe("classifyDockerAccessIssue", () => {
6+
it("classifies socket permission failures as PermissionDenied", () => {
7+
const issue = classifyDockerAccessIssue(
8+
'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get "http://%2Fvar%2Frun%2Fdocker.sock/v1.51/info": dial unix /var/run/docker.sock: connect: permission denied'
9+
)
10+
11+
expect(issue).toBe("PermissionDenied")
12+
})
13+
14+
it("classifies non-permission docker access failures as DaemonUnavailable", () => {
15+
const issue = classifyDockerAccessIssue(
16+
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"
17+
)
18+
19+
expect(issue).toBe("DaemonUnavailable")
20+
})
21+
})

packages/lib/tests/usecases/errors.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "@effect/vitest"
22

3-
import { DockerCommandError } from "../../src/shell/errors.js"
3+
import { DockerAccessError, DockerCommandError } from "../../src/shell/errors.js"
44
import { renderError } from "../../src/usecases/errors.js"
55

66
describe("renderError", () => {
@@ -10,4 +10,18 @@ describe("renderError", () => {
1010
expect(message).toContain("docker compose failed with exit code 1")
1111
expect(message).toContain("/var/run/docker.sock")
1212
})
13+
14+
it("renders actionable recovery for DockerAccessError", () => {
15+
const message = renderError(
16+
new DockerAccessError({
17+
issue: "PermissionDenied",
18+
details:
19+
'permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock'
20+
})
21+
)
22+
23+
expect(message).toContain("permission denied")
24+
expect(message).toContain("DOCKER_HOST")
25+
expect(message).toContain("Details:")
26+
})
1327
})

0 commit comments

Comments
 (0)