Skip to content
Open
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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ let package = Package(
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerPersistence",
"ContainerResource",
"ContainerTestSupport",
]
),
Expand Down
2 changes: 2 additions & 0 deletions Sources/APIServer/APIServer+Start.swift
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,9 @@ extension APIServer {

routes[XPCRoute.containerList] = XPCServer.route(harness.list)
routes[XPCRoute.containerCreate] = XPCServer.route(harness.create)
routes[XPCRoute.containerCreateWithResult] = XPCServer.route(harness.create)
routes[XPCRoute.containerDelete] = XPCServer.route(harness.delete)
routes[XPCRoute.containerDeleteIfInstance] = XPCServer.route(harness.deleteIfInstance)
routes[XPCRoute.containerLogs] = XPCServer.route(harness.logs)
routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap)
routes[XPCRoute.containerDial] = XPCServer.route(harness.dial)
Expand Down
35 changes: 32 additions & 3 deletions Sources/ContainerCommands/Container/ContainerCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import TerminalProgress

extension Application {
public struct ContainerCreate: AsyncLoggableCommand {
enum OutputFormat: String, CaseIterable, ExpressibleByArgument, Sendable {
case json
}

public init() {}

public static let configuration = CommandConfiguration(
Expand All @@ -49,6 +53,9 @@ extension Application {
@OptionGroup
public var logOptions: Flags.Logging

@Option(help: "Output the server-generated container identity (values: json)")
var format: OutputFormat?

@Argument(help: "Image name")
var image: String

Expand Down Expand Up @@ -91,11 +98,29 @@ extension Application {

let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
let client = ContainerClient()
try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
let createResult: ContainerCreateResult?
if format == .json {
createResult = try await client.createWithResult(
configuration: ck.0,
options: options,
kernel: ck.1,
initImage: ck.2
)
} else {
_ = try await client.create(
configuration: ck.0,
options: options,
kernel: ck.1,
initImage: ck.2
)
createResult = nil
}

let createdID = createResult?.id ?? id

if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = id.data(using: .utf8)
let data = createdID.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
Expand All @@ -110,7 +135,11 @@ extension Application {
}
progress.finish()

print(id)
if let createResult {
Output.emit(try Output.renderJSON(createResult))
} else {
print(createdID)
}
}
}
}
20 changes: 19 additions & 1 deletion Sources/ContainerCommands/Container/ContainerDelete.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ extension Application {
@Flag(name: .shortAndLong, help: "Delete containers even if they are running")
var force = false

@Option(help: "Delete only if the container has this instance token")
var ifInstanceToken: String?

@OptionGroup
public var logOptions: Flags.Logging

Expand All @@ -51,11 +54,18 @@ extension Application {
message: "explicitly supplied container ID(s) conflict with the --all flag"
)
}
if ifInstanceToken != nil && (all || containerIds.count != 1) {
throw ContainerizationError(
.invalidArgument,
message: "--if-instance-token requires exactly one explicit container ID"
)
}
}

public mutating func run() async throws {
let client = ContainerClient()
let force = self.force
let expectedInstanceToken = self.ifInstanceToken

let containers: [String]
if all {
Expand All @@ -76,7 +86,15 @@ extension Application {
for container in containers {
group.addTask {
do {
try await client.delete(id: container, force: force)
if let expectedInstanceToken {
try await client.deleteIfInstance(
id: container,
force: force,
expectedInstanceToken: expectedInstanceToken
)
} else {
try await client.delete(id: container, force: force)
}
print(container)
return nil
} catch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import Foundation
public struct ContainerConfiguration: Sendable, Codable {
/// Identifier for the container.
public var id: String
/// Opaque server-generated identity for this incarnation of the container.
///
/// Older persisted containers may not have a token. The API server assigns
/// a new token on every create and ignores any value supplied by a client.
public var instanceToken: String?
/// Image used to create the container.
public var image: ImageDescription
/// External mounts to add to the container.
Expand Down Expand Up @@ -75,6 +80,7 @@ public struct ContainerConfiguration: Sendable, Codable {

enum CodingKeys: String, CodingKey {
case id
case instanceToken
case image
case mounts
case publishedPorts
Expand Down Expand Up @@ -107,6 +113,7 @@ public struct ContainerConfiguration: Sendable, Codable {
let container = try decoder.container(keyedBy: CodingKeys.self)

id = try container.decode(String.self, forKey: .id)
instanceToken = try container.decodeIfPresent(String.self, forKey: .instanceToken)
image = try container.decode(ImageDescription.self, forKey: .image)
mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? []
publishedPorts = try container.decodeIfPresent([PublishPort].self, forKey: .publishedPorts) ?? []
Expand Down
29 changes: 29 additions & 0 deletions Sources/ContainerResource/Container/ContainerCreateResult.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 server-owned identity returned by an atomic container create operation.
public struct ContainerCreateResult: Codable, Equatable, Sendable {
/// The reusable container identifier selected by the caller.
public let id: String

/// The opaque identity generated by the API server for this incarnation.
public let instanceToken: String

public init(id: String, instanceToken: String) {
self.id = id
self.instanceToken = instanceToken
}
}
3 changes: 3 additions & 0 deletions Sources/ContainerResource/Container/ManagedContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public struct ManagedContainer: ManagedResource {
/// not the protocol's 64-hex default.
public static func generateId() -> String { UUID().uuidString.lowercased() }

/// Mint an opaque, high-entropy identity for one container incarnation.
public static func generateInstanceToken() -> String { UUID().uuidString.lowercased() }

/// Container name rule
public static func nameValid(_ name: String) -> Bool {
// Maximum Linux hostname length is 64, but limit to maximum DNS label length
Expand Down
119 changes: 117 additions & 2 deletions Sources/Services/ContainerAPIService/Client/ContainerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,84 @@ public struct ContainerClient: Sendable {
}

/// Create a new container with the given configuration.
///
/// This legacy API intentionally returns `Void`. Call ``createWithResult(configuration:options:kernel:initImage:runtimeData:)``
/// when the authoritative server-generated instance identity is required.
public func create(
configuration: ContainerConfiguration,
options: ContainerCreateOptions = .default,
kernel: Kernel,
initImage: String? = nil,
runtimeData: Data? = nil
) async throws {
_ = try await create(
configuration: configuration,
options: options,
kernel: kernel,
initImage: initImage,
runtimeData: runtimeData,
route: .containerCreate
)
}

/// Create a new container and require its server-generated instance identity.
///
/// The capability check and distinct route ensure that an older server
/// rejects the request before creating a container without a result.
public func createWithResult(
configuration: ContainerConfiguration,
options: ContainerCreateOptions = .default,
kernel: Kernel,
initImage: String? = nil,
runtimeData: Data? = nil
) async throws -> ContainerCreateResult {
do {
let capabilityRequest = XPCMessage(route: .ping)
let capabilityReply = try await xpcSend(message: capabilityRequest, timeout: .seconds(10))
guard capabilityReply.bool(key: .containerCreateResultSupported) else {
throw ContainerizationError(
.unsupported,
message: "API server does not support atomic container create results"
)
}

guard
let result = try await create(
configuration: configuration,
options: options,
kernel: kernel,
initImage: initImage,
runtimeData: runtimeData,
route: .containerCreateWithResult
)
else {
throw ContainerizationError(
.internalError,
message: "API server omitted the atomic container create result"
)
}
return result
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(
.internalError,
message: "failed to create container with an atomic result",
cause: error
)
}
}

private func create(
configuration: ContainerConfiguration,
options: ContainerCreateOptions,
kernel: Kernel,
initImage: String?,
runtimeData: Data?,
route: XPCRoute
) async throws -> ContainerCreateResult? {
do {
let request = XPCMessage(route: .containerCreate)
let request = XPCMessage(route: route)

let data = try JSONEncoder().encode(configuration)
let kdata = try JSONEncoder().encode(kernel)
Expand All @@ -70,7 +139,8 @@ public struct ContainerClient: Sendable {
request.set(key: .runtimeData, value: runtimeData)
}

try await xpcSend(message: request)
let response = try await xpcSend(message: request)
return try Self.decodeCreateResult(response.dataNoCopy(key: .containerCreateResult))
} catch let error as ContainerizationError {
throw error
} catch {
Expand All @@ -82,6 +152,13 @@ public struct ContainerClient: Sendable {
}
}

static func decodeCreateResult(_ data: Data?) throws -> ContainerCreateResult? {
guard let data else {
return nil
}
return try JSONDecoder().decode(ContainerCreateResult.self, from: data)
}

/// List containers matching the given filters.
public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] {
do {
Expand Down Expand Up @@ -211,6 +288,44 @@ public struct ContainerClient: Sendable {
}
}

/// Delete the container only when its current instance identity matches.
public func deleteIfInstance(id: String, force: Bool, expectedInstanceToken: String) async throws {
try await Self.withConditionalDeleteErrorPreservation {
do {
let capabilityRequest = XPCMessage(route: .ping)
let capabilityReply = try await xpcSend(message: capabilityRequest, timeout: .seconds(10))
guard capabilityReply.bool(key: .conditionalContainerDeleteSupported) else {
throw ContainerizationError(
.unsupported,
message: "API server does not support conditional container deletion"
)
}

let request = XPCMessage(route: .containerDeleteIfInstance)
request.set(key: .id, value: id)
request.set(key: .forceDelete, value: force)
request.set(key: .expectedInstanceToken, value: expectedInstanceToken)
try await xpcClient.send(request)
}
}
}

static func withConditionalDeleteErrorPreservation(
_ operation: @Sendable () async throws -> Void
) async throws {
do {
try await operation()
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(
.internalError,
message: "failed to conditionally delete container",
cause: error
)
}
}

/// Get the disk usage for a container.
public func diskUsage(id: String) async throws -> UInt64 {
let request = XPCMessage(route: .containerDiskUsage)
Expand Down
10 changes: 10 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ public enum XPCKeys: String {
case processIdentifier
/// Container configuration key.
case containerConfig
/// Atomic container create result key.
case containerCreateResult
/// Container options key.
case containerOptions
/// Opaque runtime-specific data.
Expand All @@ -52,6 +54,12 @@ public enum XPCKeys: String {
case stopOptions
/// Whether to force stop a container when deleting.
case forceDelete
/// Expected server-generated identity for conditional container deletion.
case expectedInstanceToken
/// Whether the server implements atomic conditional container deletion.
case conditionalContainerDeleteSupported
/// Whether the server implements create with an atomic instance identity result.
case containerCreateResultSupported
/// Plugins
case pluginName
case plugins
Expand Down Expand Up @@ -148,11 +156,13 @@ public enum XPCKeys: String {
public enum XPCRoute: String {
case containerList
case containerCreate
case containerCreateWithResult
case containerBootstrap
case containerCreateProcess
case containerStartProcess
case containerWait
case containerDelete
case containerDeleteIfInstance
case containerStop
case containerDial
case containerResize
Expand Down
Loading