diff --git a/Sources/DockerClientSwift/APIs/DockerClient+Container.swift b/Sources/DockerClientSwift/APIs/DockerClient+Container.swift index 905c623..0f170d9 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,31 @@ 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? + 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, exposedPorts: exposedPorts, hostConfig: hostConfig)) .flatMap({ response in try self.get(containerByNameOrId: response.Id) }) @@ -48,10 +70,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.. @@ -105,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: " ")) } } @@ -134,7 +182,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..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) { + 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) + self.body = .init(Image: imageName, Cmd: commands, ExposedPorts: exposedPorts, HostConfig: hostConfig) } var path: String { @@ -23,6 +23,19 @@ 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]?] + + 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..65e4e60 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 { @@ -30,7 +31,16 @@ struct InspectContainerEndpoint: Endpoint { } struct ConfigResponse: Codable { - let Cmd: [String] + 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..a31166f 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()