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
25 changes: 17 additions & 8 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,30 @@ struct ComposeCommand: AsyncParsableCommand {
// MARK: - Shared Options

struct ComposeOptions: ParsableArguments {
@Option(name: [.customShort("f"), .long], help: "Compose file path")
var file: String?
@Option(name: [.customShort("f"), .long], parsing: .singleValue,
help: "Compose file path (repeatable; later files override earlier)")
var files: [String] = []

@Option(name: [.customShort("p"), .customLong("project-name")], help: "Project name")
var projectName: String?

func loadCompose() throws -> (ComposeFile, String) {
guard let path = file ?? ComposeFile.findDefault() else {
let searched = ComposeFile.defaultFileNames.joined(separator: ", ")
throw MockerError.composeFileNotFound("No compose file found. Searched for: \(searched)")
let paths: [String]
if files.isEmpty {
guard let def = ComposeFile.findDefault() else {
let searched = ComposeFile.defaultFileNames.joined(separator: ", ")
throw MockerError.composeFileNotFound("No compose file found. Searched for: \(searched)")
}
paths = [def]
} else {
paths = files
}
let composeFile = try ComposeFile.load(from: path)

// Derive project name from directory if not specified
let project = projectName ?? URL(fileURLWithPath: path)
let loaded = try paths.map { try ComposeFile.load(from: $0) }
let composeFile = ComposeFile.merge(loaded)

// Derive project name from the first file's directory if not specified.
let project = projectName ?? URL(fileURLWithPath: paths[0])
.deletingLastPathComponent()
.lastPathComponent
.lowercased()
Expand Down
74 changes: 74 additions & 0 deletions Sources/Mocker/ComposeArgNormalizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation

/// Reorders a top-level `mocker compose` argument vector so that Docker-style
/// global flags placed BEFORE the subcommand (e.g. `compose -f a.yaml -f b.yaml pull`)
/// are relocated to AFTER the subcommand token, where swift-argument-parser's
/// per-subcommand `@OptionGroup` can actually parse them.
///
/// Only the compose-level global options `-f`/`--file` and `-p`/`--project-name`
/// are relocated; everything else is left exactly where it is so the parser's
/// normal validation and error messages are preserved. This is a pure function
/// so it can be unit-tested without spawning the CLI.
enum ComposeArgNormalizer {
/// Flags that take a separate value token and may legitimately appear before
/// the compose subcommand (Docker places them there).
private static let valueFlags: Set<String> = ["-f", "--file", "-p", "--project-name"]

static func reorder(_ args: [String]) -> [String] {
guard args.first == "compose" else { return args }

var relocated: [String] = [] // global flags to move after the subcommand
var leading: [String] = [] // other pre-subcommand tokens (left untouched)
var index = 1
var subcommandIndex: Int?

while index < args.count {
let token = args[index]

// The first non-flag token is the subcommand verb.
if !token.hasPrefix("-") {
subcommandIndex = index
break
}

// Equals-form (`--file=a.yaml` / `-f=a.yaml`): relocate as a single token.
if let eq = token.firstIndex(of: "="),
valueFlags.contains(String(token[token.startIndex..<eq])) {
relocated.append(token)
index += 1
continue
}

if valueFlags.contains(token) {
relocated.append(token)
// Consume the following value token unless it is itself a flag
// (missing value — let ArgumentParser report it normally).
if index + 1 < args.count, !args[index + 1].hasPrefix("-") {
relocated.append(args[index + 1])
index += 2
} else {
index += 1
}
continue
}

// Unknown pre-subcommand flag: leave it in place so the parser errors
// exactly as it does today (no behaviour change for these).
leading.append(token)
index += 1
}

// No subcommand verb found (e.g. `compose -f a.yaml` with no verb): leave
// args untouched so ArgumentParser produces its normal help/usage output.
guard let subIdx = subcommandIndex else { return args }

var result: [String] = ["compose"]
result.append(contentsOf: leading)
result.append(args[subIdx])
result.append(contentsOf: relocated)
if subIdx + 1 < args.count {
result.append(contentsOf: args[(subIdx + 1)...])
}
return result
}
}
19 changes: 19 additions & 0 deletions Sources/Mocker/MockerCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,23 @@ struct MockerCLI: AsyncParsableCommand {
Proxy.self,
]
)

/// Custom entry point that preprocesses argv before ArgumentParser sees it.
/// Docker-style global compose flags placed before the subcommand
/// (`compose -f a.yaml pull`) are relocated to after the subcommand token,
/// since ArgumentParser only parses a subcommand's options after that token.
/// See `ComposeArgNormalizer`.
static func main() async {
let argv = ComposeArgNormalizer.reorder(Array(CommandLine.arguments.dropFirst()))
do {
var command = try parseAsRoot(argv)
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
exit(withError: error)
}
}
}
43 changes: 43 additions & 0 deletions Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,27 @@ public struct ComposeFile: Sendable {
let filteredServices = services.filter { included.contains($0.key) }
return ComposeFile(services: filteredServices, networks: networks, volumes: volumes)
}

/// Merge multiple compose files in order, matching `docker compose -f a -f b`
/// overlay semantics: later files override earlier ones. A new service is
/// inserted; an existing service is field-merged (see `ComposeService.merged`).
/// Networks and volumes are unioned with later definitions winning on key
/// collision. A single-file input is returned unchanged.
public static func merge(_ files: [ComposeFile]) -> ComposeFile {
guard var result = files.first else { return ComposeFile() }
for overlay in files.dropFirst() {
for (name, service) in overlay.services {
if let base = result.services[name] {
result.services[name] = base.merged(with: service)
} else {
result.services[name] = service
}
}
result.networks.merge(overlay.networks) { _, new in new }
result.volumes.merge(overlay.volumes) { _, new in new }
}
return result
}
}

/// A service definition in a compose file.
Expand Down Expand Up @@ -245,6 +266,28 @@ public struct ComposeService: Sendable {
image ?? "\(projectName)-\(name):latest"
}

/// Overlay `other` onto `self` (other wins) for `docker compose` multi-file
/// merge. Scalars take the later value when present; `environment` and
/// `labels` are field-merged (later wins on key collision); list-valued
/// fields are replaced by the later file unless it is empty.
func merged(with other: ComposeService) -> ComposeService {
ComposeService(
name: name,
image: other.image ?? image,
build: other.build ?? build,
command: other.command.isEmpty ? command : other.command,
environment: environment.merging(other.environment) { _, new in new },
ports: other.ports.isEmpty ? ports : other.ports,
volumes: other.volumes.isEmpty ? volumes : other.volumes,
networks: other.networks.isEmpty ? networks : other.networks,
dependsOn: other.dependsOn.isEmpty ? dependsOn : other.dependsOn,
restart: other.restart ?? restart,
labels: labels.merging(other.labels) { _, new in new },
hostname: other.hostname ?? hostname,
workingDir: other.workingDir ?? workingDir
)
}

/// Decide how this service's image should be obtained, per the Compose spec.
///
/// When `build:` is present (and not disabled via `--no-build`) the image is
Expand Down
20 changes: 12 additions & 8 deletions Sources/MockerKit/Container/ContainerEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,10 @@ public actor ContainerEngine {
let info = try await fetchContainerInfo(id: assignedID, name: name, config: containerConfig)
try await store.save(info)

// Start port proxies if -p mappings were requested and we got an IP
if !containerConfig.ports.isEmpty, !info.networkAddress.isEmpty {
try? await portProxy.start(
containerID: info.id,
ports: containerConfig.ports,
containerIP: info.networkAddress
)
}
// Ports are now published natively by the container runtime (see
// buildRunArguments). The legacy user-space PortProxy is no longer started;
// portProxy.stop() is still invoked on stop/remove to clean up any proxies
// left behind by an older mocker version.

return info
}
Expand All @@ -91,6 +87,14 @@ public actor ContainerEngine {
}
}

// Publish ports natively via the container runtime. Apple's `container run`
// supports `-p [host-ip:]host-port:container-port[/protocol]` and wires
// host-reachable forwarding through vmnet — this is what actually makes
// published ports reachable, for both `mocker run -p` and `compose up`.
for port in containerConfig.ports {
args += ["-p", "\(port.hostPort):\(port.containerPort)/\(port.portProtocol.rawValue)"]
}

if let workingDir = containerConfig.workingDir, !workingDir.isEmpty {
args += ["-w", workingDir]
}
Expand Down
36 changes: 29 additions & 7 deletions Sources/MockerKit/Models/ContainerConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,42 @@ public struct PortMapping: Codable, Sendable, CustomStringConvertible {
"\(hostPort):\(containerPort)/\(portProtocol.rawValue)"
}

/// Parse a port mapping string like "8080:80" or "8080:80/udp".
/// Parse a port mapping string. Accepts the common Docker/Compose forms:
/// "80" -> same host and container port
/// "8080:80" -> explicit host:container
/// "8080:80/udp" -> with protocol
/// "127.0.0.1:8080:80" -> host-ip:host:container (the host IP is accepted
/// but not bound separately; publishing host:container
/// already reaches localhost)
/// Port ranges (e.g. "8000-8010:9000-9010") are not yet supported and throw.
public static func parse(_ value: String) throws -> PortMapping {
let parts = value.split(separator: "/")
let proto: PortProtocol = parts.count > 1 ? (parts[1] == "udp" ? .udp : .tcp) : .tcp

let portParts = parts[0].split(separator: ":")
guard portParts.count == 2,
let host = UInt16(portParts[0]),
let container = UInt16(portParts[1])
else {
switch portParts.count {
case 1:
// Bare container port. Docker would assign a random host port; mocker maps
// host == container for predictable, scriptable behaviour.
guard let port = UInt16(portParts[0]) else {
throw MockerError.invalidPortMapping(value)
}
return PortMapping(hostPort: port, containerPort: port, portProtocol: proto)
case 2:
guard let host = UInt16(portParts[0]), let container = UInt16(portParts[1]) else {
throw MockerError.invalidPortMapping(value)
}
return PortMapping(hostPort: host, containerPort: container, portProtocol: proto)
case 3:
// portParts[0] is the host IP (e.g. 127.0.0.1); it is not persisted because
// `container run -p host:container` already binds a host-reachable port.
guard let host = UInt16(portParts[1]), let container = UInt16(portParts[2]) else {
throw MockerError.invalidPortMapping(value)
}
return PortMapping(hostPort: host, containerPort: container, portProtocol: proto)
default:
throw MockerError.invalidPortMapping(value)
}

return PortMapping(hostPort: host, containerPort: container, portProtocol: proto)
}
}

Expand Down
42 changes: 42 additions & 0 deletions Tests/MockerKitTests/ComposeFileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,48 @@ struct ComposeFileTests {
#expect(compose.services["redis"]?.image == "redis:7")
}

@Test("Merge overlays later files over earlier ones")
func mergeOverlay() throws {
let base = try ComposeFile.parse("""
services:
web:
image: nginx:latest
environment:
A: "1"
B: "1"
""")
let overlay = try ComposeFile.parse("""
services:
web:
image: nginx:alpine
environment:
B: "2"
db:
image: postgres:16
""")

let merged = ComposeFile.merge([base, overlay])

// Later file wins on scalars; new service is added.
#expect(merged.services["web"]?.image == "nginx:alpine")
#expect(merged.services["db"]?.image == "postgres:16")
// environment is field-merged with later winning on conflict.
#expect(merged.services["web"]?.environment["A"] == "1")
#expect(merged.services["web"]?.environment["B"] == "2")
}

@Test("Merge of a single file returns it unchanged")
func mergeSingle() throws {
let only = try ComposeFile.parse("""
services:
web:
image: nginx:latest
""")
let merged = ComposeFile.merge([only])
#expect(merged.services.count == 1)
#expect(merged.services["web"]?.image == "nginx:latest")
}

@Test("Parse compose with environment as list")
func parseEnvironmentList() throws {
let yaml = """
Expand Down
35 changes: 35 additions & 0 deletions Tests/MockerKitTests/ContainerEngineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ struct ContainerEngineTests {
#expect(args.contains("/path/to/kernel"))
}

@Test("Run arguments publish TCP ports natively with -p")
func testRunArgumentsPublishTCPPort() {
let config = ContainerConfig(
image: "nginx:alpine",
ports: [PortMapping(hostPort: 18081, containerPort: 80)]
)

let args = ContainerEngine.buildRunArguments(name: "web", config: config)

let idx = args.firstIndex(of: "-p")
#expect(idx != nil)
if let idx { #expect(args[idx + 1] == "18081:80/tcp") }
}

@Test("Run arguments publish UDP ports with protocol suffix")
func testRunArgumentsPublishUDPPort() {
let config = ContainerConfig(
image: "coredns",
ports: [PortMapping(hostPort: 5353, containerPort: 53, portProtocol: .udp)]
)

let args = ContainerEngine.buildRunArguments(name: "dns", config: config)

let idx = args.firstIndex(of: "-p")
#expect(idx != nil)
if let idx { #expect(args[idx + 1] == "5353:53/udp") }
}

@Test("Run arguments omit -p when no ports requested")
func testRunArgumentsNoPorts() {
let config = ContainerConfig(image: "alpine")
let args = ContainerEngine.buildRunArguments(name: "bare", config: config)
#expect(!args.contains("-p"))
}

@Test("Container CLI resolver prefers Homebrew installation")
func testContainerCLIResolverPrefersHomebrew() throws {
let root = URL(fileURLWithPath: NSTemporaryDirectory())
Expand Down
13 changes: 13 additions & 0 deletions Tests/MockerTests/CLITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ struct CLITests {
#expect(command.image == "alpine")
}

@Test("Compose subcommand accepts repeated -f flags")
func composeRepeatedFile() throws {
let command = try ComposePull.parse(["-f", "a.yaml", "-f", "b.yaml"])
#expect(command.options.files == ["a.yaml", "b.yaml"])
}

@Test("Compose up parses -f together with a subcommand flag")
func composeUpFileAndFlag() throws {
let command = try ComposeUp.parse(["-f", "docker-compose.yml", "--detach"])
#expect(command.options.files == ["docker-compose.yml"])
#expect(command.detach == true)
}

@Test("Run command accepts nested virtualization flags")
func runVirtualizationFlags() throws {
let command = try Run.parse(["--virtualization", "--kernel", "/tmp/vmlinux", "ubuntu:latest"])
Expand Down
Loading
Loading