From 148456f1ea8f30156586dc18263fc795ab91f8eb Mon Sep 17 00:00:00 2001 From: Jack Leow Date: Sun, 19 Feb 2023 18:44:58 -0800 Subject: [PATCH 1/4] Initial port forwarding support implementation. --- .../APIs/DockerClient+Container.swift | 54 ++++++++++++++++--- .../Containers/CreateContainerEndpoint.swift | 14 ++++- .../Containers/InspectContainerEndpoint.swift | 10 ++++ .../DockerClientSwift/Models/Container.swift | 28 ++++++++++ Tests/DockerClientTests/ContainerTests.swift | 43 ++++++++++++++- 5 files changed, 139 insertions(+), 10 deletions(-) diff --git a/Sources/DockerClientSwift/APIs/DockerClient+Container.swift b/Sources/DockerClientSwift/APIs/DockerClient+Container.swift index 905c623..91d5eda 100644 --- a/Sources/DockerClientSwift/APIs/DockerClient+Container.swift +++ b/Sources/DockerClientSwift/APIs/DockerClient+Container.swift @@ -1,4 +1,5 @@ import Foundation +import Network import NIO extension DockerClient { @@ -36,10 +37,25 @@ extension DockerClient { /// - Parameters: /// - image: Instance of an `Image`. /// - commands: Override the default commands from the image. Default `nil`. + /// - portBindings: Port bindings (forwardings). See ``PortBinding`` for details. Default `[]`. /// - Throws: Errors that can occur when executing the request. /// - Returns: Returns an `EventLoopFuture` of a `Container`. - public func createContainer(image: Image, commands: [String]?=nil) throws -> EventLoopFuture { - return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands)) + public func createContainer(image: Image, commands: [String]?=nil, portBindings: [PortBinding]=[]) throws -> EventLoopFuture { + let hostConfig: CreateContainerEndpoint.CreateContainerBody.HostConfig? + if portBindings.isEmpty { + hostConfig = nil + } else { + var portBindingsByContainerPort: [String: [CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding]] = [:] + for portBinding in portBindings { + let containerPort: String = "\(portBinding.containerPort)/\(portBinding.networkProtocol)" + var hostAddresses = portBindingsByContainerPort[containerPort, default: []] + hostAddresses.append( + CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding(HostIp: "\(portBinding.hostIP)", HostPort: "\(portBinding.hostPort)")) + portBindingsByContainerPort[containerPort] = hostAddresses + } + hostConfig = CreateContainerEndpoint.CreateContainerBody.HostConfig(PortBindings: portBindingsByContainerPort) + } + return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands, hostConfig: hostConfig)) .flatMap({ response in try self.get(containerByNameOrId: response.Id) }) @@ -48,10 +64,36 @@ extension DockerClient { /// Starts a container. Before starting it needs to be created. /// - Parameter container: Instance of a created `Container`. /// - Throws: Errors that can occur when executing the request. - /// - Returns: Returns an `EventLoopFuture` when the container is started. - public func start(container: Container) throws -> EventLoopFuture { + /// - Returns: Returns an `EventLoopFuture` of active actual `PortBinding`s when the container is started. + public func start(container: Container) throws -> EventLoopFuture<[PortBinding]> { return try client.run(StartContainerEndpoint(containerId: container.id.value)) - .map({ _ in Void() }) + .flatMap { _ in + try client.run(InspectContainerEndpoint(nameOrId: container.id.value)) + .flatMapThrowing { response in + try response.NetworkSettings.Ports.flatMap { (containerPortSpec, bindings) in + let containerPortParts = containerPortSpec.split(separator: "/", maxSplits: 2) + guard + let containerPort: UInt16 = UInt16(containerPortParts[0]), + let networkProtocol: NetworkProtocol = NetworkProtocol(rawValue: String(containerPortParts[1])) + else { throw DockerError.message(#"unable to parse port/protocol from NetworkSettings.Ports key - "\#(containerPortSpec)""#) } + + return try (bindings ?? []).compactMap { binding in + guard + let hostIP: IPAddress = IPv4Address(binding.HostIp) ?? IPv6Address(binding.HostIp) + else { + throw DockerError.message(#"unable to parse IPAddress from NetworkSettings.Ports[].HostIp - "\#(binding.HostIp)""#) + } + guard + let hostPort = UInt16(binding.HostPort) + else { + throw DockerError.message(#"unable to parse port number from NetworkSettings.Ports[].HostPort - "\#(binding.HostPort)""#) + } + + return PortBinding(hostIP: hostIP, hostPort: hostPort, containerPort: containerPort, networkProtocol: networkProtocol) + } + } + } + } } /// Stops a container. Before stopping it needs to be created and started.. @@ -134,7 +176,7 @@ extension Container { /// - Parameter client: A `DockerClient` instance that is used to perform the request. /// - Throws: Errors that can occur when executing the request. /// - Returns: Returns an `EventLoopFuture` when the container is started. - public func start(on client: DockerClient) throws -> EventLoopFuture { + public func start(on client: DockerClient) throws -> EventLoopFuture<[PortBinding]> { try client.containers.start(container: self) } diff --git a/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift b/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift index c9b0934..02459e3 100644 --- a/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift +++ b/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift @@ -10,10 +10,10 @@ struct CreateContainerEndpoint: Endpoint { private let imageName: String private let commands: [String]? - init(imageName: String, commands: [String]?=nil) { + init(imageName: String, commands: [String]?=nil, hostConfig: CreateContainerBody.HostConfig?=nil) { self.imageName = imageName self.commands = commands - self.body = .init(Image: imageName, Cmd: commands) + self.body = .init(Image: imageName, Cmd: commands, HostConfig: hostConfig) } var path: String { @@ -23,6 +23,16 @@ struct CreateContainerEndpoint: Endpoint { struct CreateContainerBody: Codable { let Image: String let Cmd: [String]? + let HostConfig: HostConfig? + + struct HostConfig: Codable { + let PortBindings: [String: [PortBinding]?] + + struct PortBinding: Codable { + let HostIp: String? + let HostPort: String? + } + } } struct CreateContainerResponse: Codable { diff --git a/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift b/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift index 033f6e3..7d20335 100644 --- a/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift +++ b/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift @@ -22,6 +22,7 @@ struct InspectContainerEndpoint: Endpoint { let Image: String let Created: String let State: StateResponse + let NetworkSettings: NetworkSettings // TODO: Add additional fields struct StateResponse: Codable { @@ -32,5 +33,14 @@ struct InspectContainerEndpoint: Endpoint { struct ConfigResponse: Codable { let Cmd: [String] } + + struct NetworkSettings: Codable { + let Ports: [String: [PortBinding]?] + + struct PortBinding: Codable { + let HostIp: String + let HostPort: String + } + } } } diff --git a/Sources/DockerClientSwift/Models/Container.swift b/Sources/DockerClientSwift/Models/Container.swift index 2431078..d3f9536 100644 --- a/Sources/DockerClientSwift/Models/Container.swift +++ b/Sources/DockerClientSwift/Models/Container.swift @@ -1,4 +1,5 @@ import Foundation +import Network /// Representation of a container. /// Some actions can be performed on an instance. @@ -12,3 +13,30 @@ public struct Container { } extension Container: Codable {} + +/// Representation of a port binding +public struct PortBinding { + public var hostIP: IPAddress + public var hostPort: UInt16 + public var containerPort: UInt16 + public var networkProtocol: NetworkProtocol + + /// Creates a PortBinding + /// + /// - Parameters: + /// - hostIP: The host IP address to map the connection to. Default `0.0.0.0`. + /// - hostPort: The port on the Docker host to map connections to 0 means map to a random available port. Default `0`. + /// - containerPort: The port on the container to map connections from. + /// - networkProtocol: The protocol (`tcp`/`udp`) to bind. Default `tcp`. + public init(hostIP: IPAddress=IPv4Address.any, hostPort: UInt16=0, containerPort: UInt16, networkProtocol: NetworkProtocol = .tcp) { + self.hostIP = hostIP + self.hostPort = hostPort + self.containerPort = containerPort + self.networkProtocol = networkProtocol + } +} + +public enum NetworkProtocol: String { + case tcp + case udp +} diff --git a/Tests/DockerClientTests/ContainerTests.swift b/Tests/DockerClientTests/ContainerTests.swift index 998387a..1258612 100644 --- a/Tests/DockerClientTests/ContainerTests.swift +++ b/Tests/DockerClientTests/ContainerTests.swift @@ -43,7 +43,7 @@ final class ContainerTests: XCTestCase { func testStartingContainerAndRetrievingLogs() throws { let image = try client.images.pullImage(byName: "hello-world", tag: "latest").wait() let container = try client.containers.createContainer(image: image).wait() - try container.start(on: client).wait() + _ = try container.start(on: client).wait() let output = try container.logs(on: client).wait() // Depending on CPU architecture, step 2 of the log output may by: // 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. @@ -97,10 +97,49 @@ final class ContainerTests: XCTestCase { ) } + func testStartingContainerForwardingToSpecificPort() throws { + let image = try client.images.pullImage(byName: "nginxdemos/hello", tag: "plain-text").wait() + let container = try client.containers.createContainer(image: image, portBindings: [PortBinding(hostPort: 8080, containerPort: 80)]).wait() + _ = try container.start(on: client).wait() + + let sem: DispatchSemaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: URL(string: "http://localhost:8080")!) { (data, response, _) in + let httpResponse = response as? HTTPURLResponse + XCTAssertEqual(httpResponse?.statusCode, 200) + XCTAssertEqual(httpResponse?.value(forHTTPHeaderField: "Content-Type"), "text/plain") + XCTAssertTrue(String(data: data!, encoding: .utf8)!.hasPrefix("Server address")) + + sem.signal() + } + task.resume() + sem.wait() + try container.stop(on: client).wait() + } + + func testStartingContainerForwardingToRandomPort() throws { + let image = try client.images.pullImage(byName: "nginxdemos/hello", tag: "plain-text").wait() + let container = try client.containers.createContainer(image: image, portBindings: [PortBinding(containerPort: 80)]).wait() + let portBindings = try container.start(on: client).wait() + let randomPort = portBindings[0].hostPort + + let sem: DispatchSemaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: URL(string: "http://localhost:\(randomPort)")!) { (data, response, _) in + let httpResponse = response as? HTTPURLResponse + XCTAssertEqual(httpResponse?.statusCode, 200) + XCTAssertEqual(httpResponse?.value(forHTTPHeaderField: "Content-Type"), "text/plain") + XCTAssertTrue(String(data: data!, encoding: .utf8)!.hasPrefix("Server address")) + + sem.signal() + } + task.resume() + sem.wait() + try container.stop(on: client).wait() + } + func testPruneContainers() throws { let image = try client.images.pullImage(byName: "nginx", tag: "latest").wait() let container = try client.containers.createContainer(image: image).wait() - try container.start(on: client).wait() + _ = try container.start(on: client).wait() try container.stop(on: client).wait() let pruned = try client.containers.prune().wait() From 38398afc1464c94659a8a2c31e34554dcea6b798 Mon Sep 17 00:00:00 2001 From: Jack Leow Date: Sun, 19 Feb 2023 19:28:40 -0800 Subject: [PATCH 2/4] Build "ExposedPorts" as part of container creation, as image may not have them defined. --- .../APIs/DockerClient+Container.swift | 10 ++++++++-- .../Endpoints/Containers/CreateContainerEndpoint.swift | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/DockerClientSwift/APIs/DockerClient+Container.swift b/Sources/DockerClientSwift/APIs/DockerClient+Container.swift index 91d5eda..0f170d9 100644 --- a/Sources/DockerClientSwift/APIs/DockerClient+Container.swift +++ b/Sources/DockerClientSwift/APIs/DockerClient+Container.swift @@ -42,20 +42,26 @@ extension DockerClient { /// - Returns: Returns an `EventLoopFuture` of a `Container`. public func createContainer(image: Image, commands: [String]?=nil, portBindings: [PortBinding]=[]) throws -> EventLoopFuture { let hostConfig: CreateContainerEndpoint.CreateContainerBody.HostConfig? + let exposedPorts: [String: CreateContainerEndpoint.CreateContainerBody.Empty]? if portBindings.isEmpty { + exposedPorts = nil hostConfig = nil } else { + var exposedPortsBuilder: [String: CreateContainerEndpoint.CreateContainerBody.Empty] = [:] var portBindingsByContainerPort: [String: [CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding]] = [:] for portBinding in portBindings { let containerPort: String = "\(portBinding.containerPort)/\(portBinding.networkProtocol)" + + exposedPortsBuilder[containerPort] = CreateContainerEndpoint.CreateContainerBody.Empty() var hostAddresses = portBindingsByContainerPort[containerPort, default: []] hostAddresses.append( CreateContainerEndpoint.CreateContainerBody.HostConfig.PortBinding(HostIp: "\(portBinding.hostIP)", HostPort: "\(portBinding.hostPort)")) portBindingsByContainerPort[containerPort] = hostAddresses } + exposedPorts = exposedPortsBuilder hostConfig = CreateContainerEndpoint.CreateContainerBody.HostConfig(PortBindings: portBindingsByContainerPort) } - return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands, hostConfig: hostConfig)) + return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands, exposedPorts: exposedPorts, hostConfig: hostConfig)) .flatMap({ response in try self.get(containerByNameOrId: response.Id) }) @@ -147,7 +153,7 @@ extension DockerClient { repositoryTag = repoTag } let image = Image(id: .init(response.Image), digest: digest, repositoryTags: repositoryTag.map({ [$0]}), createdAt: nil) - 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: " ")) + 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: " ")) } } diff --git a/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift b/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift index 02459e3..5057347 100644 --- a/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift +++ b/Sources/DockerClientSwift/Endpoints/Containers/CreateContainerEndpoint.swift @@ -10,10 +10,10 @@ struct CreateContainerEndpoint: Endpoint { private let imageName: String private let commands: [String]? - init(imageName: String, commands: [String]?=nil, hostConfig: CreateContainerBody.HostConfig?=nil) { + init(imageName: String, commands: [String]?=nil, exposedPorts: [String: CreateContainerBody.Empty]?=nil, hostConfig: CreateContainerBody.HostConfig?=nil) { self.imageName = imageName self.commands = commands - self.body = .init(Image: imageName, Cmd: commands, HostConfig: hostConfig) + self.body = .init(Image: imageName, Cmd: commands, ExposedPorts: exposedPorts, HostConfig: hostConfig) } var path: String { @@ -23,8 +23,11 @@ struct CreateContainerEndpoint: Endpoint { struct CreateContainerBody: Codable { let Image: String let Cmd: [String]? + let ExposedPorts: [String: Empty]? let HostConfig: HostConfig? + struct Empty: Codable {} + struct HostConfig: Codable { let PortBindings: [String: [PortBinding]?] From 445a60f9fefbc0eaf66edfd89c88fb7c60c050fe Mon Sep 17 00:00:00 2001 From: Jack Leow Date: Sun, 19 Feb 2023 19:29:23 -0800 Subject: [PATCH 3/4] Updated endpoint JSON model to reflect that container's Config.Cmd may not always be there. --- .../Endpoints/Containers/InspectContainerEndpoint.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift b/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift index 7d20335..65e4e60 100644 --- a/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift +++ b/Sources/DockerClientSwift/Endpoints/Containers/InspectContainerEndpoint.swift @@ -31,7 +31,7 @@ struct InspectContainerEndpoint: Endpoint { } struct ConfigResponse: Codable { - let Cmd: [String] + let Cmd: [String]? } struct NetworkSettings: Codable { From 61ab30de422c342461cbfe0e64aa87fdd2c469d6 Mon Sep 17 00:00:00 2001 From: Jack Leow Date: Mon, 20 Feb 2023 08:44:33 -0800 Subject: [PATCH 4/4] Grammar issue in doc comment. --- Sources/DockerClientSwift/Models/Container.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/DockerClientSwift/Models/Container.swift b/Sources/DockerClientSwift/Models/Container.swift index d3f9536..a31166f 100644 --- a/Sources/DockerClientSwift/Models/Container.swift +++ b/Sources/DockerClientSwift/Models/Container.swift @@ -25,7 +25,7 @@ public struct PortBinding { /// /// - Parameters: /// - hostIP: The host IP address to map the connection to. Default `0.0.0.0`. - /// - hostPort: The port on the Docker host to map connections to 0 means map to a random available port. Default `0`. + /// - hostPort: The port on the Docker host to map connections to. `0` means map to a random available port. Default `0`. /// - containerPort: The port on the container to map connections from. /// - networkProtocol: The protocol (`tcp`/`udp`) to bind. Default `tcp`. public init(hostIP: IPAddress=IPv4Address.any, hostPort: UInt16=0, containerPort: UInt16, networkProtocol: NetworkProtocol = .tcp) {