chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:06:18 +08:00
commit e84fb4a79e
474 changed files with 68321 additions and 0 deletions
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import Foundation
import SystemPackage
extension Application {
public struct ContainerCopy: AsyncLoggableCommand {
enum PathRef {
case local(String)
case container(id: String, path: String)
}
static func parsePathRef(_ ref: String) throws -> PathRef {
let parts = ref.components(separatedBy: ":")
switch parts.count {
case 1:
return .local(ref)
case 2 where !parts[0].isEmpty && parts[1].starts(with: "/"):
return .container(id: parts[0], path: parts[1])
default:
throw ContainerizationError(.invalidArgument, message: "invalid path given: \(ref)")
}
}
public init() {}
public static let configuration = CommandConfiguration(
commandName: "copy",
abstract: "Copy files/folders between a container and the local filesystem",
aliases: ["cp"])
@OptionGroup()
public var logOptions: Flags.Logging
@Argument(help: "Source path (container:path or local path)")
var source: String
@Argument(help: "Destination path (container:path or local path)")
var destination: String
public func run() async throws {
let client = ContainerClient()
let srcRef = try Self.parsePathRef(source)
let dstRef = try Self.parsePathRef(destination)
switch (srcRef, dstRef) {
case (.container(let id, let path), .local(let localPath)):
let srcPath = FilePath(path)
let destPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: destPath.string, isDirectory: &isDirectory)
var finalDestPath = destPath
if exists && isDirectory.boolValue {
guard let lastComponent = srcPath.lastComponent else {
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(path)")
}
finalDestPath = destPath.appending(lastComponent)
try await client.copyOut(id: id, source: path, destination: finalDestPath.string)
} else if localPath.hasSuffix("/") {
try await client.copyOut(id: id, source: path, destination: destPath.string)
var resultIsDir: ObjCBool = false
if FileManager.default.fileExists(atPath: destPath.string, isDirectory: &resultIsDir),
!resultIsDir.boolValue
{
try? FileManager.default.removeItem(atPath: destPath.string)
throw ContainerizationError(
.invalidArgument,
message: "destination is not a directory: \(localPath)")
}
} else {
try await client.copyOut(id: id, source: path, destination: destPath.string)
}
print(finalDestPath.string)
case (.local(let localPath), .container(let id, let path)):
let srcPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
var isDirectory: ObjCBool = false
guard let lastComponent = srcPath.lastComponent else {
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(localPath)")
}
guard FileManager.default.fileExists(atPath: srcPath.string, isDirectory: &isDirectory) else {
throw ContainerizationError(.notFound, message: "source path does not exist: \(localPath)")
}
if localPath.hasSuffix("/") && !isDirectory.boolValue {
throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)")
}
try await client.copyIn(id: id, source: srcPath.string, destination: path, createParents: true)
let printedDest = path.hasSuffix("/") ? "\(id):\(path)\(lastComponent.string)" : "\(id):\(path)"
print(printedDest)
case (.container, .container):
throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported")
case (.local, .local):
throw ContainerizationError(
.invalidArgument,
message: "one of source or destination must be a container reference (container_id:path)")
}
}
}
}
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import ContainerizationError
import Foundation
import TerminalProgress
extension Application {
public struct ContainerCreate: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup(title: "Resource options")
var resourceFlags: Flags.Resource
@OptionGroup(title: "Management options")
var managementFlags: Flags.Management
@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry
@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Image name")
var image: String
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 3
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let id = Utility.createContainerID(name: self.managementFlags.name)
try Utility.validEntityName(id)
let ck = try await Utility.containerConfigFromFlags(
id: id,
image: image,
arguments: arguments,
process: processFlags,
management: managementFlags,
resource: resourceFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler,
log: log
)
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
let client = ContainerClient()
try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = id.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
atPath: path,
contents: data,
attributes: attributes
)
guard success else {
throw ContainerizationError(
.internalError, message: "failed to create cidfile at \(path): \(errno)")
}
}
progress.finish()
print(id)
}
}
}
@@ -0,0 +1,100 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerDelete: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more containers",
aliases: ["rm"])
@Flag(name: .shortAndLong, help: "Delete all containers")
var all = false
@Flag(name: .shortAndLong, help: "Delete containers even if they are running")
var force = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(
.invalidArgument,
message: "explicitly supplied container ID(s) conflict with the --all flag"
)
}
}
public mutating func run() async throws {
let client = ContainerClient()
let force = self.force
let containers: [String]
if all {
let filters = ContainerListFilters().withoutMachines()
containers = try await client.list(filters: filters).compactMap { c in
// Skip running containers when using --all without --force
if c.status == .running && !force {
return nil
}
return c.id
}
} else {
containers = Array(Set(containerIds))
}
var errors: [any Error] = []
try await withThrowingTaskGroup(of: (any Error)?.self) { group in
for container in containers {
group.addTask {
do {
try await client.delete(id: container, force: force)
print(container)
return nil
} catch {
return error
}
}
}
for try await error in group {
if let error {
errors.append(error)
}
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,120 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import ContainerizationOS
import Foundation
extension Application {
public struct ContainerExec: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "exec",
abstract: "Run a new command in a running container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup
public var logOptions: Flags.Logging
@Flag(name: .shortAndLong, help: "Run the process and detach from it")
var detach = false
@Argument(help: "Container ID")
var containerId: String
@Argument(parsing: .captureForPassthrough, help: "New process arguments")
var arguments: [String]
public func run() async throws {
var exitCode: Int32 = 127
let client = ContainerClient()
let container = try await client.get(id: containerId)
try ensureRunning(container: container)
let stdin = self.processFlags.interactive
let tty = self.processFlags.tty
guard let executable = arguments.first else {
throw ContainerizationError(.invalidArgument, message: "no command specified for exec")
}
var config = container.configuration.initProcess
config.executable = executable
config.arguments = [String](self.arguments.dropFirst())
config.terminal = tty
config.environment.append(
contentsOf: try Parser.allEnv(
imageEnvs: [],
envFiles: self.processFlags.envFile,
envs: self.processFlags.env
))
if let cwd = self.processFlags.cwd {
config.workingDirectory = cwd
}
let defaultUser = config.user
let (user, additionalGroups) = Parser.user(
user: processFlags.user, uid: processFlags.uid,
gid: processFlags.gid, defaultUser: defaultUser)
config.user = user
config.supplementalGroups.append(contentsOf: additionalGroups)
do {
let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: self.detach)
defer {
try? io.close()
}
let process = try await client.createProcess(
containerId: container.id,
processId: UUID().uuidString.lowercased(),
configuration: config,
stdio: io.stdio
)
if self.detach {
try await process.start()
try io.closeAfterStart()
print(containerId)
return
}
if !self.processFlags.tty {
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
let log = self.log
handler.start {
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
Darwin.exit(1)
}
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to exec process \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,74 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Foundation
import TerminalProgress
extension Application {
public struct ContainerExport: AsyncLoggableCommand {
public init() {}
public static var configuration: CommandConfiguration {
CommandConfiguration(
commandName: "export",
abstract: "Export a container's filesystem as a tar archive",
)
}
@OptionGroup
public var logOptions: Flags.Logging
@Option(
name: .shortAndLong, help: "Pathname for the saved container filesystem (defaults to stdout)", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
var output: String?
@Argument(help: "container ID")
var id: String
public func run() async throws {
let client = ContainerClient()
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let archive = tempDir.appendingPathComponent("archive.tar")
try await client.export(id: id, archive: archive)
if output == nil {
guard let fileHandle = try? FileHandle(forReadingFrom: archive) else {
throw ContainerizationError(.internalError, message: "unable to open archive for reading")
}
let bufferSize = 4096
while true {
let chunk = fileHandle.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
FileHandle.standardOutput.write(chunk)
}
try fileHandle.close()
} else {
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
}
}
}
}
@@ -0,0 +1,56 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerInspect: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more containers")
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs to inspect")
var containerIds: [String]
public func run() async throws {
let client = ContainerClient()
let uniqueIds = Set(containerIds)
let containers = try await client.list().filter {
uniqueIds.contains($0.id)
}
if containers.count != uniqueIds.count {
let found = Set(containers.map { $0.id })
let missing = uniqueIds.subtracting(found).sorted()
throw ContainerizationError(
.notFound,
message: "container not found: \(missing.joined(separator: ", "))"
)
}
try Output.emit(Output.renderJSON(containers.map { ManagedContainer($0) }, options: .pretty))
}
}
}
@@ -0,0 +1,79 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Darwin
extension Application {
public struct ContainerKill: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "kill",
abstract: "Kill or signal one or more running containers")
@Flag(name: .shortAndLong, help: "Kill or signal all running containers")
var all = false
@Option(name: .shortAndLong, help: "Signal to send to the container(s)")
var signal: String = "KILL"
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
}
}
public mutating func run() async throws {
let client = ContainerClient()
let containers: [String]
if self.all {
let filters = ContainerListFilters(status: .running).withoutMachines()
containers = try await client.list(filters: filters).map { $0.id }
} else {
containers = containerIds
}
var errors: [any Error] = []
for container in containers {
do {
try await client.kill(id: container, signal: signal)
print(container)
} catch {
errors.append(error)
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationExtras
import Foundation
import SwiftProtobuf
extension Application {
public struct ContainerList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List running containers",
aliases: ["ls"])
@Flag(name: .shortAndLong, help: "Include containers that are not running")
var all = false
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the container ID")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let client = ContainerClient()
let filters = ContainerListFilters(status: self.all ? nil : .running).withoutMachines()
let containers = try await client.list(filters: filters)
let items = containers.map { ManagedContainer($0) }
try Output.render(payload: items, display: items, format: format, quiet: quiet)
}
}
}
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Darwin
import Dispatch
import Foundation
extension Application {
public struct ContainerLogs: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "logs",
abstract: "Fetch container logs"
)
@Flag(name: .long, help: "Display the boot log for the container instead of stdio")
var boot: Bool = false
@Flag(name: .shortAndLong, help: "Follow log output")
var follow: Bool = false
@Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs")
var numLines: Int?
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container ID")
var containerId: String
public func run() async throws {
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
)
}
private static func tail(
fh: FileHandle,
n: Int?,
follow: Bool
) async throws {
if let n {
var buffer = Data()
let size = try fh.seekToEnd()
var offset = size
var lines: [String] = []
while offset > 0, lines.count < n {
let readSize = min(1024, offset)
offset -= readSize
try fh.seek(toOffset: offset)
let data = fh.readData(ofLength: Int(readSize))
buffer.insert(contentsOf: data, at: 0)
if let chunk = String(data: buffer, encoding: .utf8) {
lines = chunk.components(separatedBy: .newlines)
lines = lines.filter { !$0.isEmpty }
}
}
lines = Array(lines.suffix(n))
for line in lines {
print(line)
}
} else {
// Fast path if all they want is the full file.
guard let data = try fh.readToEnd() else {
// Seems you get nil if it's a zero byte read, or you
// try and read from dev/null.
return
}
guard let str = String(data: data, encoding: .utf8) else {
throw ContainerizationError(
.internalError,
message: "failed to convert container logs to utf8"
)
}
print(str.trimmingCharacters(in: .newlines))
}
fflush(stdout)
if follow {
setbuf(stdout, nil)
try await Self.followFile(fh: fh)
}
}
private static func followFile(fh: FileHandle) async throws {
_ = try fh.seekToEnd()
let stream = AsyncStream<String> { cont in
fh.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
// Triggers on container restart - can exit here as well
do {
_ = try fh.seekToEnd() // To continue streaming existing truncated log files
} catch {
fh.readabilityHandler = nil
cont.finish()
return
}
}
if let str = String(data: data, encoding: .utf8), !str.isEmpty {
var lines = str.components(separatedBy: .newlines)
lines = lines.filter { !$0.isEmpty }
for line in lines {
cont.yield(line)
}
}
}
}
for await line in stream {
print(line)
}
}
}
}
@@ -0,0 +1,68 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerPrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove all stopped containers"
)
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let client = ContainerClient()
let filters = ContainerListFilters(status: .stopped).withoutMachines()
let containersToPrune = try await client.list(filters: filters)
var prunedContainerIds = [String]()
var totalSize: UInt64 = 0
for container in containersToPrune {
do {
let actualSize = try await client.diskUsage(id: container.id)
totalSize += actualSize
try await client.delete(id: container.id)
prunedContainerIds.append(container.id)
} catch {
log.error(
"failed to prune container",
metadata: [
"id": "\(container.id)",
"error": "\(error)",
])
}
}
let formatter = ByteCountFormatter()
let freed = formatter.string(fromByteCount: Int64(totalSize))
for name in prunedContainerIds {
print(name)
}
log.info("Reclaimed \(freed) in disk space")
}
}
}
@@ -0,0 +1,181 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
import NIOCore
import NIOPosix
import TerminalProgress
extension Application {
public struct ContainerRun: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run a container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup(title: "Resource options")
var resourceFlags: Flags.Resource
@OptionGroup(title: "Management options")
var managementFlags: Flags.Management
@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry
@OptionGroup(title: "Progress options")
var progressFlags: Flags.Progress
@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Image name")
var image: String
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
var exitCode: Int32 = 127
let id = Utility.createContainerID(name: self.managementFlags.name)
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 6
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try Utility.validEntityName(id)
// Check if container with id already exists.
let client = ContainerClient()
let existing = try? await client.get(id: id)
guard existing == nil else {
throw ContainerizationError(
.exists,
message: "container with id \(id) already exists"
)
}
let ck = try await Utility.containerConfigFromFlags(
id: id,
image: image,
arguments: arguments,
process: processFlags,
management: managementFlags,
resource: resourceFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler,
log: log
)
progress.set(description: "Starting container")
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
try await client.create(
configuration: ck.0,
options: options,
kernel: ck.1,
initImage: ck.2
)
let detach = self.managementFlags.detach
do {
let io = try ProcessIO.create(
tty: self.processFlags.tty,
interactive: self.processFlags.interactive,
detach: detach
)
defer {
try? io.close()
}
var dynamicEnv: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
}
let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv)
progress.finish()
if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = id.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
atPath: path,
contents: data,
attributes: attributes
)
guard success else {
throw ContainerizationError(
.internalError, message: "failed to create cidfile at \(path): \(errno)")
}
}
if detach {
try await process.start()
try io.closeAfterStart()
print(id)
return
}
if !self.processFlags.tty {
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
let log = self.log
handler.start {
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
Darwin.exit(1)
}
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await client.delete(id: id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to run container: \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,117 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import ContainerizationOS
import Foundation
import TerminalProgress
extension Application {
public struct ContainerStart: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start a container")
@Flag(name: .shortAndLong, help: "Attach stdout/stderr")
var attach = false
@Flag(name: .shortAndLong, help: "Attach stdin")
var interactive = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container ID")
var containerId: String
public func run() async throws {
var exitCode: Int32 = 127
let progressConfig = try ProgressConfig(
description: "Starting container"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let detach = !self.attach && !self.interactive
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
// support attach currently, so we can't do `start -a` a second time and have it succeed.
if container.status == .running {
if !detach {
throw ContainerizationError(
.invalidArgument,
message: "attach is currently unsupported on already running containers"
)
}
print(containerId)
return
}
for mount in container.configuration.mounts where mount.isVirtiofs {
if !FileManager.default.fileExists(atPath: mount.source) {
throw ContainerizationError(.invalidState, message: "mount source path '\(mount.source)' does not exist")
}
}
do {
let io = try ProcessIO.create(
tty: container.configuration.initProcess.terminal,
interactive: self.interactive,
detach: detach
)
defer {
try? io.close()
}
var env: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
env["SSH_AUTH_SOCK"] = sshAuthSock
}
let process = try await client.bootstrap(id: container.id, stdio: io.stdio, dynamicEnv: env)
progress.finish()
if detach {
try await process.start()
try io.closeAfterStart()
print(self.containerId)
return
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await client.stop(id: container.id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to start container: \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,284 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
extension Application {
public struct ContainerStats: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "stats",
abstract: "Display resource usage statistics for containers")
@Argument(help: "Container ID or name (optional, shows all running containers if not specified)")
var containers: [String] = []
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .long, help: "Disable streaming stats and only pull the first result")
var noStream = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
if format != .table || noStream {
// Static mode - get stats once and exit
try await runStatic()
} else {
// Streaming mode - continuously update like top
// Enter alternate screen buffer and hide cursor
print("\u{001B}[?1049h\u{001B}[?25l", terminator: "")
fflush(stdout)
defer {
// Exit alternate screen buffer and show cursor again
print("\u{001B}[?25h\u{001B}[?1049l", terminator: "")
fflush(stdout)
}
let containerIds = containers
try await withThrowingTaskGroup(of: Void.self) { group in
defer { group.cancelAll() }
group.addTask {
let handler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
for await _ in handler.signals {
throw CancellationError()
}
}
group.addTask { [containerIds] in
try await Self.runStreaming(containerIds: containerIds)
}
do {
try await group.next()
} catch is CancellationError {
// Normal exit on signal, defer will restore the terminal
}
}
}
}
private func runStatic() async throws {
let client = ContainerClient()
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
// No containers specified - show all running containers
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
// Fetch specified containers by ID
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
// Validate all specified containers were found
for containerId in containers {
guard containersToShow.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
)
}
}
}
let statsData = try await Self.collectStats(client: client, for: containersToShow)
try Output.render(payload: statsData.map { $0.stats2 }, format: format) {
Self.statsTable(statsData)
}
}
private static func runStreaming(containerIds: [String]) async throws {
let client = ContainerClient()
// If containers were specified, validate they all exist upfront
if !containerIds.isEmpty {
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containerIds))
for containerId in containerIds {
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
)
}
}
}
clearScreen()
// Show header right away.
print(statsTable([]))
while true {
do {
let containersToShow: [ContainerSnapshot]
if containerIds.isEmpty {
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
containersToShow = try await client.list(filters: ContainerListFilters(ids: containerIds))
}
let statsData = try await collectStats(client: client, for: containersToShow)
// Clear screen and reprint
clearScreen()
print(statsTable(statsData))
if statsData.isEmpty {
try await Task.sleep(for: .seconds(2))
}
} catch {
clearScreen()
print("error collecting stats: \(error)")
try await Task.sleep(for: .seconds(2))
}
}
}
private struct StatsSnapshot {
let container: ContainerSnapshot
let stats1: ContainerResource.ContainerStats
let stats2: ContainerResource.ContainerStats
}
private static 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 client.stats(id: container.id)
snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1))
} catch {
// Skip containers that error out
continue
}
}
// Wait 2 seconds for CPU delta calculation
if !snapshots.isEmpty {
try await Task.sleep(for: .seconds(2))
// Second sample
for i in 0..<snapshots.count {
do {
let stats2 = try await client.stats(id: snapshots[i].container.id)
snapshots[i] = StatsSnapshot(
container: snapshots[i].container,
stats1: snapshots[i].stats1,
stats2: stats2
)
} catch {
// Keep the original stats if second sample fails
continue
}
}
}
return snapshots
}
/// Calculate CPU percentage from two stat snapshots
/// - Parameters:
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
/// - timeDeltaUsec: Time delta between samples in microseconds
/// - Returns: CPU percentage where 100% = one fully utilized core
static func calculateCPUPercent(
cpuUsage1: Duration,
cpuUsage2: Duration,
timeInterval: Duration
) -> Double {
let cpuDelta =
cpuUsage2 > cpuUsage1
? cpuUsage2 - cpuUsage1
: .seconds(0)
return (cpuDelta / timeInterval) * 100.0
}
static func formatBytes(_ bytes: UInt64) -> String {
let kib = 1024.0
let mib = kib * 1024.0
let gib = mib * 1024.0
let value = Double(bytes)
if value >= gib {
return String(format: "%.2f GiB", value / gib)
} else if value >= mib {
return String(format: "%.2f MiB", value / mib)
} else {
return String(format: "%.2f KiB", value / kib)
}
}
private static func statsTable(_ statsData: [StatsSnapshot]) -> String {
let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]
let notAvailable = "--"
var rows = [headerRow]
for snapshot in statsData {
var row = [snapshot.container.id]
let stats1 = snapshot.stats1
let stats2 = snapshot.stats2
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
let cpuPercent = Self.calculateCPUPercent(
cpuUsage1: .microseconds(cpuUsageUsec1),
cpuUsage2: .microseconds(cpuUsageUsec2),
timeInterval: .seconds(2)
)
let cpuStr = String(format: "%.2f%%", cpuPercent)
row.append(cpuStr)
} else {
row.append(notAvailable)
}
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(memUsageStr) / \(memLimitStr)")
let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable
let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(netRxStr) / \(netTxStr)")
let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable
let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(blkReadStr) / \(blkWriteStr)")
let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable
row.append(pidsStr)
rows.append(row)
}
// Always print header, even if no containers
return TableOutput(rows: rows).format()
}
private static func clearScreen() {
// Move cursor to home position and clear from cursor to end of screen
print("\u{001B}[H\u{001B}[J", terminator: "")
fflush(stdout)
}
}
}
@@ -0,0 +1,107 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import Foundation
import Logging
extension Application {
public struct ContainerStop: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "stop",
abstract: "Stop one or more running containers")
@Flag(name: .shortAndLong, help: "Stop all running containers")
var all = false
@Option(name: .shortAndLong, help: "Signal to send to the containers")
var signal: String?
@Option(name: .shortAndLong, help: "Seconds to wait before killing the containers")
var time: Int32 = 5
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(
.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
}
}
public mutating func run() async throws {
let client = ContainerClient()
let containers: [String]
if self.all {
let filters = ContainerListFilters().withoutMachines()
containers = try await client.list(filters: filters).map { $0.id }
} else {
containers = containerIds
}
let opts = ContainerStopOptions(
timeoutInSeconds: self.time,
signal: self.signal
)
try await Self.stopContainers(
client: client,
containers: containers,
stopOptions: opts
)
}
static func stopContainers(client: ContainerClient, containers: [String], stopOptions: ContainerStopOptions) async throws {
var errors: [any Error] = []
await withTaskGroup(of: (any Error)?.self) { group in
for container in containers {
group.addTask {
do {
try await client.stop(id: container, opts: stopOptions)
print(container)
return nil
} catch {
return error
}
}
}
for await error in group {
if let error {
errors.append(error)
}
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
import ContainerResource
import Foundation
extension ManagedContainer: ListDisplayable {
public static var tableHeader: [String] {
["ID", "IMAGE", "OS", "ARCH", "STATE", "IP", "CPUS", "MEMORY", "STARTED"]
}
public var tableRow: [String] {
[
configuration.id,
configuration.image.reference,
configuration.platform.os,
configuration.platform.architecture,
status.state.rawValue,
status.networks.map { $0.ipv4Address.description }.joined(separator: ","),
"\(configuration.resources.cpus)",
"\(configuration.resources.memoryInBytes / (1024 * 1024)) MB",
status.startedDate?.ISO8601Format() ?? "",
]
}
public var quietValue: String { configuration.id }
}
@@ -0,0 +1,30 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-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.
//===----------------------------------------------------------------------===//
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
extension Application {
static func ensureRunning(container: ContainerSnapshot) throws {
if container.status != .running {
throw ContainerizationError(.invalidState, message: "container \(container.id) is not running")
}
}
}