diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index a1c9ab2..33fd097 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -1054,13 +1054,21 @@ struct ComposeRun: AsyncParsableCommand { environment[String(parts[0])] = String(parts[1]) } + // Docker treats `--entrypoint` as shell form: it is split on spaces, the + // first token overrides the executable and any remaining tokens lead the + // command argv. Apple's `container` CLI only accepts a single token here. + let exec = ComposeOrchestrator.resolveExec( + entrypoint: entrypoint?.split(separator: " ").map(String.init) ?? [], + command: command + ) + let containerConfig = ContainerConfig( image: image, - command: command, + command: exec.command, environment: environment, detach: detach, workingDir: workdir, - entrypoint: entrypoint + entrypoint: exec.entrypoint ) let container = try await engine.run(containerConfig) diff --git a/Sources/Mocker/Commands/Create.swift b/Sources/Mocker/Commands/Create.swift index 1fba695..7910dee 100644 --- a/Sources/Mocker/Commands/Create.swift +++ b/Sources/Mocker/Commands/Create.swift @@ -348,10 +348,18 @@ struct Create: AsyncParsableCommand { let restartPolicy = RestartPolicy(rawValue: restart) ?? .no + // Docker treats `--entrypoint` as shell form: it is split on spaces, the + // first token overrides the executable and any remaining tokens lead the + // command argv. Apple's `container` CLI only accepts a single token here. + let exec = ComposeOrchestrator.resolveExec( + entrypoint: entrypoint?.split(separator: " ").map(String.init) ?? [], + command: command + ) + let containerConfig = ContainerConfig( name: name, image: image, - command: command, + command: exec.command, environment: environment, ports: ports, volumes: volumes, @@ -364,7 +372,7 @@ struct Create: AsyncParsableCommand { hostname: hostname, restartPolicy: restartPolicy, user: user, - entrypoint: entrypoint, + entrypoint: exec.entrypoint, platform: platform, virtualization: virtualization, kernel: kernel, diff --git a/Sources/Mocker/Commands/Run.swift b/Sources/Mocker/Commands/Run.swift index fa22c5c..98b6365 100644 --- a/Sources/Mocker/Commands/Run.swift +++ b/Sources/Mocker/Commands/Run.swift @@ -396,10 +396,18 @@ struct Run: AsyncParsableCommand { let restartPolicy = RestartPolicy(rawValue: restart) ?? .no + // Docker treats `--entrypoint` as shell form: it is split on spaces, the + // first token overrides the executable and any remaining tokens lead the + // command argv. Apple's `container` CLI only accepts a single token here. + let exec = ComposeOrchestrator.resolveExec( + entrypoint: entrypoint?.split(separator: " ").map(String.init) ?? [], + command: command + ) + let containerConfig = ContainerConfig( name: name, image: image, - command: command, + command: exec.command, environment: environment, ports: ports, volumes: volumes, @@ -412,7 +420,7 @@ struct Run: AsyncParsableCommand { hostname: hostname, restartPolicy: restartPolicy, user: user, - entrypoint: entrypoint, + entrypoint: exec.entrypoint, platform: platform, virtualization: virtualization, kernel: kernel, diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 9685e4d..64ac30e 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -506,6 +506,7 @@ public struct ComposeService: Sendable { public var image: String? public var build: ComposeBuild? public var command: [String] + public var entrypoint: [String] = [] public var environment: [String: String] public var ports: [String] public var volumes: [String] @@ -531,7 +532,7 @@ public struct ComposeService: Sendable { public var restartPolicyWindow: String? private enum HashCodingKeys: String, CodingKey { - case name, image, command, environment, ports, volumes, networks + case name, image, command, entrypoint, environment, ports, volumes, networks case restart, labels, hostname, workingDir case memLimit, cpus, memReservation, cpusReservation, memSwapLimit case shmSize, pidsLimit @@ -568,6 +569,7 @@ public struct ComposeService: Sendable { } let dependsOn = parseDependsOn(dict["depends_on"]) let command = parseCommand(dict["command"]) + let entrypoint = parseCommand(dict["entrypoint"]) let labels = (dict["labels"] as? [String: String]) ?? [:] var build: ComposeBuild? @@ -617,6 +619,7 @@ public struct ComposeService: Sendable { image: dict["image"] as? String, build: build, command: command, + entrypoint: entrypoint, environment: environment, ports: ports, volumes: volumes, @@ -654,6 +657,7 @@ public struct ComposeService: Sendable { image: other.image ?? image, build: other.build ?? build, command: other.command.isEmpty ? command : other.command, + entrypoint: other.entrypoint.isEmpty ? entrypoint : other.entrypoint, environment: environment.merging(other.environment) { _, new in new }, ports: other.ports.isEmpty ? ports : other.ports, volumes: other.volumes.isEmpty ? volumes : other.volumes, @@ -833,6 +837,7 @@ extension ComposeService: Encodable { try c.encode(name, forKey: .name) try c.encodeIfPresent(image, forKey: .image) try c.encode(command, forKey: .command) + try c.encode(entrypoint, forKey: .entrypoint) try c.encode(environment, forKey: .environment) try c.encode(ports, forKey: .ports) try c.encode(volumes, forKey: .volumes) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 5929bd3..0f3c466 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -525,10 +525,17 @@ public actor ComposeOrchestrator { let volumes = try Self.resolveVolumeMounts(service.volumes, projectDir: projectDir) + // Docker's exec model concatenates entrypoint + command into one argv. + // Apple's `container` CLI can only override the executable + // (`--entrypoint `, a single token, no args), so the first + // entrypoint element is used as the executable and any remaining + // elements are spliced ahead of the command args. + let exec = Self.resolveExec(entrypoint: service.entrypoint, command: service.command) + let config = ContainerConfig( name: containerName, image: imageName, - command: service.command, + command: exec.command, environment: service.environment, ports: ports, volumes: volumes, @@ -548,11 +555,12 @@ public actor ComposeOrchestrator { ) { _, new in new }, workingDir: service.workingDir, hostname: service.hostname, + restartPolicy: service.restart.flatMap { RestartPolicy(rawValue: $0) } ?? .no, + entrypoint: exec.entrypoint, // `restartPolicy` and `shmSize` (like the soft mem/cpu reservations) are stored on the // config for Docker surface parity, mirroring `run`/`create`. Apple's `container` CLI // currently exposes no `--restart`/`--shm-size` flags, so these are NOT enforced by the // runtime today — only `memory` (-m) and `cpus` (-c) are actually emitted. - restartPolicy: service.restart.flatMap { RestartPolicy(rawValue: $0) } ?? .no, shmSize: service.shmSize, memory: service.memLimit, cpus: service.cpus @@ -593,6 +601,25 @@ public actor ComposeOrchestrator { } return volumes } + + /// Mirror Docker's exec model for a compose service: the effective argv is the + /// entrypoint array concatenated with the command array. + /// + /// Apple's `container` CLI can only override the entrypoint executable via + /// `--entrypoint ` — a single token with no args (a value like + /// `/bin/sh -c` is treated as one executable path and fails to launch). + /// So the first entrypoint element becomes the executable and any remaining + /// elements are spliced ahead of the command args, preserving Docker's + /// `ENTRYPOINT [...] + CMD [...]` semantics. + public nonisolated static func resolveExec( + entrypoint: [String], + command: [String] + ) -> (entrypoint: String?, command: [String]) { + guard let first = entrypoint.first, !first.isEmpty else { + return (nil, command) + } + return (first, Array(entrypoint.dropFirst()) + command) + } } extension ComposeOrchestrator { /// Pure helper that returns one `ReconcileAction` per service in the project. diff --git a/Tests/MockerKitTests/ComposeFileTests.swift b/Tests/MockerKitTests/ComposeFileTests.swift index d0a5e6c..baad981 100644 --- a/Tests/MockerKitTests/ComposeFileTests.swift +++ b/Tests/MockerKitTests/ComposeFileTests.swift @@ -24,6 +24,62 @@ struct ComposeFileTests { #expect(compose.services["redis"]?.image == "redis:7") } + @Test("Parse entrypoint in list form") + func parseEntrypointList() throws { + let compose = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + entrypoint: + - /bin/sh + - -c + command: + - echo hello + """) + #expect(compose.services["app"]?.entrypoint == ["/bin/sh", "-c"]) + #expect(compose.services["app"]?.command == ["echo hello"]) + } + + @Test("Parse entrypoint in shell/string form, split on spaces") + func parseEntrypointString() throws { + let compose = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + entrypoint: /bin/sh -c + command: echo hello + """) + #expect(compose.services["app"]?.entrypoint == ["/bin/sh", "-c"]) + #expect(compose.services["app"]?.command == ["echo", "hello"]) + } + + @Test("Merge: later file's entrypoint wins; empty entrypoint does not clobber") + func mergeEntrypoint() throws { + let base = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + entrypoint: + - /bin/sh + - -c + """) + let overlay = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + entrypoint: + - /bin/echo + """) + let noEntrypoint = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + """) + + #expect(ComposeFile.merge([base, overlay]).services["app"]?.entrypoint == ["/bin/echo"]) + #expect(ComposeFile.merge([base, noEntrypoint]).services["app"]?.entrypoint == ["/bin/sh", "-c"]) + } + @Test("Merge overlays later files over earlier ones") func mergeOverlay() throws { let base = try ComposeFile.parse(""" @@ -953,6 +1009,29 @@ struct ComposeFileTests { // MARK: - Config hash (issue #59) + @Test("ComposeService.hash differs when entrypoint changes") + func hashDiffersOnEntrypointChange() throws { + let noEntrypoint = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + command: + - echo hi + """).services["app"]! + let withEntrypoint = try ComposeFile.parse(""" + services: + app: + image: alpine:latest + entrypoint: + - /bin/sh + - -c + command: + - echo hi + """).services["app"]! + + #expect(ComposeService.hash(of: noEntrypoint) != ComposeService.hash(of: withEntrypoint)) + } + @Test("ComposeService.hash returns sha256:<64 hex chars> for a fixed spec") func hashReturnsSha256Literal() throws { let svc = try ComposeFile.parse(""" diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index d5ff77f..12dc82a 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -157,6 +157,45 @@ struct ComposeOrchestratorTests { #expect(!ComposeService.imageMatches(img, tag: "other-app:latest")) } + // MARK: - Entrypoint resolution (compose `entrypoint:` support) + + @Test("Empty entrypoint leaves command untouched") + func resolveExecNoEntrypoint() { + let exec = ComposeOrchestrator.resolveExec(entrypoint: [], command: ["echo", "hi"]) + #expect(exec.entrypoint == nil) + #expect(exec.command == ["echo", "hi"]) + } + + @Test("Entrypoint with args: first element is the executable, rest lead the argv") + func resolveExecSplicesEntrypointArgs() { + let exec = ComposeOrchestrator.resolveExec( + entrypoint: ["/bin/sh", "-c"], + command: ["echo", "hello"] + ) + #expect(exec.entrypoint == "/bin/sh") + #expect(exec.command == ["-c", "echo", "hello"]) + } + + @Test("Single-element entrypoint overrides executable, keeps command argv") + func resolveExecSingleElement() { + let exec = ComposeOrchestrator.resolveExec( + entrypoint: ["/bin/echo"], + command: ["hello"] + ) + #expect(exec.entrypoint == "/bin/echo") + #expect(exec.command == ["hello"]) + } + + @Test("Entrypoint with empty first element is treated as absent") + func resolveExecEmptyFirstElement() { + let exec = ComposeOrchestrator.resolveExec( + entrypoint: [""], + command: ["echo", "hi"] + ) + #expect(exec.entrypoint == nil) + #expect(exec.command == ["echo", "hi"]) + } + // MARK: - Volume mount resolution (issue #49) @Test("Absolute bind mount included as-is")