Skip to content

Commit 258b4d1

Browse files
committed
Add support for publish port ranges
1 parent 671725c commit 258b4d1

File tree

2 files changed

+140
-11
lines changed

2 files changed

+140
-11
lines changed

Sources/ContainerClient/Parser.swift

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,7 @@ public struct Parser {
554554
/// Parse --publish-port arguments into PublishPort objects
555555
/// The format of each argument is `[host-ip:]host-port:container-port[/protocol]`
556556
/// (e.g., "127.0.0.1:8080:80/tcp")
557+
/// host-port and container-port can be ranges (e.g., "127.0.0.1:3456-4567:3456-4567/tcp`
557558
///
558559
/// - Parameter rawPublishPorts: Array of port arguments
559560
/// - Returns: Array of PublishPort objects
@@ -563,14 +564,14 @@ public struct Parser {
563564

564565
// Process each raw port string
565566
for socket in rawPublishPorts {
566-
let parsedSocket = try Parser.publishPort(socket)
567-
sockets.append(parsedSocket)
567+
let parsedSockets = try Parser.publishPort(socket)
568+
sockets.append(contentsOf: parsedSockets)
568569
}
569570
return sockets
570571
}
571572

572-
// Parse a single `--publish-port` argument into a `PublishPort`.
573-
public static func publishPort(_ portText: String) throws -> PublishPort {
573+
// Parse a single `--publish-port` argument into a `[PublishPort]`.
574+
public static func publishPort(_ portText: String) throws -> [PublishPort] {
574575
let protoSplit = portText.split(separator: "/")
575576
let proto: PublishProtocol
576577
let addressAndPortText: String
@@ -607,19 +608,98 @@ public struct Parser {
607608
}
608609

609610
guard let hostPort = Int(hostPortText) else {
610-
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
611+
let hostPortRangeStart: Int
612+
let hostPortRangeEnd: Int
613+
let containerPortRangeStart: Int
614+
let containerPortRangeEnd: Int
615+
616+
let hostPortParts = hostPortText.split(separator: "-")
617+
switch hostPortParts.count {
618+
case 2:
619+
guard let start = Int(hostPortParts[0]) else {
620+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port \(hostPortText)")
621+
}
622+
623+
guard let end = Int(hostPortParts[1]) else {
624+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port \(hostPortText)")
625+
}
626+
627+
hostPortRangeStart = start
628+
hostPortRangeEnd = end
629+
default:
630+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port \(hostPortText)")
631+
}
632+
633+
let containerPortParts = containerPortText.split(separator: "-")
634+
switch containerPortParts.count {
635+
case 2:
636+
guard let start = Int(containerPortParts[0]) else {
637+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port \(containerPortText)")
638+
}
639+
640+
guard let end = Int(containerPortParts[1]) else {
641+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port \(containerPortText)")
642+
}
643+
644+
containerPortRangeStart = start
645+
containerPortRangeEnd = end
646+
default:
647+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port \(containerPortText)")
648+
}
649+
650+
guard hostPortRangeStart > 1,
651+
hostPortRangeEnd > 1,
652+
hostPortRangeStart < hostPortRangeEnd,
653+
hostPortRangeEnd > hostPortRangeStart
654+
else {
655+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port range \(hostPortText)")
656+
}
657+
658+
guard containerPortRangeStart > 1,
659+
containerPortRangeEnd > 1,
660+
containerPortRangeStart < containerPortRangeEnd,
661+
containerPortRangeEnd > containerPortRangeStart
662+
else {
663+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port range \(containerPortText)")
664+
}
665+
666+
let hostRange = hostPortRangeEnd - hostPortRangeStart
667+
let containerRange = containerPortRangeEnd - containerPortRangeStart
668+
669+
guard hostRange == containerRange else {
670+
throw ContainerizationError(.invalidArgument, message: "publish host and container port range are not equal \(addressAndPortText)")
671+
}
672+
673+
var publishPorts = [PublishPort]()
674+
for i in 0..<hostPortRangeEnd - hostPortRangeStart + 1 {
675+
let hostPort = hostPortRangeStart + i
676+
let containerPort = containerPortRangeStart + i
677+
678+
publishPorts.append(
679+
PublishPort(
680+
hostAddress: hostAddress,
681+
hostPort: hostPort,
682+
containerPort: containerPort,
683+
proto: proto
684+
)
685+
)
686+
}
687+
688+
return publishPorts
611689
}
612690

613691
guard let containerPort = Int(containerPortText) else {
614692
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
615693
}
616694

617-
return PublishPort(
618-
hostAddress: hostAddress,
619-
hostPort: hostPort,
620-
containerPort: containerPort,
621-
proto: proto
622-
)
695+
return [
696+
PublishPort(
697+
hostAddress: hostAddress,
698+
hostPort: hostPort,
699+
containerPort: containerPort,
700+
proto: proto
701+
)
702+
]
623703
}
624704

625705
/// Parse --publish-socket arguments into PublishSocket objects

Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,55 @@ class TestCLIRunCommand: CLITest {
527527
}
528528
}
529529

530+
@Test func testForwardTCPPortRange() async throws {
531+
let range = UInt16(10)
532+
for portOffset in 0..<range {
533+
let retries = 10
534+
let retryDelaySeconds = Int64(3)
535+
do {
536+
let name = getLowercasedTestName()
537+
let proxyIp = "127.0.0.1"
538+
let proxyPortStart = UInt16.random(in: 50000..<55000)
539+
let serverPortStart = UInt16.random(in: 55000..<60000)
540+
let proxyPortEnd = proxyPortStart + range
541+
let serverPortEnd = serverPortStart + range
542+
try doLongRun(
543+
name: name,
544+
image: "docker.io/library/python:alpine",
545+
args: ["--publish", "\(proxyIp):\(proxyPortStart)-\(proxyPortEnd):\(serverPortStart)-\(serverPortEnd)/tcp"],
546+
containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPortStart + portOffset)"])
547+
defer {
548+
try? doStop(name: name)
549+
}
550+
551+
let url = "http://\(proxyIp):\(proxyPortStart + portOffset)"
552+
var request = HTTPClientRequest(url: url)
553+
request.method = .GET
554+
let config = HTTPClient.Configuration(proxy: nil)
555+
let client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: config)
556+
defer { _ = client.shutdown() }
557+
var retriesRemaining = retries
558+
var success = false
559+
while !success && retriesRemaining > 0 {
560+
do {
561+
let response = try await client.execute(request, timeout: .seconds(retryDelaySeconds))
562+
try #require(response.status == .ok)
563+
success = true
564+
} catch {
565+
print("request to \(url) failed, error: \(error)")
566+
try await Task.sleep(for: .seconds(retryDelaySeconds))
567+
}
568+
retriesRemaining -= 1
569+
}
570+
#expect(success, "Request to \(url) failed after \(retries - retriesRemaining) retries")
571+
try doStop(name: name)
572+
} catch {
573+
Issue.record("failed to run container \(error)")
574+
return
575+
}
576+
}
577+
}
578+
530579
func getDefaultDomain() throws -> String? {
531580
let (output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"])
532581
try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)")

0 commit comments

Comments
 (0)