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

Create container with port forwarding #7

Merged
merged 6 commits into from
Feb 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
91 changes: 76 additions & 15 deletions Tests/DockerClientTests/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class ContainerTests: XCTestCase {
func testCreateContainers() throws {
let image = try client.images.pullImage(byName: "hello-world", tag: "latest").wait()
let container = try client.containers.createContainer(image: image).wait()

XCTAssertEqual(container.command, "/hello")
}

Expand All @@ -26,7 +26,7 @@ final class ContainerTests: XCTestCase {
let _ = try client.containers.createContainer(image: image).wait()

let containers = try client.containers.list(all: true).wait()

XCTAssert(containers.count >= 1)
}

Expand All @@ -43,42 +43,103 @@ 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()

XCTAssertEqual(
output,
"""

// 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.
// (amd64)
// or
// 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
// (arm64v8)
//
// Just check the lines before and after this line
let expectedOutputPrefix = """

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
1. The Docker client contacted the Docker daemon.
2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
(amd64)
"""
let expectedOutputSuffix = """
3. The Docker daemon created a new container from that image which runs the
executable that produces the output you are currently reading.
4. The Docker daemon streamed that output to the Docker client, which sent it
to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
$ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
https://hub.docker.com/

For more examples and ideas, visit:
https://docs.docker.com/get-started/


"""

XCTAssertTrue(
output.hasPrefix(expectedOutputPrefix),
"""
"\(output)"
did not start with
"\(expectedOutputPrefix)"
"""
)
XCTAssertTrue(
output.hasSuffix(expectedOutputSuffix),
"""
"\(output)"
did not end with
"\(expectedOutputSuffix)"
"""
)
}

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