Skip to content
Merged
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
12 changes: 10 additions & 2 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 10 additions & 2 deletions Sources/Mocker/Commands/Create.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -364,7 +372,7 @@ struct Create: AsyncParsableCommand {
hostname: hostname,
restartPolicy: restartPolicy,
user: user,
entrypoint: entrypoint,
entrypoint: exec.entrypoint,
platform: platform,
virtualization: virtualization,
kernel: kernel,
Expand Down
12 changes: 10 additions & 2 deletions Sources/Mocker/Commands/Run.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -412,7 +420,7 @@ struct Run: AsyncParsableCommand {
hostname: hostname,
restartPolicy: restartPolicy,
user: user,
entrypoint: entrypoint,
entrypoint: exec.entrypoint,
platform: platform,
virtualization: virtualization,
kernel: kernel,
Expand Down
7 changes: 6 additions & 1 deletion Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 29 additions & 2 deletions Sources/MockerKit/Compose/ComposeOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cmd>`, 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,
Expand All @@ -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
Expand Down Expand Up @@ -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 <cmd>` — 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.
Expand Down
79 changes: 79 additions & 0 deletions Tests/MockerKitTests/ComposeFileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down Expand Up @@ -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("""
Expand Down
39 changes: 39 additions & 0 deletions Tests/MockerKitTests/ComposeOrchestratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading