diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index c6979aa..e113fdd 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -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() diff --git a/Sources/Mocker/ComposeArgNormalizer.swift b/Sources/Mocker/ComposeArgNormalizer.swift new file mode 100644 index 0000000..0cda7b0 --- /dev/null +++ b/Sources/Mocker/ComposeArgNormalizer.swift @@ -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 = ["-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.. 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. @@ -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 diff --git a/Sources/MockerKit/Container/ContainerEngine.swift b/Sources/MockerKit/Container/ContainerEngine.swift index 857b213..7d73314 100644 --- a/Sources/MockerKit/Container/ContainerEngine.swift +++ b/Sources/MockerKit/Container/ContainerEngine.swift @@ -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 } @@ -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] } diff --git a/Sources/MockerKit/Models/ContainerConfig.swift b/Sources/MockerKit/Models/ContainerConfig.swift index 87b489c..02aedd4 100644 --- a/Sources/MockerKit/Models/ContainerConfig.swift +++ b/Sources/MockerKit/Models/ContainerConfig.swift @@ -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) } } diff --git a/Tests/MockerKitTests/ComposeFileTests.swift b/Tests/MockerKitTests/ComposeFileTests.swift index f58b001..7753faa 100644 --- a/Tests/MockerKitTests/ComposeFileTests.swift +++ b/Tests/MockerKitTests/ComposeFileTests.swift @@ -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 = """ diff --git a/Tests/MockerKitTests/ContainerEngineTests.swift b/Tests/MockerKitTests/ContainerEngineTests.swift index 6512245..9769c44 100644 --- a/Tests/MockerKitTests/ContainerEngineTests.swift +++ b/Tests/MockerKitTests/ContainerEngineTests.swift @@ -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()) diff --git a/Tests/MockerTests/CLITests.swift b/Tests/MockerTests/CLITests.swift index 24928c8..045efd7 100644 --- a/Tests/MockerTests/CLITests.swift +++ b/Tests/MockerTests/CLITests.swift @@ -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"]) diff --git a/Tests/MockerTests/ComposeArgNormalizerTests.swift b/Tests/MockerTests/ComposeArgNormalizerTests.swift new file mode 100644 index 0000000..fd1afec --- /dev/null +++ b/Tests/MockerTests/ComposeArgNormalizerTests.swift @@ -0,0 +1,56 @@ +import Testing +@testable import Mocker + +@Suite("ComposeArgNormalizer Tests") +struct ComposeArgNormalizerTests { + + @Test("Relocates repeated -f before the subcommand to after it") + func relocateRepeatedFile() { + let out = ComposeArgNormalizer.reorder(["compose", "-f", "a.yaml", "-f", "b.yaml", "pull"]) + #expect(out == ["compose", "pull", "-f", "a.yaml", "-f", "b.yaml"]) + } + + @Test("Leaves flags already after the subcommand untouched") + func keepsTrailingFlags() { + let out = ComposeArgNormalizer.reorder(["compose", "pull", "-f", "a.yaml"]) + #expect(out == ["compose", "pull", "-f", "a.yaml"]) + } + + @Test("Relocates --project-name and preserves trailing subcommand flags") + func relocateProjectName() { + let out = ComposeArgNormalizer.reorder(["compose", "-p", "proj", "up", "-d"]) + #expect(out == ["compose", "up", "-p", "proj", "-d"]) + } + + @Test("Keeps the equals-form as a single token") + func equalsForm() { + let out = ComposeArgNormalizer.reorder(["compose", "-f=a.yaml", "config"]) + #expect(out == ["compose", "config", "-f=a.yaml"]) + } + + @Test("Mixed before/after -f both end up after the subcommand in order") + func mixedBeforeAfter() { + let out = ComposeArgNormalizer.reorder(["compose", "-f", "a.yaml", "pull", "-f", "b.yaml"]) + #expect(out == ["compose", "pull", "-f", "a.yaml", "-f", "b.yaml"]) + } + + @Test("Non-compose argv is returned untouched") + func nonCompose() { + let out = ComposeArgNormalizer.reorder(["run", "-p", "80:80", "nginx"]) + #expect(out == ["run", "-p", "80:80", "nginx"]) + } + + @Test("Compose with no subcommand verb is returned untouched") + func noSubcommand() { + let out = ComposeArgNormalizer.reorder(["compose", "-f", "a.yaml"]) + #expect(out == ["compose", "-f", "a.yaml"]) + } + + @Test("A flag whose value looks like a flag does not swallow the next flag") + func missingValueNotSwallowed() { + // No subcommand verb after -f/-p, so it returns unchanged — but the point + // is that -p is not consumed as the value of -f. + let out = ComposeArgNormalizer.reorder(["compose", "-f", "-p"]) + #expect(out == ["compose", "-f", "-p"]) + } +} diff --git a/Tests/MockerTests/FlagEnforcementTests.swift b/Tests/MockerTests/FlagEnforcementTests.swift index 361b592..60f6460 100644 --- a/Tests/MockerTests/FlagEnforcementTests.swift +++ b/Tests/MockerTests/FlagEnforcementTests.swift @@ -20,6 +20,33 @@ struct FlagEnforcementTests { #expect(pm.portProtocol == .udp) } + @Test("PortMapping parse with host IP prefix") + func testPortMappingWithHostIP() throws { + let pm = try PortMapping.parse("127.0.0.1:8080:80") + #expect(pm.hostPort == 8080) + #expect(pm.containerPort == 80) + #expect(pm.portProtocol == .tcp) + } + + @Test("PortMapping parse bare container port maps host to same port") + func testPortMappingBareContainerPort() throws { + let pm = try PortMapping.parse("80") + #expect(pm.hostPort == 80) + #expect(pm.containerPort == 80) + + let udp = try PortMapping.parse("53/udp") + #expect(udp.hostPort == 53) + #expect(udp.containerPort == 53) + #expect(udp.portProtocol == .udp) + } + + @Test("PortMapping parse rejects port ranges") + func testPortMappingRejectsRanges() { + #expect(throws: MockerError.self) { + _ = try PortMapping.parse("8000-8010:9000-9010") + } + } + @Test("VolumeMount parse bind mount") func testVolumeMountBind() throws { let vm = try VolumeMount.parse("/host/path:/container/path")