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
2 changes: 1 addition & 1 deletion Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
for port in ports {
let resolvedPort = resolveVariable(port, with: environmentVariables)
runCommandArgs.append("-p")
runCommandArgs.append("0.0.0.0:\(resolvedPort)")
runCommandArgs.append(composePortToRunArg(resolvedPort))
}
}

Expand Down
42 changes: 42 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,48 @@ public func deriveProjectName(cwd: String) -> String {
return projectName
}

/// Converts Docker Compose port specification into a container run -p format.
/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol.
/// - Parameter portSpec: The port specification string from docker-compose.yml.
/// - Returns: A properly formatted port binding for `container run -p`.
public func composePortToRunArg(_ portSpec: String) -> String {
// Check for protocol suffix (e.g., "/tcp" or "/udp")
var protocolSuffix = ""
var portBody = portSpec
if let slashRange = portSpec.range(of: "/", options: [.backwards]) {
let afterSlash = portSpec[slashRange.lowerBound...]
let protocolPart = String(afterSlash)
if protocolPart == "/tcp" || protocolPart == "/udp" {
protocolSuffix = protocolPart
portBody = String(portSpec[..<slashRange.lowerBound])
}
}

let components = portBody.split(separator: ":", maxSplits: 3).map(String.init)
switch components.count {
case 1:
let containerPort = components[0]
return "0.0.0.0:\(containerPort):\(containerPort)\(protocolSuffix)"
case 2:
let hostPart = components[0]
let containerPart = components[1]
let hasIPv4 = hostPart.contains(".")
let hasIPv6 = hostPart.contains(":") && hostPart.hasPrefix("[") && hostPart.hasSuffix("]")
if hasIPv4 || hasIPv6 {
return "\(hostPart):\(containerPart)\(protocolSuffix)"
} else {
return "0.0.0.0:\(hostPart):\(containerPart)\(protocolSuffix)"
}
case 3:
let ipPart = components[0]
let hostPart = components[1]
let containerPart = components[2]
return "\(ipPart):\(hostPart):\(containerPart)\(protocolSuffix)"
default:
return portSpec
}
}

extension String: @retroactive Error {}

/// A structure representing the result of a command-line process execution.
Expand Down
40 changes: 40 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,46 @@ struct ComposeUpTests {
#expect(appContainer.configuration.resources.cpus == 1)
#expect(appContainer.configuration.resources.memoryInBytes == 512.mib())
}

@Test("Test compose up with explicit IP port mapping")
func testComposeUpWithExplicitIPPortMapping() async throws {
let yaml = """
version: "3.8"
services:
web:
image: nginx:alpine
ports:
- "127.0.0.1:18081:80"
"""

let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)

var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)])
try await composeUp.run()

var containers = try await ClientContainer.list()
.filter({
$0.configuration.id.contains(project.name)
})

guard let webContainer = containers.first(where: { $0.configuration.id == "\(project.name)-web" }) else {
throw Errors.containerNotFound
}

#expect(webContainer.status == .running)
#expect(webContainer.configuration.publishedPorts.map({ "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" }) == ["127.0.0.1:18081:80"])

var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)])
try await composeDown.run()

containers = try await ClientContainer.list()
.filter({
$0.configuration.id.contains(project.name)
})

#expect(containers.count == 1)
#expect(containers.filter({ $0.status == .stopped }).count == 1)
}

enum Errors: Error {
case containerNotFound
Expand Down
50 changes: 49 additions & 1 deletion Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,52 @@ struct HelperFunctionsTests {
#expect(projectName == "_devcontainers")
}

}
@Test("Compose port - simple container port")
func testPortSimple() throws {
let result = composePortToRunArg("3000")
#expect(result == "0.0.0.0:3000:3000")
}

@Test("Compose port - host:container same port")
func testPortHostContainerSame() throws {
let result = composePortToRunArg("3000:3000")
#expect(result == "0.0.0.0:3000:3000")
}

@Test("Compose port - host:container different ports")
func testPortHostContainerDifferent() throws {
let result = composePortToRunArg("8080:3000")
#expect(result == "0.0.0.0:8080:3000")
}

@Test("Compose port - explicit IP binding IPv4")
func testPortIPv4Binding() throws {
let result = composePortToRunArg("127.0.0.1:5432:5432")
#expect(result == "127.0.0.1:5432:5432")
}

@Test("Compose port - explicit IP binding IPv6")
func testPortIPv6Binding() throws {
let result = composePortToRunArg("[::1]:3000:3000")
#expect(result == "[::1]:3000:3000")
}

@Test("Compose port - with protocol tcp")
func testPortWithProtocolTCP() throws {
let result = composePortToRunArg("3000:3000/tcp")
#expect(result == "0.0.0.0:3000:3000/tcp")
}

@Test("Compose port - explicit IP with protocol")
func testPortIPv4WithProtocol() throws {
let result = composePortToRunArg("127.0.0.1:5432:5432/tcp")
#expect(result == "127.0.0.1:5432:5432/tcp")
}

@Test("Compose port - explicit IP already with 0.0.0.0")
func testPortZeroZeroZeroZero() throws {
let result = composePortToRunArg("0.0.0.0:3000:3000")
#expect(result == "0.0.0.0:3000:3000")
}

}