Skip to content
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
4 changes: 4 additions & 0 deletions Sources/ContainerClient/Core/ContainerConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public struct ContainerConfiguration: Sendable, Codable {
public var image: ImageDescription
/// External mounts to add to the container.
public var mounts: [Filesystem] = []
/// Ports to publish from container to host.
public var publishedPorts: [PublishPort] = []
/// Sockets to publish from container to host.
public var publishedSockets: [PublishSocket] = []
/// Key/Value labels for the container.
Expand Down Expand Up @@ -50,6 +52,7 @@ public struct ContainerConfiguration: Sendable, Codable {
case id
case image
case mounts
case publishedPorts
case publishedSockets
case labels
case sysctls
Expand All @@ -71,6 +74,7 @@ public struct ContainerConfiguration: Sendable, Codable {
id = try container.decode(String.self, forKey: .id)
image = try container.decode(ImageDescription.self, forKey: .image)
mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? []
publishedPorts = try container.decodeIfPresent([PublishPort].self, forKey: .publishedPorts) ?? []
publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? []
labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:]
sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:]
Expand Down
58 changes: 58 additions & 0 deletions Sources/ContainerClient/Core/PublishPort.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

/// The network protocols available for port forwarding.
public enum PublishProtocol: String, Sendable, Codable {
case tcp = "tcp"
case udp = "udp"

/// Initialize a protocol with to default value, `.tcp`.
public init() {
self = .tcp
}

/// Initialize a protocol value from the provided string.
public init?(_ value: String) {
switch value.lowercased() {
case "tcp": self = .tcp
case "udp": self = .udp
default: return nil
}
}
}

/// Specifies internet port forwarding from host to container.
public struct PublishPort: Sendable, Codable {
/// The IP address of the proxy listener on the host
public let hostAddress: String

/// The port number of the proxy listener on the host
public let hostPort: Int

/// The port number of the container listener
public let containerPort: Int

/// The network protocol for the proxy
public let proto: PublishProtocol

/// Creates a new port forwarding specification.
public init(hostAddress: String, hostPort: Int, containerPort: Int, proto: PublishProtocol) {
self.hostAddress = hostAddress
self.hostPort = hostPort
self.containerPort = containerPort
self.proto = proto
}
}
13 changes: 8 additions & 5 deletions Sources/ContainerClient/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public struct Flags {
public struct Management: ParsableArguments {
public init() {}

@Flag(name: [.customLong("detach"), .customShort("d")], help: "Run the container and detach from the process")
@Flag(name: [.customLong("detach"), .short], help: "Run the container and detach from the process")
public var detach = false

@Option(name: .customLong("entrypoint"), help: "Override the entrypoint of the image")
Expand All @@ -93,6 +93,9 @@ public struct Flags {
@Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)")
public var mounts: [String] = []

@Option(name: [.customLong("publish"), .short], help: "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])")
public var publishPorts: [String] = []

@Option(name: .customLong("publish-socket"), help: "Publish a socket from container to host (format: host_path:container_path)")
public var publishSockets: [String] = []

Expand All @@ -109,14 +112,14 @@ public struct Flags {
public var os = "linux"

@Option(
name: [.customLong("arch"), .customShort("a")], help: "Set arch if image can target multiple architectures")
name: [.customLong("arch"), .short], help: "Set arch if image can target multiple architectures")
public var arch: String = Arch.hostArchitecture().rawValue

@Option(name: [.customLong("volume"), .customShort("v")], help: "Bind mount a volume into the container")
@Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container")
public var volumes: [String] = []

@Option(
name: [.customLong("kernel"), .customShort("k")], help: "Set a custom kernel path", completion: .file(),
name: [.customLong("kernel"), .short], help: "Set a custom kernel path", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
Expand All @@ -143,7 +146,7 @@ public struct Flags {
@Option(name: .customLong("dns-option"), help: "DNS options")
public var dnsOptions: [String] = []

@Option(name: [.customLong("label"), .customShort("l")], help: "Add a key=value label to the container")
@Option(name: [.customLong("label"), .short], help: "Add a key=value label to the container")
public var labels: [String] = []
}

Expand Down
93 changes: 82 additions & 11 deletions Sources/ContainerClient/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,84 @@ public struct Parser {
}
}

// Parse --publish-socket arguments into PublishSocket objects
// Format: "host_path:container_path" (e.g., "/tmp/docker.sock:/var/run/docker.sock")
//
// - Parameter rawPublishSockets: Array of socket specifications
// - Returns: Array of PublishSocket objects
// - Throws: ContainerizationError if parsing fails
/// Parse --publish-port arguments into PublishPort objects
/// The format of each argument is `[host-ip:]host-port:container-port[/protocol]`
/// (e.g., "127.0.0.1:8080:80/tcp")
///
/// - Parameter rawPublishPorts: Array of port arguments
/// - Returns: Array of PublishPort objects
/// - Throws: ContainerizationError if parsing fails
static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] {
var sockets: [PublishPort] = []

// Process each raw port string
for socket in rawPublishPorts {
let parsedSocket = try Parser.publishPort(socket)
sockets.append(parsedSocket)
}
return sockets
}

// Parse a single `--publish-port` argument into a `PublishPort`.
private static func publishPort(_ portText: String) throws -> PublishPort {
let protoSplit = portText.split(separator: "/")
let proto: PublishProtocol
let addressAndPortText: String
switch protoSplit.count {
case 1:
addressAndPortText = String(protoSplit[0])
proto = .tcp
case 2:
addressAndPortText = String(protoSplit[0])
let protoText = String(protoSplit[1])
guard let parsedProto = PublishProtocol(protoText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)")
}
proto = parsedProto
default:
throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)")
}

let hostAddress: String
let hostPortText: String
let containerPortText: String
let parts = addressAndPortText.split(separator: ":")
switch parts.count {
case 2:
hostAddress = "0.0.0.0"
hostPortText = String(parts[0])
containerPortText = String(parts[1])
case 3:
hostAddress = String(parts[0])
hostPortText = String(parts[1])
containerPortText = String(parts[2])
default:
throw ContainerizationError(.invalidArgument, message: "invalid publish address: \(portText)")
}

guard let hostPort = Int(hostPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)")
}

