Skip to content

Commit 5e6bd56

Browse files
authored
fix: handle port mappings with explicit IP bindings (#60)
* fix: handle port mappings with explicit IP bindings correctly The previous implementation blindly prepended '0.0.0.0:' to every port specification, causing invalid formats like '0.0.0.0:127.0.0.1:5432:5432' when the compose file already included an IP binding. Introduced composePortToRunArg helper function that: - Handles simple port (e.g., '3000') → '0.0.0.0:3000:3000' - Handles host:container pairs (e.g., '8080:3000') → '0.0.0.0:8080:3000' - Preserves explicit IP bindings (e.g., '127.0.0.1:5432:5432') as-is - Supports IPv6 bracket notation (e.g., '[::1]:3000:3000') - Preserves protocol suffixes (/tcp, /udp) Added comprehensive unit tests covering all port format variations. Fixes the 'invalid publish IPv4 address' error when using explicit IP bindings in docker-compose.yml. * chore: reduce diff noise for port binding fix Reapply the explicit IP port binding changes without indentation-only churn so the PR stays focused on behavior and easier to review. * test: add dynamic coverage for explicit IP port mapping Verify compose up/down behavior with an explicit host IP port binding so the runtime path is covered end to end.
1 parent dabb2d4 commit 5e6bd56

File tree

4 files changed

+132
-2
lines changed

4 files changed

+132
-2
lines changed

Sources/Container-Compose/Commands/ComposeUp.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -442,7 +442,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
442442
for port in ports {
443443
let resolvedPort = resolveVariable(port, with: environmentVariables)
444444
runCommandArgs.append("-p")
445-
runCommandArgs.append("0.0.0.0:\(resolvedPort)")
445+
runCommandArgs.append(composePortToRunArg(resolvedPort))
446446
}
447447
}
448448

Sources/Container-Compose/Helper Functions.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,48 @@ public func deriveProjectName(cwd: String) -> String {
104104
return projectName
105105
}
106106

107+
/// Converts Docker Compose port specification into a container run -p format.
108+
/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol.
109+
/// - Parameter portSpec: The port specification string from docker-compose.yml.
110+
/// - Returns: A properly formatted port binding for `container run -p`.
111+
public func composePortToRunArg(_ portSpec: String) -> String {
112+
// Check for protocol suffix (e.g., "/tcp" or "/udp")
113+
var protocolSuffix = ""
114+
var portBody = portSpec
115+
if let slashRange = portSpec.range(of: "/", options: [.backwards]) {
116+
let afterSlash = portSpec[slashRange.lowerBound...]
117+
let protocolPart = String(afterSlash)
118+
if protocolPart == "/tcp" || protocolPart == "/udp" {
119+
protocolSuffix = protocolPart
120+
portBody = String(portSpec[..<slashRange.lowerBound])
121+
}
122+
}
123+
124+
let components = portBody.split(separator: ":", maxSplits: 3).map(String.init)
125+
switch components.count {
126+
case 1:
127+
let containerPort = components[0]
128+
return "0.0.0.0:\(containerPort):\(containerPort)\(protocolSuffix)"
129+
case 2:
130+
let hostPart = components[0]
131+
let containerPart = components[1]
132+
let hasIPv4 = hostPart.contains(".")
133+
let hasIPv6 = hostPart.contains(":") && hostPart.hasPrefix("[") && hostPart.hasSuffix("]")
134+
if hasIPv4 || hasIPv6 {
135+
return "\(hostPart):\(containerPart)\(protocolSuffix)"
136+
} else {
137+
return "0.0.0.0:\(hostPart):\(containerPart)\(protocolSuffix)"
138+
}
139+
case 3:
140+
let ipPart = components[0]
141+
let hostPart = components[1]
142+
let containerPart = components[2]
143+
return "\(ipPart):\(hostPart):\(containerPart)\(protocolSuffix)"
144+
default:
145+
return portSpec
146+
}
147+
}
148+
107149
extension String: @retroactive Error {}
108150

