Skip to content

Commit 739f5e5

Browse files
authored
Import --publish checks and data representation. (#872)
- Closes #871. - Simplify and improve port range checks. - Add count field to `PublishPort` to keep config size small for large port ranges.
1 parent a99cb4a commit 739f5e5

File tree

7 files changed

+371
-114
lines changed

7 files changed

+371
-114
lines changed

Sources/ContainerClient/Core/PublishPort.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,35 @@ public struct PublishPort: Sendable, Codable {
4040
public let hostAddress: String
4141

4242
/// The port number of the proxy listener on the host
43-
public let hostPort: Int
43+
public let hostPort: UInt16
4444

4545
/// The port number of the container listener
46-
public let containerPort: Int
46+
public let containerPort: UInt16
4747

4848
/// The network protocol for the proxy
4949
public let proto: PublishProtocol
5050

51+
/// The number of ports to publish
52+
public let count: UInt16
53+
5154
/// Creates a new port forwarding specification.
52-
public init(hostAddress: String, hostPort: Int, containerPort: Int, proto: PublishProtocol) {
55+
public init(hostAddress: String, hostPort: UInt16, containerPort: UInt16, proto: PublishProtocol, count: UInt16) {
5356
self.hostAddress = hostAddress
5457
self.hostPort = hostPort
5558
self.containerPort = containerPort
5659
self.proto = proto
60+
self.count = count
61+
}
62+
63+
/// Create a configuration from the supplied Decoder, initializing missing
64+
/// values where possible to reasonable defaults.
65+
public init(from decoder: Decoder) throws {
66+
let container = try decoder.container(keyedBy: CodingKeys.self)
67+
68+
hostAddress = try container.decode(String.self, forKey: .hostAddress)
69+
hostPort = try container.decode(UInt16.self, forKey: .hostPort)
70+
containerPort = try container.decode(UInt16.self, forKey: .containerPort)
71+
proto = try container.decode(PublishProtocol.self, forKey: .proto)
72+
count = try container.decodeIfPresent(UInt16.self, forKey: .count) ?? 1
5773
}
5874
}

Sources/ContainerClient/Parser.swift

Lines changed: 67 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -560,18 +560,18 @@ public struct Parser {
560560
/// - Returns: Array of PublishPort objects
561561
/// - Throws: ContainerizationError if parsing fails
562562
public static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] {
563-
var sockets: [PublishPort] = []
563+
var publishPorts: [PublishPort] = []
564564

565565
// Process each raw port string
566566
for socket in rawPublishPorts {
567-
let parsedSockets = try Parser.publishPort(socket)
568-
sockets.append(contentsOf: parsedSockets)
567+
let publishPort = try Parser.publishPort(socket)
568+
publishPorts.append(publishPort)
569569
}
570-
return sockets
570+
return publishPorts
571571
}
572572

573-
// Parse a single `--publish-port` argument into a `[PublishPort]`.
574-
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 {
575575
let protoSplit = portText.split(separator: "/")
576576
let proto: PublishProtocol
577577
let addressAndPortText: String
@@ -607,99 +607,84 @@ public struct Parser {
607607
throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)")
608608
}
609609

610-
guard let hostPort = Int(hostPortText) else {
611-
let hostPortRangeStart: Int
612-
let hostPortRangeEnd: Int
613-
let containerPortRangeStart: Int
614-
let containerPortRangeEnd: Int
610+
let hostPortRangeStart: UInt16
611+
let hostPortRangeEnd: UInt16
612+
let containerPortRangeStart: UInt16
613+
let containerPortRangeEnd: UInt16
615614

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)")
615+
let hostPortParts = hostPortText.split(separator: "-")
616+
switch hostPortParts.count {
617+
case 1:
618+
guard let start = UInt16(hostPortParts[0]) else {
619+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
631620
}
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)")
621+
hostPortRangeStart = start
622+
hostPortRangeEnd = start
623+
case 2:
624+
guard let start = UInt16(hostPortParts[0]) else {
625+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
648626
}
649627

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)")
628+
guard let end = UInt16(hostPortParts[1]) else {
629+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
656630
}
657631

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)")
632+
hostPortRangeStart = start
633+
hostPortRangeEnd = end
634+
default:
635+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
636+
}
637+
638+
let containerPortParts = containerPortText.split(separator: "-")
639+
switch containerPortParts.count {
640+
case 1:
641+
guard let start = UInt16(containerPortParts[0]) else {
642+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
664643
}
665644

666-
let hostRange = hostPortRangeEnd - hostPortRangeStart
667-
let containerRange = containerPortRangeEnd - containerPortRangeStart
645+
containerPortRangeStart = start
646+
containerPortRangeEnd = start
647+
case 2:
648+
guard let start = UInt16(containerPortParts[0]) else {
649+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
650+
}
668651

669-
guard hostRange == containerRange else {
670-
throw ContainerizationError(.invalidArgument, message: "publish host and container port range are not equal \(addressAndPortText)")
652+
guard let end = UInt16(containerPortParts[1]) else {
653+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
671654
}
672655

673-
var publishPorts = [PublishPort]()
674-
for i in 0..<hostPortRangeEnd - hostPortRangeStart + 1 {
675-
let hostPort = hostPortRangeStart + i
676-
let containerPort = containerPortRangeStart + i
656+
containerPortRangeStart = start
657+
containerPortRangeEnd = end
658+
default:
659+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
660+
}
677661

678-
publishPorts.append(
679-
PublishPort(
680-
hostAddress: hostAddress,
681-
hostPort: hostPort,
682-
containerPort: containerPort,
683-
proto: proto
684-
)
685-
)
686-
}
662+
guard hostPortRangeStart > 1,
663+
hostPortRangeStart <= hostPortRangeEnd
664+
else {
665+
throw ContainerizationError(.invalidArgument, message: "invalid publish host port range: \(hostPortText)")
666+
}
687667

688-
return publishPorts
668+
guard containerPortRangeStart > 1,
669+
containerPortRangeStart <= containerPortRangeEnd
670+
else {
671+
throw ContainerizationError(.invalidArgument, message: "invalid publish container port range: \(containerPortText)")
689672
}
690673

691-
guard let containerPort = Int(containerPortText) else {
692-
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
674+
let hostCount = hostPortRangeEnd - hostPortRangeStart + 1
675+
let containerCount = containerPortRangeEnd - containerPortRangeStart + 1
676+
677+
guard hostCount == containerCount else {
678+
throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(addressAndPortText)")
693679
}
694680

695-
return [
696-
PublishPort(
697-
hostAddress: hostAddress,
698-
hostPort: hostPort,
699-
containerPort: containerPort,
700-
proto: proto
701-
)
702-
]
681+
return PublishPort(
682+
hostAddress: hostAddress,
683+
hostPort: hostPortRangeStart,
684+
containerPort: containerPortRangeStart,
685+
proto: proto,
686+
count: hostCount
687+
)
703688
}
704689

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

Sources/ContainerClient/Utility.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import Foundation
2424
import TerminalProgress
2525

2626
public struct Utility {
27+
static let publishedPortCountLimit = 64
28+
2729
private static let infraImages = [
2830
DefaultsStore.get(key: .defaultBuilderImage),
2931
DefaultsStore.get(key: .defaultInitImage),
@@ -70,6 +72,19 @@ public struct Utility {
7072
}
7173
}
7274

75+
public static func validPublishPorts(_ publishPorts: [PublishPort]) throws {
76+
var hostPorts = Set<UInt16>()
77+
for publishPort in publishPorts {
78+
for index in 0..<publishPort.count {
79+
let hostPort = publishPort.hostPort + index
80+
guard !hostPorts.contains(hostPort) else {
81+
throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap")
82+
}
83+
hostPorts.insert(hostPort)
84+
}
85+
}
86+
}
87+
7388
public static func containerConfigFromFlags(
7489
id: String,
7590
image: String,
@@ -222,6 +237,10 @@ public struct Utility {
222237
config.labels = try Parser.labels(management.labels)
223238

224239
config.publishedPorts = try Parser.publishPorts(management.publishPorts)
240+
guard config.publishedPorts.count <= publishedPortCountLimit else {
241+
throw ContainerizationError(.invalidArgument, message: "cannot exceed more than \(publishedPortCountLimit) port publish descriptors")
242+
}
243+
try validPublishPorts(config.publishedPorts)
225244

226245
// Parse --publish-socket arguments and add to container configuration
227246
// to enable socket forwarding from container to host.

Sources/Services/ContainerSandboxService/SandboxService.swift

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -676,36 +676,39 @@ public actor SandboxService {
676676

677677
private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws {
678678
var forwarders: [SocketForwarderResult] = []
679+
try Utility.validPublishPorts(publishedPorts)
679680
try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in
680681
for publishedPort in publishedPorts {
681-
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort))
682-
let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort))
683-
log.info(
684-
"creating forwarder for",
685-
metadata: [
686-
"proxy": "\(proxyAddress)",
687-
"server": "\(serverAddress)",
688-
"protocol": "\(publishedPort.proto)",
689-
])
690-
group.addTask {
691-
let forwarder: SocketForwarder
692-
switch publishedPort.proto {
693-
case .tcp:
694-
forwarder = try TCPForwarder(
695-
proxyAddress: proxyAddress,
696-
serverAddress: serverAddress,
697-
eventLoopGroup: self.eventLoopGroup,
698-
log: self.log
699-
)
700-
case .udp:
701-
forwarder = try UDPForwarder(
702-
proxyAddress: proxyAddress,
703-
serverAddress: serverAddress,
704-
eventLoopGroup: self.eventLoopGroup,
705-
log: self.log
706-
)
682+
for index in 0..<publishedPort.count {
683+
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort + index))
684+
let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort + index))
685+
log.info(
686+
"creating forwarder for",
687+
metadata: [
688+
"proxy": "\(proxyAddress)",
689+
"server": "\(serverAddress)",
690+
"protocol": "\(publishedPort.proto)",
691+
])
692+
group.addTask {
693+
let forwarder: SocketForwarder
694+
switch publishedPort.proto {
695+
case .tcp:
696+
forwarder = try TCPForwarder(
697+
proxyAddress: proxyAddress,
698+
serverAddress: serverAddress,
699+
eventLoopGroup: self.eventLoopGroup,
700+
log: self.log
701+
)
702+
case .udp:
703+
forwarder = try UDPForwarder(
704+
proxyAddress: proxyAddress,
705+
serverAddress: serverAddress,
706+
eventLoopGroup: self.eventLoopGroup,
707+
log: self.log
708+
)
709+
}
710+
return try await forwarder.run().get()
707711
}
708-
return try await forwarder.run().get()
709712
}
710713
}
711714
for try await result in group {

Tests/CLITests/Subcommands/Containers/TestCLICreate.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,51 @@ class TestCLICreateCommand: CLITest {
4646
#expect(inspectResp.networks[0].macAddress == expectedMAC, "expected MAC address \(expectedMAC), got \(inspectResp.networks[0].macAddress ?? "nil")")
4747
}
4848
}
49+
50+
@Test func testPublishPortParserMaxPorts() throws {
51+
let name = getTestName()
52+
var args: [String] = ["create", "--name", name]
53+
54+
let portCount = 64
55+
for i in 0..<portCount {
56+
args.append("--publish")
57+
args.append("127.0.0.1:\(8000 + i):\(9000 + i)")
58+
}
59+
60+
args.append("ghcr.io/linuxcontainers/alpine:3.20")
61+
args.append("echo")
62+
args.append("\"hello world\"")
63+
64+
#expect(throws: Never.self, "expected container create maximum port publishes to succeed") {
65+
let (_, error, status) = try run(arguments: args)
66+
defer { try? doRemove(name: name) }
67+
if status != 0 {
68+
throw CLIError.executionFailed("command failed: \(error)")
69+
}
70+
}
71+
}
72+
73+
@Test func testPublishPortParserTooManyPorts() throws {
74+
let name = getTestName()
75+
var args: [String] = ["create", "--name", name]
76+
77+
let portCount = 65
78+
for i in 0..<portCount {
79+
args.append("--publish")
80+
args.append("127.0.0.1:\(8000 + i):\(9000 + i)")
81+
}
82+
83+
args.append("ghcr.io/linuxcontainers/alpine:3.20")
84+
args.append("echo")
85+
args.append("\"hello world\"")
86+
87+
#expect(throws: CLIError.self, "expected container create more than maximum port publishes to fail") {
88+
let (_, error, status) = try run(arguments: args)
89+
defer { try? doRemove(name: name) }
90+
if status != 0 {
91+
throw CLIError.executionFailed("command failed: \(error)")
92+
}
93+
}
94+
}
95+
4996
}

0 commit comments

Comments
 (0)