CLI: Rework ClientContainer (#1139)

ClientContainer was honestly extremely awkward. It could only be created
by passing either a ContainerConfiguration, or a Snapshot that had to be
obtained from calling a static method on the type itself. The type also
did not store a connection, so every single method got a new xpc
connection to the APIServer. This change aims to rework this type to be
just a generic client, that is *not* a client for one specific
container, but for any.

- Rename to ContainerClient
- Have list() return [ContainerSnapshot]
- Create a connection in the constructor
- Change all the callsites to use the new API
- Small, somewhat related, change to logs API in the APIServer. Now that
we don't need to call get() to grab a client anymore which was typically
what did "does this container exist" logic and gave a nice error
message, I added a small check in the APIServer to see if the container
exists and return mostly the same error message.
This commit is contained in:
Danny Canter
2026-02-04 09:52:09 -08:00
committed by GitHub
parent 23c0ece161
commit 8fdfa29728
25 changed files with 211 additions and 213 deletions
+2 -2
View File
@@ -153,10 +153,10 @@ extension Application {
}
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in
let client = ContainerClient()
while true {
do {
let container = try await ClientContainer.get(id: "buildkit")
let fh = try await container.dial(vsockPort)
let fh = try await client.dial(id: "buildkit", port: vsockPort)
let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let b = try Builder(socket: fh, group: threadGroup)
@@ -39,14 +39,15 @@ extension Application {
public func run() async throws {
do {
let container = try await ClientContainer.get(id: "buildkit")
let client = ContainerClient()
let container = try await client.get(id: "buildkit")
if container.status != .stopped {
guard force else {
throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override")
}
try await container.stop()
try await client.stop(id: container.id)
}
try await container.delete()
try await client.delete(id: container.id)
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
@@ -119,7 +119,8 @@ extension Application {
}
targetEnvVars.sort()
let existingContainer = try? await ClientContainer.get(id: "buildkit")
let client = ContainerClient()
let existingContainer = try? await client.get(id: "buildkit")
if let existingContainer {
let existingImage = existingContainer.configuration.image.reference
let existingResources = existingContainer.configuration.resources
@@ -174,16 +175,16 @@ extension Application {
return
}
// If they changed, stop and delete the existing builder
try await existingContainer.stop()
try await existingContainer.delete()
try await client.stop(id: existingContainer.id)
try await client.delete(id: existingContainer.id)
case .stopped:
// If the builder is stopped and matches our requirements, start it
// Otherwise, delete it and create a new one
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
try await existingContainer.startBuildKit(progressUpdate, nil)
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
return
}
try await existingContainer.delete()
try await client.delete(id: existingContainer.id)
case .stopping:
throw ContainerizationError(
.invalidState,
@@ -296,43 +297,46 @@ extension Application {
.setDescription("Starting BuildKit container")
])
let container = try await ClientContainer.create(
try await client.create(
configuration: config,
options: .default,
kernel: kernel
)
try await container.startBuildKit(progressUpdate, taskManager)
try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager)
log.debug("starting BuildKit and BuildKit-shim")
}
}
}
// MARK: - ClientContainer Extension for BuildKit
// MARK: - BuildKit Start Helper
extension ClientContainer {
/// Starts the BuildKit process within the container
/// This method handles bootstrapping the container and starting the BuildKit process
fileprivate func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws {
do {
let io = try ProcessIO.create(
tty: false,
interactive: false,
detach: true
)
defer { try? io.close() }
/// Starts the BuildKit process within the container
/// This function handles bootstrapping the container and starting the BuildKit process
private func startBuildKit(
client: ContainerClient,
id: String,
_ progress: @escaping ProgressUpdateHandler,
_ taskManager: ProgressTaskCoordinator? = nil
) async throws {
do {
let io = try ProcessIO.create(
tty: false,
interactive: false,
detach: true
)
defer { try? io.close() }
let process = try await bootstrap(stdio: io.stdio)
try await process.start()
await taskManager?.finish()
try io.closeAfterStart()
} catch {
try? await stop()
try? await delete()
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)")
let process = try await client.bootstrap(id: id, stdio: io.stdio)
try await process.start()
await taskManager?.finish()
try io.closeAfterStart()
} catch {
try? await client.stop(id: id)
try? await client.delete(id: id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)")
}
}
@@ -16,6 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import ContainerizationExtras
import Foundation
@@ -42,7 +43,8 @@ extension Application {
public func run() async throws {
do {
let container = try await ClientContainer.get(id: "buildkit")
let client = ContainerClient()
let container = try await client.get(id: "buildkit")
try printContainers(containers: [container], format: format)
} catch {
if error is ContainerizationError {
@@ -59,7 +61,7 @@ extension Application {
[["ID", "IMAGE", "STATE", "ADDR", "CPUS", "MEMORY"]]
}
private func printContainers(containers: [ClientContainer], format: ListFormat) throws {
private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws {
if format == .json {
let printables = containers.map {
PrintableContainer($0)
@@ -88,7 +90,7 @@ extension Application {
}
}
extension ClientContainer {
extension ContainerSnapshot {
fileprivate var asRow: [String] {
[
self.id,
@@ -35,8 +35,8 @@ extension Application {
public func run() async throws {
do {
let container = try await ClientContainer.get(id: "buildkit")
try await container.stop()
let client = ContainerClient()
try await client.stop(id: "buildkit")
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
@@ -82,11 +82,12 @@ extension Application {
)
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
let container = try await ClientContainer.create(configuration: ck.0, options: options, kernel: ck.1)
let client = ContainerClient()
try await client.create(configuration: ck.0, options: options, kernel: ck.1)
if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = container.id.data(using: .utf8)
let data = id.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
@@ -101,7 +102,7 @@ extension Application {
}
progress.finish()
print(container.id)
print(id)
}
}
}
@@ -16,6 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
@@ -54,12 +55,13 @@ extension Application {
public mutating func run() async throws {
let set = Set<String>(containerIds)
var containers = [ClientContainer]()
let client = ContainerClient()
var containers = [ContainerSnapshot]()
if all {
containers = try await ClientContainer.list()
containers = try await client.list()
} else {
let ctrs = try await ClientContainer.list()
let ctrs = try await client.list()
containers = ctrs.filter { c in
set.contains(c.id)
}
@@ -94,7 +96,7 @@ extension Application {
return nil // Skip running container when using --all
}
try await container.delete(force: force)
try await client.delete(id: container.id, force: force)
print(container.id)
return nil
} catch {
@@ -45,7 +45,8 @@ extension Application {
public func run() async throws {
var exitCode: Int32 = 127
let container = try await ClientContainer.get(id: containerId)
let client = ContainerClient()
let container = try await client.get(id: containerId)
try ensureRunning(container: container)
let stdin = self.processFlags.interactive
@@ -79,8 +80,9 @@ extension Application {
try? io.close()
}
let process = try await container.createProcess(
id: UUID().uuidString.lowercased(),
let process = try await client.createProcess(
containerId: container.id,
processId: UUID().uuidString.lowercased(),
configuration: config,
stdio: io.stdio
)
@@ -16,6 +16,7 @@
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Foundation
import SwiftProtobuf
@@ -34,7 +35,8 @@ extension Application {
var containerIds: [String]
public func run() async throws {
let objects: [any Codable] = try await ClientContainer.list().filter {
let client = ContainerClient()
let objects: [any Codable] = try await client.list().filter {
containerIds.contains($0.id)
}.map {
PrintableContainer($0)
@@ -51,8 +51,9 @@ extension Application {
public mutating func run() async throws {
let set = Set<String>(containerIds)
let client = ContainerClient()
var containers = try await ClientContainer.list().filter { c in
var containers = try await client.list().filter { c in
c.status == .running
}
if !self.all {
@@ -66,7 +67,7 @@ extension Application {
var failed: [String] = []
for container in containers {
do {
try await container.kill(signalNumber)
try await client.kill(id: container.id, signal: signalNumber)
print(container.id)
} catch {
log.error("failed to kill container \(container.id): \(error)")
@@ -43,7 +43,8 @@ extension Application {
public init() {}
public func run() async throws {
let containers = try await ClientContainer.list()
let client = ContainerClient()
let containers = try await client.list()
try printContainers(containers: containers, format: format)
}
@@ -51,7 +52,7 @@ extension Application {
[["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR", "CPUS", "MEMORY", "STARTED"]]
}
private func printContainers(containers: [ClientContainer], format: ListFormat) throws {
private func printContainers(containers: [ContainerSnapshot], format: ListFormat) throws {
if format == .json {
let printables = containers.map {
PrintableContainer($0)
@@ -86,13 +87,13 @@ extension Application {
}
}
extension ClientContainer {
extension ContainerSnapshot {
fileprivate var asRow: [String] {
[
self.id,
self.configuration.image.reference,
self.configuration.platform.os,
self.configuration.platform.architecture,
self.platform.os,
self.platform.architecture,
self.status.rawValue,
self.networks.compactMap { $0.ipv4Address.description }.joined(separator: ","),
"\(self.configuration.resources.cpus)",
@@ -108,7 +109,7 @@ struct PrintableContainer: Codable {
let networks: [Attachment]
let startedDate: Date?
init(_ container: ClientContainer) {
init(_ container: ContainerSnapshot) {
self.status = container.status
self.configuration = container.configuration
self.networks = container.networks
@@ -46,22 +46,15 @@ extension Application {
var containerId: String
public func run() async throws {
do {
let container = try await ClientContainer.get(id: containerId)
let fhs = try await container.logs()
let fileHandle = boot ? fhs[1] : fhs[0]
let client = ContainerClient()
let fhs = try await client.logs(id: containerId)
let fileHandle = boot ? fhs[1] : fhs[0]
try await Self.tail(
fh: fileHandle,
n: numLines,
follow: follow
)
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to fetch container logs for \(containerId): \(error)"
)
}
try await Self.tail(
fh: fileHandle,
n: numLines,
follow: follow
)
}
private static func tail(
@@ -32,16 +32,17 @@ extension Application {
public var logOptions: Flags.Logging
public func run() async throws {
let containersToPrune = try await ClientContainer.list().filter { $0.status == .stopped }
let client = ContainerClient()
let containersToPrune = try await client.list().filter { $0.status == .stopped }
var prunedContainerIds = [String]()
var totalSize: UInt64 = 0
for container in containersToPrune {
do {
let actualSize = try await ClientContainer.containerDiskUsage(id: container.id)
let actualSize = try await client.diskUsage(id: container.id)
totalSize += actualSize
try await container.delete()
try await client.delete(id: container.id)
prunedContainerIds.append(container.id)
} catch {
log.error("Failed to prune container \(container.id): \(error)")
@@ -85,7 +85,8 @@ extension Application {
try Utility.validEntityName(id)
// Check if container with id already exists.
let existing = try? await ClientContainer.get(id: id)
let client = ContainerClient()
let existing = try? await client.get(id: id)
guard existing == nil else {
throw ContainerizationError(
.exists,
@@ -108,7 +109,7 @@ extension Application {
progress.set(description: "Starting container")
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
let container = try await ClientContainer.create(
try await client.create(
configuration: ck.0,
options: options,
kernel: ck.1
@@ -125,7 +126,7 @@ extension Application {
try? io.close()
}
let process = try await container.bootstrap(stdio: io.stdio)
let process = try await client.bootstrap(id: id, stdio: io.stdio)
progress.finish()
if !self.managementFlags.cidfile.isEmpty {
@@ -161,7 +162,7 @@ extension Application {
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await container.delete()
try? await client.delete(id: id)
if error is ContainerizationError {
throw error
}
@@ -54,7 +54,8 @@ extension Application {
progress.start()
let detach = !self.attach && !self.interactive
let container = try await ClientContainer.get(id: containerId)
let client = ContainerClient()
let container = try await client.get(id: containerId)
// Bootstrap and process start are both idempotent and don't fail the second time
// around, however not doing an rpc is always faster :). The other bit is we don't
@@ -86,7 +87,7 @@ extension Application {
try? io.close()
}
let process = try await container.bootstrap(stdio: io.stdio)
let process = try await client.bootstrap(id: container.id, stdio: io.stdio)
progress.finish()
if detach {
@@ -98,7 +99,7 @@ extension Application {
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await container.stop()
try? await client.stop(id: container.id)
if error is ContainerizationError {
throw error
@@ -62,15 +62,16 @@ extension Application {
}
private func runStatic() async throws {
let allContainers = try await ClientContainer.list()
let client = ContainerClient()
let allContainers = try await client.list()
let containersToShow: [ClientContainer]
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
// No containers specified - show all running containers
containersToShow = allContainers.filter { $0.status == .running }
} else {
// Validate all specified containers exist before proceeding
var found: [ClientContainer] = []
var found: [ContainerSnapshot] = []
for containerId in containers {
guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else {
throw ContainerizationError(
@@ -83,7 +84,7 @@ extension Application {
containersToShow = found
}
let statsData = try await collectStats(for: containersToShow)
let statsData = try await collectStats(client: client, for: containersToShow)
if format == .json {
let jsonStats = statsData.map { $0.stats2 }
@@ -96,9 +97,11 @@ extension Application {
}
private func runStreaming() async throws {
let client = ContainerClient()
// If containers were specified, validate they all exist upfront
if !containers.isEmpty {
let allContainers = try await ClientContainer.list()
let allContainers = try await client.list()
for containerId in containers {
guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else {
throw ContainerizationError(
@@ -115,13 +118,13 @@ extension Application {
while true {
do {
let allContainers = try await ClientContainer.list()
let allContainers = try await client.list()
let containersToShow: [ClientContainer]
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
containersToShow = allContainers.filter { $0.status == .running }
} else {
var found: [ClientContainer] = []
var found: [ContainerSnapshot] = []
for containerId in containers {
if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) {
found.append(container)
@@ -130,7 +133,7 @@ extension Application {
containersToShow = found
}
let statsData = try await collectStats(for: containersToShow)
let statsData = try await collectStats(client: client, for: containersToShow)
// Clear screen and reprint
clearScreen()
@@ -148,19 +151,19 @@ extension Application {
}
private struct StatsSnapshot {
let container: ClientContainer
let container: ContainerSnapshot
let stats1: ContainerResource.ContainerStats
let stats2: ContainerResource.ContainerStats
}
private func collectStats(for containers: [ClientContainer]) async throws -> [StatsSnapshot] {
private func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
var snapshots: [StatsSnapshot] = []
// First sample
for container in containers {
guard container.status == .running else { continue }
do {
let stats1 = try await container.stats()
let stats1 = try await client.stats(id: container.id)
snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1))
} catch {
// Skip containers that error out
@@ -175,7 +178,7 @@ extension Application {
// Second sample
for i in 0..<snapshots.count {
do {
let stats2 = try await snapshots[i].container.stats()
let stats2 = try await client.stats(id: snapshots[i].container.id)
snapshots[i] = StatsSnapshot(
container: snapshots[i].container,
stats1: snapshots[i].stats1,
@@ -57,11 +57,12 @@ extension Application {
public mutating func run() async throws {
let set = Set<String>(containerIds)
var containers = [ClientContainer]()
let client = ContainerClient()
var containers = [ContainerSnapshot]()
if self.all {
containers = try await ClientContainer.list()
containers = try await client.list()
} else {
containers = try await ClientContainer.list().filter { c in
containers = try await client.list().filter { c in
set.contains(c.id)
}
}
@@ -70,7 +71,7 @@ extension Application {
timeoutInSeconds: self.time,
signal: try Signals.parseSignal(self.signal)
)
let failed = try await Self.stopContainers(containers: containers, stopOptions: opts, log: log)
let failed = try await Self.stopContainers(client: client, containers: containers, stopOptions: opts, log: log)
if failed.count > 0 {
throw ContainerizationError(
.internalError,
@@ -79,13 +80,13 @@ extension Application {
}
}
static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions, log: Logger) async throws -> [String] {
static func stopContainers(client: ContainerClient, containers: [ContainerSnapshot], stopOptions: ContainerStopOptions, log: Logger) async throws -> [String] {
var failed: [String] = []
try await withThrowingTaskGroup(of: ClientContainer?.self) { group in
try await withThrowingTaskGroup(of: ContainerSnapshot?.self) { group in
for container in containers {
group.addTask {
do {
try await container.stop(opts: stopOptions)
try await client.stop(id: container.id, opts: stopOptions)
print(container.id)
return nil
} catch {
@@ -15,13 +15,14 @@
//===----------------------------------------------------------------------===//
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
extension Application {
static func ensureRunning(container: ClientContainer) throws {
static func ensureRunning(container: ContainerSnapshot) throws {
if container.status != .running {
throw ContainerizationError(.invalidState, message: "container \(container.id) is not running")
}
@@ -38,7 +38,8 @@ extension Application {
let imagesToPrune: [ClientImage]
if all {
// Find all images not used by any container
let containers = try await ClientContainer.list()
let client = ContainerClient()
let containers = try await client.list()
var imagesInUse = Set<String>()
for container in containers {
imagesInUse.insert(container.configuration.image.reference)
@@ -30,7 +30,8 @@ extension Application.NetworkCommand {
public var logOptions: Flags.Logging
public func run() async throws {
let allContainers = try await ClientContainer.list()
let client = ContainerClient()
let allContainers = try await client.list()
let allNetworks = try await ClientNetwork.list()
var networksInUse = Set<String>()
@@ -61,12 +61,13 @@ extension Application {
}
if running {
let client = ContainerClient()
log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"])
do {
let containers = try await ClientContainer.list()
let containers = try await client.list()
let signal = try Signals.parseSignal("SIGTERM")
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal)
let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts, log: log)
let failed = try await ContainerStop.stopContainers(client: client, containers: containers, stopOptions: opts, log: log)
if !failed.isEmpty {
log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"])
}
@@ -77,7 +78,7 @@ extension Application {
log.info("waiting for containers to exit")
do {
for _ in 0..<Self.shutdownTimeoutSeconds {
let anyRunning = try await ClientContainer.list()
let anyRunning = try await client.list()
.contains { $0.status == .running }
guard anyRunning else {
break
@@ -32,7 +32,8 @@ extension Application.VolumeCommand {
let allVolumes = try await ClientVolume.list()
// Find all volumes not used by any container
let containers = try await ClientContainer.list()
let client = ContainerClient()
let containers = try await client.list()
var volumesInUse = Set<String>()
for container in containers {
for mount in container.configuration.mounts {
@@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOCI
import Foundation
/// A snapshot of a container along with its configuration
@@ -21,6 +22,17 @@ import Foundation
public struct ContainerSnapshot: Codable, Sendable {
/// The configuration of the container.
public var configuration: ContainerConfiguration
/// Identifier of the container.
public var id: String {
configuration.id
}
/// Configured platform for the container.
public var platform: ContainerizationOCI.Platform {
configuration.platform
}
/// The runtime status of the container.
public var status: RuntimeStatus
/// Network interfaces attached to the sandbox that are provided to the container.
@@ -20,68 +20,37 @@ import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation
import TerminalProgress
public struct ClientContainer: Sendable, Codable {
static let serviceIdentifier = "com.apple.container.apiserver"
/// A client for interacting with the container API server.
///
/// This client holds a reusable XPC connection and provides methods for
/// container lifecycle operations. All methods that operate on a specific
/// container take an `id` parameter.
public struct ContainerClient: Sendable {
private static let serviceIdentifier = "com.apple.container.apiserver"
/// Identifier of the container.
public var id: String {
configuration.id
}
private let xpcClient: XPCClient
public let status: RuntimeStatus
/// Configured platform for the container.
public var platform: ContainerizationOCI.Platform {
configuration.platform
}
/// Configuration for the container.
public let configuration: ContainerConfiguration
/// Network allocated to the container.
public let networks: [Attachment]
/// When the container was started.
public let startedDate: Date?
package init(configuration: ContainerConfiguration) {
self.configuration = configuration
self.status = .stopped
self.networks = []
self.startedDate = nil
}
init(snapshot: ContainerSnapshot) {
self.configuration = snapshot.configuration
self.status = snapshot.status
self.networks = snapshot.networks
self.startedDate = snapshot.startedDate
}
}
extension ClientContainer {
private static func newXPCClient() -> XPCClient {
XPCClient(service: serviceIdentifier)
/// Creates a new container client with a connection to the API server.
public init() {
self.xpcClient = XPCClient(service: Self.serviceIdentifier)
}
@discardableResult
private static func xpcSend(
client: XPCClient,
private func xpcSend(
message: XPCMessage,
timeout: Duration? = .seconds(15)
) async throws -> XPCMessage {
try await client.send(message, responseTimeout: timeout)
try await xpcClient.send(message, responseTimeout: timeout)
}
public static func create(
/// Create a new container with the given configuration.
public func create(
configuration: ContainerConfiguration,
options: ContainerCreateOptions = .default,
kernel: Kernel
) async throws -> ClientContainer {
) async throws {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerCreate)
let data = try JSONEncoder().encode(configuration)
@@ -91,8 +60,7 @@ extension ClientContainer {
request.set(key: .kernel, value: kdata)
request.set(key: .containerOptions, value: odata)
try await xpcSend(client: client, message: request)
return ClientContainer(configuration: configuration)
try await xpcSend(message: request)
} catch {
throw ContainerizationError(
.internalError,
@@ -102,13 +70,12 @@ extension ClientContainer {
}
}
public static func list() async throws -> [ClientContainer] {
/// List all containers.
public func list() async throws -> [ContainerSnapshot] {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerList)
let response = try await xpcSend(
client: client,
message: request,
timeout: .seconds(10)
)
@@ -116,8 +83,7 @@ extension ClientContainer {
guard let data else {
return []
}
let configs = try JSONDecoder().decode([ContainerSnapshot].self, from: data)
return configs.map { ClientContainer(snapshot: $0) }
return try JSONDecoder().decode([ContainerSnapshot].self, from: data)
} catch {
throw ContainerizationError(
.internalError,
@@ -128,9 +94,9 @@ extension ClientContainer {
}
/// Get the container for the provided id.
public static func get(id: String) async throws -> ClientContainer {
public func get(id: String) async throws -> ContainerSnapshot {
let containers = try await list()
guard let container = containers.first(where: { $0.id == id }) else {
guard let container = containers.first(where: { $0.configuration.id == id }) else {
throw ContainerizationError(
.notFound,
message: "get failed: container \(id) not found"
@@ -138,12 +104,10 @@ extension ClientContainer {
}
return container
}
}
extension ClientContainer {
public func bootstrap(stdio: [FileHandle?]) async throws -> ClientProcess {
/// Bootstrap the container's init process.
public func bootstrap(id: String, stdio: [FileHandle?]) async throws -> ClientProcess {
let request = XPCMessage(route: .containerBootstrap)
let client = Self.newXPCClient()
for (i, h) in stdio.enumerated() {
let key: XPCKeys = try {
@@ -162,9 +126,9 @@ extension ClientContainer {
}
do {
request.set(key: .id, value: self.id)
try await client.send(request)
return ClientProcessImpl(containerId: self.id, xpcClient: client)
request.set(key: .id, value: id)
try await xpcClient.send(request)
return ClientProcessImpl(containerId: id, xpcClient: xpcClient)
} catch {
throw ContainerizationError(
.internalError,
@@ -174,15 +138,15 @@ extension ClientContainer {
}
}
public func kill(_ signal: Int32) async throws {
/// Send a signal to the container.
public func kill(id: String, signal: Int32) async throws {
do {
let request = XPCMessage(route: .containerKill)
request.set(key: .id, value: self.id)
request.set(key: .processIdentifier, value: self.id)
request.set(key: .id, value: id)
request.set(key: .processIdentifier, value: id)
request.set(key: .signal, value: Int64(signal))
let client = Self.newXPCClient()
try await client.send(request)
try await xpcClient.send(request)
} catch {
throw ContainerizationError(
.internalError,
@@ -193,15 +157,14 @@ extension ClientContainer {
}
/// Stop the container and all processes currently executing inside.
public func stop(opts: ContainerStopOptions = ContainerStopOptions.default) async throws {
public func stop(id: String, opts: ContainerStopOptions = ContainerStopOptions.default) async throws {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerStop)
let data = try JSONEncoder().encode(opts)
request.set(key: .id, value: self.id)
request.set(key: .id, value: id)
request.set(key: .stopOptions, value: data)
try await client.send(request)
try await xpcClient.send(request)
} catch {
throw ContainerizationError(
.internalError,
@@ -212,13 +175,12 @@ extension ClientContainer {
}
/// Delete the container along with any resources.
public func delete(force: Bool = false) async throws {
public func delete(id: String, force: Bool = false) async throws {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerDelete)
request.set(key: .id, value: self.id)
request.set(key: .id, value: id)
request.set(key: .forceDelete, value: force)
try await client.send(request)
try await xpcClient.send(request)
} catch {
throw ContainerizationError(
.internalError,
@@ -228,27 +190,28 @@ extension ClientContainer {
}
}
public static func containerDiskUsage(id: String) async throws -> UInt64 {
let client = Self.newXPCClient()
/// Get the disk usage for a container.
public func diskUsage(id: String) async throws -> UInt64 {
let request = XPCMessage(route: .containerDiskUsage)
request.set(key: .id, value: id)
let reply = try await client.send(request)
let reply = try await xpcClient.send(request)
let size = reply.uint64(key: .containerSize)
return size
}
/// Create a new process inside a running container. The process is in a
/// created state and must still be started.
/// Create a new process inside a running container.
/// The process is in a created state and must still be started.
public func createProcess(
id: String,
containerId: String,
processId: String,
configuration: ProcessConfiguration,
stdio: [FileHandle?]
) async throws -> ClientProcess {
do {
let request = XPCMessage(route: .containerCreateProcess)
request.set(key: .id, value: self.id)
request.set(key: .processIdentifier, value: id)
request.set(key: .id, value: containerId)
request.set(key: .processIdentifier, value: processId)
let data = try JSONEncoder().encode(configuration)
request.set(key: .processConfig, value: data)
@@ -269,9 +232,8 @@ extension ClientContainer {
}
}
let client = Self.newXPCClient()
try await client.send(request)
return ClientProcessImpl(containerId: self.id, processId: id, xpcClient: client)
try await xpcClient.send(request)
return ClientProcessImpl(containerId: containerId, processId: processId, xpcClient: xpcClient)
} catch {
throw ContainerizationError(
.internalError,
@@ -281,13 +243,13 @@ extension ClientContainer {
}
}
public func logs() async throws -> [FileHandle] {
/// Get the log file handles for a container.
public func logs(id: String) async throws -> [FileHandle] {
do {
let client = Self.newXPCClient()
let request = XPCMessage(route: .containerLogs)
request.set(key: .id, value: self.id)
request.set(key: .id, value: id)
let response = try await client.send(request)
let response = try await xpcClient.send(request)
let fds = response.fileHandles(key: .logs)
guard let fds else {
throw ContainerizationError(
@@ -299,21 +261,21 @@ extension ClientContainer {
} catch {
throw ContainerizationError(
.internalError,
message: "failed to get logs for container \(self.id)",
message: "failed to get logs for container \(id)",
cause: error
)
}
}
public func dial(_ port: UInt32) async throws -> FileHandle {
/// Dial a port on the container via vsock.
public func dial(id: String, port: UInt32) async throws -> FileHandle {
let request = XPCMessage(route: .containerDial)
request.set(key: .id, value: self.id)
request.set(key: .id, value: id)
request.set(key: .port, value: UInt64(port))
let client = Self.newXPCClient()
let response: XPCMessage
do {
response = try await client.send(request)
response = try await xpcClient.send(request)
} catch {
throw ContainerizationError(
.internalError,
@@ -330,13 +292,13 @@ extension ClientContainer {
return fh
}
public func stats() async throws -> ContainerStats {
/// Get resource usage statistics for a container.
public func stats(id: String) async throws -> ContainerStats {
let request = XPCMessage(route: .containerStats)
request.set(key: .id, value: self.id)
request.set(key: .id, value: id)
let client = Self.newXPCClient()
do {
let response = try await client.send(request)
let response = try await xpcClient.send(request)
guard let data = response.dataNoCopy(key: .statistics) else {
throw ContainerizationError(
.internalError,
@@ -347,7 +309,7 @@ extension ClientContainer {
} catch {
throw ContainerizationError(
.internalError,
message: "failed to get statistics for container \(self.id)",
message: "failed to get statistics for container \(id)",
cause: error
)
}
@@ -446,8 +446,11 @@ public actor ContainersService {
self.log.debug("\(#function)")
// Logs doesn't care if the container is running or not, just that
// the bundle is there, and that the files actually exist.
// the bundle is there, and that the files actually exist. We do
// first try and get the container state so we get a nicer error message
// (container foo not found) however.
do {
_ = try _getContainerState(id: id)
let path = self.containerRoot.appendingPathComponent(id)
let bundle = ContainerResource.Bundle(path: path)
return [