Skip to content
This repository was archived by the owner on Nov 24, 2024. It is now read-only.

Commit 58d0a76

Browse files
Merge pull request #7 from jackgene/feature/port-forwarding
Create container with port forwarding
2 parents 0c874f7 + 61ab30d commit 58d0a76

File tree

5 files changed

+150
-12
lines changed

5 files changed

+150
-12
lines changed

Sources/DockerClientSwift/APIs/DockerClient+Container.swift

+55-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Network
23
import NIO
34

45
extension DockerClient {
@@ -36,10 +37,31 @@ extension DockerClient {
3637
/// - Parameters:
3738
/// - image: Instance of an `Image`.
3839
/// - commands: Override the default commands from the image. Default `nil`.
40+
/// - portBindings: Port bindings (forwardings). See ``PortBinding`` for details. Default `[]`.
3941
/// - Throws: Errors that can occur when executing the request.
4042
/// - Returns: Returns an `EventLoopFuture` of a `Container`.
41-
public func createContainer(image: Image, commands: [String]?=nil) throws -> EventLoopFuture<Container> {
42-
return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands))
43+
public func createContainer(image: Image, commands: [String]?=nil, portBindings: [PortBinding]=[]) throws -> EventLoopFuture<Container> {
44+
let hostConfig: CreateContainerEndpoint.CreateContainerBody.HostConfig?
45+
let exposedPorts: [String: CreateContainerEndpoint.CreateContainerBody.Empty]?
46+
if portBindings.isEmpty {
47+
exposedPorts = nil
48+
hostConfig = nil
49+
} else {
50+
var exposedPortsBuilder: [String: CreateContainerEndpoint.CreateContainerBody.Empty] = [:]
51+
var portBindingsByContainerPort: [String: [CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding]] = [:]
52+
for portBinding in portBindings {
53+
let containerPort: String = "\(portBinding.containerPort)/\(portBinding.networkProtocol)"
54+
55+
exposedPortsBuilder[containerPort] = CreateContainerEndpoint.CreateContainerBody.Empty()
56+
var hostAddresses = portBindingsByContainerPort[containerPort, default: []]
57+
hostAddresses.append(
58+
CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding(HostIp: "\(portBinding.hostIP)", HostPort: "\(portBinding.hostPort)"))
59+
portBindingsByContainerPort[containerPort] = hostAddresses
60+
}
61+
exposedPorts = exposedPortsBuilder
62+
hostConfig = CreateContainerEndpoint.CreateContainerBody.HostConfig(PortBindings: portBindingsByContainerPort)
63+
}
64+
return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands, exposedPorts: exposedPorts, hostConfig: hostConfig))
4365
.flatMap({ response in
4466
try self.get(containerByNameOrId: response.Id)
4567
})
@@ -48,10 +70,36 @@ extension DockerClient {
4870
/// Starts a container. Before starting it needs to be created.
4971
/// - Parameter container: Instance of a created `Container`.
5072
/// - Throws: Errors that can occur when executing the request.
51-
/// - Returns: Returns an `EventLoopFuture` when the container is started.
52-
public func start(container: Container) throws -> EventLoopFuture<Void> {
73+
/// - Returns: Returns an `EventLoopFuture` of active actual `PortBinding`s when the container is started.
74+
public func start(container: Container) throws -> EventLoopFuture<[PortBinding]> {
5375
return try client.run(StartContainerEndpoint(containerId: container.id.value))
54-
.map({ _ in Void() })
76+
.flatMap { _ in
77+
try client.run(InspectContainerEndpoint(nameOrId: container.id.value))
78+
.flatMapThrowing { response in
79+
try response.NetworkSettings.Ports.flatMap { (containerPortSpec, bindings) in
80+
let containerPortParts = containerPortSpec.split(separator: "/", maxSplits: 2)
81+
guard
82+
let containerPort: UInt16 = UInt16(containerPortParts[0]),
83+
let networkProtocol: NetworkProtocol = NetworkProtocol(rawValue: String(containerPortParts[1]))
84+
else { throw DockerError.message(#"unable to parse port/protocol from NetworkSettings.Ports key - "\#(containerPortSpec)""#) }
85+
86+
return try (bindings ?? []).compactMap { binding in
87+
guard
88+
let hostIP: IPAddress = IPv4Address(binding.HostIp) ?? IPv6Address(binding.HostIp)
89+
else {
90+
throw DockerError.message(#"unable to parse IPAddress from NetworkSettings.Ports[].HostIp - "\#(binding.HostIp)""#)
91+
}
92+
guard
93+
let hostPort = UInt16(binding.HostPort)
94+
else {
95+
throw DockerError.message(#"unable to parse port number from NetworkSettings.Ports[].HostPort - "\#(binding.HostPort)""#)
96+
}
97+
98+
return PortBinding(hostIP: hostIP, hostPort: hostPort, containerPort: containerPort, networkProtocol: networkProtocol)
99+
}
100+
}
101+
}
102+
}
55103
}
56104

57105
/// Stops a container. Before stopping it needs to be created and started..
@@ -105,7 +153,7 @@ extension DockerClient {
105153
repositoryTag = repoTag
106154
}
107155
let image = Image(id: .init(response.Image), digest: digest, repositoryTags: repositoryTag.map({ [$0]}), createdAt: nil)
108-
return Container(id: .init(response.Id), image: image, createdAt: Date.parseDockerDate(response.Created)!, names: [response.Name], state: response.State.Status, command: response.Config.Cmd.joined(separator: " "))
156+
return Container(id: .init(response.Id), image: image, createdAt: Date.parseDockerDate(response.Created)!, names: [response.Name], state: response.State.Status, command: (response.Config.Cmd ?? []).joined(separator: " "))
109157
}
110158
}
111159

@@ -134,7 +182,7 @@ extension Container {
134182
/// - Parameter client: A `DockerClient` instance that is used to perform the request.
135183
/// - Throws: Errors that can occur when executing the request.
136184
/// - Returns: Returns an `EventLoopFuture` when the container is started.
137-
public func start(on client: DockerClient) throws -> EventLoopFuture<Void> {
185+
public func start(on client: DockerClient) throws -> EventLoopFuture<[PortBinding]> {
138186
try client.containers.start(container: self)
139187
}
140188

Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift

+15-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ struct CreateContainerEndpoint: Endpoint {
1010
private let imageName: String
1111
private let commands: [String]?
1212

13-
init(imageName: String, commands: [String]?=nil) {
13+
init(imageName: String, commands: [String]?=nil, exposedPorts: [String: CreateContainerBody.Empty]?=nil, hostConfig: CreateContainerBody.HostConfig?=nil) {
1414
self.imageName = imageName
1515
self.commands = commands
16-
self.body = .init(Image: imageName, Cmd: commands)
16+
self.body = .init(Image: imageName, Cmd: commands, ExposedPorts: exposedPorts, HostConfig: hostConfig)
1717
}
1818

1919
var path: String {
@@ -23,6 +23,19 @@ struct CreateContainerEndpoint: Endpoint {
2323
struct CreateContainerBody: Codable {
2424
let Image: String
2525
let Cmd: [String]?
26+
let ExposedPorts: [String: Empty]?
27+
let HostConfig: HostConfig?
28+
29+
struct Empty: Codable {}
30+
31+
struct HostConfig: Codable {
32+
let PortBindings: [String: [PortBinding]?]
33+
34+
struct PortBinding: Codable {
35+
let HostIp: String?
36+
let HostPort: String?
37+
}
38+
}
2639
}
2740

2841
struct CreateContainerResponse: Codable {

Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift

+11-1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ struct InspectContainerEndpoint: Endpoint {
2222
let Image: String
2323
let Created: String
2424
let State: StateResponse
25+
let NetworkSettings: NetworkSettings
2526
// TODO: Add additional fields
2627

2728
struct StateResponse: Codable {
@@ -30,7 +31,16 @@ struct InspectContainerEndpoint: Endpoint {
3031
}
3132

3233
struct ConfigResponse: Codable {
33-
let Cmd: [String]
34+
let Cmd: [String]?
35+
}
36+
37+
struct NetworkSettings: Codable {
38+
let Ports: [String: [PortBinding]?]
39+
40+
struct PortBinding: Codable {
41+
let HostIp: String
42+
let HostPort: String
43+
}
3444
}
3545
}
3646
}

Sources/DockerClientSwift/Models/Container.swift

+28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import Network
23

34
/// Representation of a container.
45
/// Some actions can be performed on an instance.
@@ -12,3 +13,30 @@ public struct Container {
1213
}
1314

1415
extension Container: Codable {}
16+
17+
/// Representation of a port binding
18+
public struct PortBinding {
19+
public var hostIP: IPAddress
20+
public var hostPort: UInt16
21+
public var containerPort: UInt16
22+
public var networkProtocol: NetworkProtocol
23+
24+
/// Creates a PortBinding
25+
///
26+
/// - Parameters:
27+
/// - hostIP: The host IP address to map the connection to. Default `0.0.0.0`.
28+
/// - hostPort: The port on the Docker host to map connections to. `0` means map to a random available port. Default `0`.
29+
/// - containerPort: The port on the container to map connections from.
30+
/// - networkProtocol: The protocol (`tcp`/`udp`) to bind. Default `tcp`.
31+
public init(hostIP: IPAddress=IPv4Address.any, hostPort: UInt16=0, containerPort: UInt16, networkProtocol: NetworkProtocol = .tcp) {
32+
self.hostIP = hostIP
33+
self.hostPort = hostPort
34+
self.containerPort = containerPort
35+
self.networkProtocol = networkProtocol
36+
}
37+
}
38+
39+
public enum NetworkProtocol: String {
40+
case tcp
41+
case udp
42+
}

Tests/DockerClientTests/ContainerTests.swift

+41-2
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ final class ContainerTests: XCTestCase {
4343
func testStartingContainerAndRetrievingLogs() throws {
4444
let image = try client.images.pullImage(byName: "hello-world", tag: "latest").wait()
4545
let container = try client.containers.createContainer(image: image).wait()
46-
try container.start(on: client).wait()
46+
_ = try container.start(on: client).wait()
4747
let output = try container.logs(on: client).wait()
4848
// Depending on CPU architecture, step 2 of the log output may by:
4949
// 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
@@ -97,10 +97,49 @@ final class ContainerTests: XCTestCase {
9797
)
9898
}
9999

100+
func testStartingContainerForwardingToSpecificPort() throws {
101+
let image = try client.images.pullImage(byName: "nginxdemos/hello", tag: "plain-text").wait()
102+
let container = try client.containers.createContainer(image: image, portBindings: [PortBinding(hostPort: 8080, containerPort: 80)]).wait()
103+
_ = try container.start(on: client).wait()
104+
105+
let sem: DispatchSemaphore = DispatchSemaphore(value: 0)
106+
let task = URLSession.shared.dataTask(with: URL(string: "http://localhost:8080")!) { (data, response, _) in
107+
let httpResponse = response as? HTTPURLResponse
108+
XCTAssertEqual(httpResponse?.statusCode, 200)
109+
XCTAssertEqual(httpResponse?.value(forHTTPHeaderField: "Content-Type"), "text/plain")
110+
XCTAssertTrue(String(data: data!, encoding: .utf8)!.hasPrefix("Server address"))
111+
112+
sem.signal()
113+
}
114+
task.resume()
115+
sem.wait()
116+
try container.stop(on: client).wait()
117+
}
118+
119+
func testStartingContainerForwardingToRandomPort() throws {
120+
let image = try client.images.pullImage(byName: "nginxdemos/hello", tag: "plain-text").wait()
121+
let container = try client.containers.createContainer(image: image, portBindings: [PortBinding(containerPort: 80)]).wait()
122+
let portBindings = try container.start(on: client).wait()
123+
let randomPort = portBindings[0].hostPort
124+
125+
let sem: DispatchSemaphore = DispatchSemaphore(value: 0)
126+
let task = URLSession.shared.dataTask(with: URL(string: "http://localhost:\(randomPort)")!) { (data, response, _) in
127+
let httpResponse = response as? HTTPURLResponse
128+
XCTAssertEqual(httpResponse?.statusCode, 200)
129+
XCTAssertEqual(httpResponse?.value(forHTTPHeaderField: "Content-Type"), "text/plain")
130+
XCTAssertTrue(String(data: data!, encoding: .utf8)!.hasPrefix("Server address"))
131+
132+
sem.signal()
133+
}
134+
task.resume()
135+
sem.wait()
136+
try container.stop(on: client).wait()
137+
}
138+
100139
func testPruneContainers() throws {
101140
let image = try client.images.pullImage(byName: "nginx", tag: "latest").wait()
102141
let container = try client.containers.createContainer(image: image).wait()
103-
try container.start(on: client).wait()
142+
_ = try container.start(on: client).wait()
104143
try container.stop(on: client).wait()
105144

106145
let pruned = try client.containers.prune().wait()

0 commit comments

Comments
 (0)