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

Commit

Permalink
Merge pull request #7 from jackgene/feature/port-forwarding
Browse files Browse the repository at this point in the history
Create container with port forwarding
  • Loading branch information
alexsteinerde authored Feb 21, 2023
2 parents 0c874f7 + 61ab30d commit 58d0a76
Show file tree
Hide file tree
Showing 5 changed files with 150 additions and 12 deletions.
62 changes: 55 additions & 7 deletions Sources/DockerClientSwift/APIs/DockerClient+Container.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Network
import NIO

extension DockerClient {
Expand Down Expand Up @@ -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<Container> {
return try client.run(CreateContainerEndpoint(imageName: image.id.value, commands: commands))
public func createContainer(image: Image, commands: [String]?=nil, portBindings: [PortBinding]=[]) throws -> EventLoopFuture<Container> {
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)
})
Expand All @@ -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<Void> {
/// - 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..
Expand Down Expand Up @@ -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: " "))
}
}

Expand Down Expand Up @@ -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<Void> {
public func start(on client: DockerClient) throws -> EventLoopFuture<[PortBinding]> {
try client.containers.start(container: self)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
}
}
28 changes: 28 additions & 0 deletions Sources/DockerClientSwift/Models/Container.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Network

/// Representation of a container.
/// Some actions can be performed on an instance.
Expand All @@ -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
}
43 changes: 41 additions & 2 deletions Tests/DockerClientTests/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down

0 comments on commit 58d0a76

Please sign in to comment.