109151
/// A structure representing the result of a command-line process execution.

Tests/Container-Compose-DynamicTests/ComposeUpTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,46 @@ struct ComposeUpTests {
267267
#expect(appContainer.configuration.resources.cpus == 1)
268268
#expect(appContainer.configuration.resources.memoryInBytes == 512.mib())
269269
}
270+
271+
@Test("Test compose up with explicit IP port mapping")
272+
func testComposeUpWithExplicitIPPortMapping() async throws {
273+
let yaml = """
274+
version: "3.8"
275+
services:
276+
web:
277+
image: nginx:alpine
278+
ports:
279+
- "127.0.0.1:18081:80"
280+
"""
281+
282+
let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)
283+
284+
var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)])
285+
try await composeUp.run()
286+
287+
var containers = try await ClientContainer.list()
288+
.filter({
289+
$0.configuration.id.contains(project.name)
290+
})
291+
292+
guard let webContainer = containers.first(where: { $0.configuration.id == "\(project.name)-web" }) else {
293+
throw Errors.containerNotFound
294+
}
295+
296+
#expect(webContainer.status == .running)
297+
#expect(webContainer.configuration.publishedPorts.map({ "\($0.hostAddress):\($0.hostPort):\($0.containerPort)" }) == ["127.0.0.1:18081:80"])
298+
299+
var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)])
300+
try await composeDown.run()
301+
302+
containers = try await ClientContainer.list()
303+
.filter({
304+
$0.configuration.id.contains(project.name)
305+
})
306+
307+
#expect(containers.count == 1)
308+
#expect(containers.filter({ $0.status == .stopped }).count == 1)
309+
}
270310

271311
enum Errors: Error {
272312
case containerNotFound

Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,52 @@ struct HelperFunctionsTests {
3232
#expect(projectName == "_devcontainers")
3333
}
3434

35-
}
35+
@Test("Compose port - simple container port")
36+
func testPortSimple() throws {
37+
let result = composePortToRunArg("3000")
38+
#expect(result == "0.0.0.0:3000:3000")
39+
}
40+
41+
@Test("Compose port - host:container same port")
42+
func testPortHostContainerSame() throws {
43+
let result = composePortToRunArg("3000:3000")
44+
#expect(result == "0.0.0.0:3000:3000")
45+
}
46+
47+
@Test("Compose port - host:container different ports")
48+
func testPortHostContainerDifferent() throws {
49+
let result = composePortToRunArg("8080:3000")
50+
#expect(result == "0.0.0.0:8080:3000")
51+
}
52+
53+
@Test("Compose port - explicit IP binding IPv4")
54+
func testPortIPv4Binding() throws {
55+
let result = composePortToRunArg("127.0.0.1:5432:5432")
56+
#expect(result == "127.0.0.1:5432:5432")
57+
}
58+
59+
@Test("Compose port - explicit IP binding IPv6")
60+
func testPortIPv6Binding() throws {
61+
let result = composePortToRunArg("[::1]:3000:3000")
62+
#expect(result == "[::1]:3000:3000")
63+
}
64+
65+
@Test("Compose port - with protocol tcp")
66+
func testPortWithProtocolTCP() throws {
67+
let result = composePortToRunArg("3000:3000/tcp")
68+
#expect(result == "0.0.0.0:3000:3000/tcp")
69+
}
70+
71+
@Test("Compose port - explicit IP with protocol")
72+
func testPortIPv4WithProtocol() throws {
73+
let result = composePortToRunArg("127.0.0.1:5432:5432/tcp")
74+
#expect(result == "127.0.0.1:5432:5432/tcp")
75+
}
76+
77+
@Test("Compose port - explicit IP already with 0.0.0.0")
78+
func testPortZeroZeroZeroZero() throws {
79+
let result = composePortToRunArg("0.0.0.0:3000:3000")
80+
#expect(result == "0.0.0.0:3000:3000")
81+
}
82+
83+
}

0 commit comments

Comments
 (0)