guard let containerPort = Int(containerPortText) else {
throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)")
}

return PublishPort(
hostAddress: hostAddress,
hostPort: hostPort,
containerPort: containerPort,
proto: proto
)
}

/// Parse --publish-socket arguments into PublishSocket objects
/// The format of each argument is `host_path:container_path`
/// (e.g., "/tmp/docker.sock:/var/run/docker.sock")
///
/// - Parameter rawPublishSockets: Array of socket arguments
/// - Returns: Array of PublishSocket objects
/// - Throws: ContainerizationError if parsing fails or a path is invalid
static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] {
var sockets: [PublishSocket] = []

Expand All @@ -414,11 +486,10 @@ public struct Parser {
return sockets
}

// Parse a single --publish-socket argument and validate paths
// Format: "host_path:container_path" -> PublishSocket
private static func publishSocket(_ socket: String) throws -> PublishSocket {
// Parse a single `--publish-socket`` argument into a `PublishSocket`.
private static func publishSocket(_ socketText: String) throws -> PublishSocket {
// Split by colon to two parts: [host_path, container_path]
let parts = socket.split(separator: ":")
let parts = socketText.split(separator: ":")

switch parts.count {
case 2:
Expand Down Expand Up @@ -483,7 +554,7 @@ public struct Parser {
throw ContainerizationError(
.invalidArgument,
message:
"invalid publish-socket format \(socket). Expected: host_path:container_path")
"invalid publish-socket format \(socketText). Expected: host_path:container_path")
}
}
}
2 changes: 2 additions & 0 deletions Sources/ContainerClient/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,8 @@ public struct Utility {

config.labels = try Parser.labels(management.labels)

config.publishedPorts = try Parser.publishPorts(management.publishPorts)

// Parse --publish-socket arguments and add to container configuration
// to enable socket forwarding from container to host.
config.publishedSockets = try Parser.publishSockets(management.publishSockets)
Expand Down
63 changes: 63 additions & 0 deletions Sources/Services/ContainerSandboxService/SandboxService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import NIO
import NIOFoundationCompat
import SocketForwarder

import struct ContainerizationOCI.Mount
import struct ContainerizationOCI.Process
Expand All @@ -36,11 +39,13 @@ public actor SandboxService {
private let interfaceStrategy: InterfaceStrategy
private var container: ContainerInfo?
private let monitor: ExitMonitor
private let eventLoopGroup: MultiThreadedEventLoopGroup
private var waiters: [String: [CheckedContinuation<Int32, Never>]] = [:]
private let lock: AsyncLock = AsyncLock()
private let log: Logging.Logger
private var state: State = .created
private var processes: [String: ProcessInfo] = [:]
private var socketForwarders: [SocketForwarderResult] = []

/// Create an instance with a bundle that describes the container.
///
Expand All @@ -54,6 +59,7 @@ public actor SandboxService {
self.interfaceStrategy = interfaceStrategy
self.log = log
self.monitor = ExitMonitor(log: log)
self.eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be calling shutdownGracefully anywhere for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should let the caller allocate and manage the lifecycle

}

/// Start the VM and the guest agent process for a container.
Expand Down Expand Up @@ -142,6 +148,11 @@ public actor SandboxService {
do {
try await container.create()
try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit)
if !container.interfaces.isEmpty {
let firstCidr = try CIDRAddress(container.interfaces[0].address)
let ipAddress = firstCidr.address.description
try await self.startSocketForwarders(containerIpAddress: ipAddress, publishedPorts: config.publishedPorts)
}
await self.setState(.booted)
} catch {
do {
Expand Down Expand Up @@ -274,6 +285,57 @@ public actor SandboxService {
try await self.monitor.track(id: id, waitingOn: waitFunc)
}

private func startSocketForwarders(containerIpAddress: String, publishedPorts: [PublishPort]) async throws {
var forwarders: [SocketForwarderResult] = []
try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in
for publishedPort in publishedPorts {
let proxyAddress = try SocketAddress(ipAddress: publishedPort.hostAddress, port: Int(publishedPort.hostPort))
let serverAddress = try SocketAddress(ipAddress: containerIpAddress, port: Int(publishedPort.containerPort))
log.info(
"creating forwarder for",
metadata: [
"proxy": "\(proxyAddress)",
"server": "\(serverAddress)",
"protocol": "\(publishedPort.proto)",
])
group.addTask {
let forwarder: SocketForwarder
switch publishedPort.proto {
case .tcp:
forwarder = try TCPForwarder(
proxyAddress: proxyAddress,
serverAddress: serverAddress,
eventLoopGroup: self.eventLoopGroup,
log: self.log
)
case .udp:
forwarder = try UDPForwarder(
proxyAddress: proxyAddress,
serverAddress: serverAddress,
eventLoopGroup: self.eventLoopGroup,
log: self.log
)
}
return try await forwarder.run().get()
}
}
for try await result in group {
forwarders.append(result)
}
}

self.socketForwarders = forwarders
}

private func stopSocketForwarders() async {
log.info("closing forwarders")
for forwarder in self.socketForwarders {
forwarder.close()
try? await forwarder.wait()
}
log.info("closed forwarders")
}

/// Create a process inside the virtual machine for the container.
///
/// Use this procedure to run ad hoc processes in the virtual
Expand Down Expand Up @@ -776,6 +838,7 @@ public actor SandboxService {

private func cleanupContainer() async throws {
// Give back our lovely IP(s)
await self.stopSocketForwarders()
let containerInfo = try self.getContainer()
for attachment in containerInfo.attachments {
let client = NetworkClient(id: attachment.network)
Expand Down
16 changes: 0 additions & 16 deletions Tests/CLITests/Subcommands/Networks/TestCLINetwork.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,20 +80,4 @@ class TestCLINetwork: CLITest {
return
}
}

private func getClient() -> HTTPClient {
var httpConfiguration = HTTPClient.Configuration()
let proxyConfig: HTTPClient.Configuration.Proxy? = {
let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"]
guard let proxyEnv else {
return nil
}
guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else {
return nil
}
return .server(host: host, port: port)
}()
httpConfiguration.proxy = proxyConfig
return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
}
}
Loading