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,32 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
/// An error type that aggregates multiple errors into one.
///
/// When displayed, each underlying error is printed on its own line.
public struct AggregateError: Swift.Error, Sendable {
public let errors: [any Error]
public init(_ errors: [any Error]) {
self.errors = errors
}
}
extension AggregateError: CustomStringConvertible {
public var description: String {
errors.map { String(describing: $0) }.joined(separator: "\n")
}
}
+287
View File
@@ -0,0 +1,287 @@
//===----------------------------------------------------------------------===//
// 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 ContainerLog
import ContainerPersistence
import ContainerPlugin
import ContainerVersion
import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
import SystemPackage
import TerminalProgress
// This logger is only used until `asyncCommand.run()`.
// `log` is updated only once in the `validate()` method.
private nonisolated(unsafe) var bootstrapLogger = {
LoggingSystem.bootstrap({ _ in StderrLogHandler() })
var log = Logger(label: "com.apple.container")
log.logLevel = .info
return log
}()
public struct Application: AsyncLoggableCommand {
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public static let configuration = CommandConfiguration(
commandName: "container",
abstract: "A container platform for macOS",
version: ReleaseVersion.singleLine(appName: "container CLI"),
subcommands: [
DefaultCommand.self,
HelpCommand.self,
],
groupedSubcommands: [
CommandGroup(
name: "Container",
subcommands: [
ContainerCopy.self,
ContainerCreate.self,
ContainerDelete.self,
ContainerExec.self,
ContainerExport.self,
ContainerInspect.self,
ContainerKill.self,
ContainerList.self,
ContainerLogs.self,
ContainerRun.self,
ContainerStart.self,
ContainerStats.self,
ContainerStop.self,
ContainerPrune.self,
]
),
CommandGroup(
name: "Image",
subcommands: [
BuildCommand.self,
ImageCommand.self,
RegistryCommand.self,
]
),
CommandGroup(
name: "Machine",
subcommands: [
MachineCommand.self
]
),
CommandGroup(
name: "Volume",
subcommands: [
VolumeCommand.self
]
),
CommandGroup(
name: "Other",
subcommands: Self.otherCommands()
),
],
// Hidden command to handle plugins on unrecognized input.
defaultSubcommand: DefaultCommand.self
)
public static func main() async throws {
restoreCursorAtExit()
#if DEBUG
let warning = "Running debug build. Performance may be degraded."
let formattedWarning: String
if isatty(FileHandle.standardError.fileDescriptor) == 1 {
formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n"
} else {
formattedWarning = "Warning! \(warning)\n"
}
let warningData = Data(formattedWarning.utf8)
FileHandle.standardError.write(warningData)
#endif
let fullArgs = CommandLine.arguments
let args = Array(fullArgs.dropFirst())
do {
var command = try Application.parseAsRoot(args)
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
// --help/-h on the root command (e.g. `container --help`) is intercepted
// by ArgumentParser and lands here.
let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help")
if fullArgs.count <= 2 && containsHelp {
let pluginLoader = try? await createPluginLoader()
await Self.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
let errorAsString: String = String(describing: error)
if errorAsString.contains("XPC connection error") {
let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.")
Application.exit(withError: modifiedError)
} else {
Application.exit(withError: error)
}
}
}
public static func createPluginLoader() async throws -> PluginLoader {
let installRootPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath.
let installRootURL = URL(fileURLWithPath: installRootPath.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
var directoryExists: ObjCBool = false
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
// plugins built into the application installed as a macOS app bundle
let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins")
// plugins built into the application installed as a Unix-like application
let installRootPluginsPath =
installRootPath
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
appBundlePluginsURL,
installRootPluginsURL,
].compactMap { $0 }
let pluginFactories: [any PluginFactory] = [
DefaultPluginFactory(logger: bootstrapLogger),
AppBundlePluginFactory(logger: bootstrapLogger),
]
guard let systemHealth = try? await ClientHealthCheck.ping(timeout: .seconds(10)) else {
throw ContainerizationError(.timeout, message: "unable to retrieve application data root from API server")
}
return try PluginLoader(
appRoot: systemHealth.appRoot,
installRoot: systemHealth.installRoot,
logRoot: systemHealth.logRoot,
pluginDirectories: pluginDirectories,
pluginFactories: pluginFactories,
log: bootstrapLogger
)
}
/// Load the system configuration using `appRoot` / `installRoot` reported by the
/// daemon. `container system start` MUST have previously been run to start the daemon.
public static func loadContainerSystemConfig() async throws -> ContainerSystemConfig {
let health = try await ClientHealthCheck.ping(timeout: .seconds(10))
let appRoot = FilePath(health.appRoot.path(percentEncoded: false))
let installRoot = FilePath(health.installRoot.path(percentEncoded: false))
return try await ConfigurationLoader.load(
configurationFiles: [
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
]
)
}
public func validate() throws {
// Not really a "validation", but a cheat to run this before
// any of the commands do their business.
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
if self.logOptions.debug || debugEnvVar != nil {
bootstrapLogger.logLevel = .debug
}
// Ensure we're not running under Rosetta.
if try isTranslated() {
throw ValidationError(
"""
`container` is currently running under Rosetta Translation, which could be
caused by your terminal application. Please ensure this is turned off.
"""
)
}
}
private static func otherCommands() -> [any ParsableCommand.Type] {
guard #available(macOS 26, *) else {
return [
BuilderCommand.self,
SystemCommand.self,
]
}
return [
BuilderCommand.self,
NetworkCommand.self,
SystemCommand.self,
]
}
private static func restoreCursorAtExit() {
let signalHandler: @convention(c) (Int32) -> Void = { signal in
let exitCode = ExitCode(signal + 128)
Application.exit(withError: exitCode)
}
// Termination by Ctrl+C.
signal(SIGINT, signalHandler)
// Termination using `kill`.
signal(SIGTERM, signalHandler)
// Normal and explicit exit.
atexit {
if let progressConfig = try? ProgressConfig() {
let progressBar = ProgressBar(config: progressConfig)
progressBar.resetCursor()
}
}
}
}
extension Application {
// Because we support plugins, we need to modify the help text to display
// any if we found some.
static func printModifiedHelpText(pluginLoader: PluginLoader?) async {
let original = Application.helpMessage(for: Application.self)
guard let pluginLoader else {
print(addGroupSpacing(original))
print("\nPLUGINS: not available, run `container system start`")
return
}
let altered = pluginLoader.alterCLIHelpText(original: original)
print(addGroupSpacing(altered))
}
private static func addGroupSpacing(_ text: String) -> String {
text
.replacingOccurrences(of: "\n([A-Z].+SUBCOMMANDS:)", with: "\n\n$1", options: .regularExpression)
.replacingOccurrences(of: "\nPLUGINS:", with: "\n\nPLUGINS:")
}
func isTranslated() throws -> Bool {
do {
return try Sysctl.byName("sysctl.proc_translated") == 1
} catch let posixErr as POSIXError {
if posixErr.code == .ENOENT {
return false
}
throw posixErr
}
}
}
@@ -0,0 +1,35 @@
//===----------------------------------------------------------------------===//
// 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 ContainerLog
import Logging
public protocol AsyncLoggableCommand: AsyncParsableCommand {
var logOptions: Flags.Logging { get }
}
extension AsyncLoggableCommand {
/// A shared logger instance configured based on the command's options
public var log: Logger {
var logger = Logger(label: "container", factory: { _ in StderrLogHandler() })
logger.logLevel = logOptions.debug ? .debug : .info
return logger
}
}
@@ -0,0 +1,515 @@
//===----------------------------------------------------------------------===//
// 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 ContainerBuild
import ContainerImagesServiceClient
import ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import NIO
import TerminalProgress
extension Application {
public struct BuildCommand: AsyncLoggableCommand {
public init() {}
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "build"
config.abstract = "Build an image from a Dockerfile or Containerfile"
config._superCommandName = "container"
config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help"))
return config
}
enum ProgressType: String, ExpressibleByArgument {
case auto
case plain
case tty
}
enum SecretType: Decodable {
case data(Data)
case file(String)
}
@Option(
name: .shortAndLong,
help: ArgumentHelp("Add the architecture type to the build", valueName: "value"),
transform: { val in val.split(separator: ",").map { String($0) } }
)
var arch: [[String]] = {
[[Arch.hostArchitecture().rawValue]]
}()
@Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val"))
var buildArg: [String] = []
@Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden))
var cacheIn: [String] = {
[]
}()
@Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden))
var cacheOut: [String] = {
[]
}()
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
var cpus: Int64?
@Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path"))
var file: String?
var dockerfile: String = "-"
@Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val"))
var label: [String] = []
@Option(
name: .shortAndLong,
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
)
var memory: String?
@Flag(name: .long, help: "Do not use cache")
var noCache: Bool = false
@Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build (format: type=<oci|tar|local>[,dest=])", valueName: "value"))
var output: [String] = {
["type=oci"]
}()
@Option(
name: .long,
help: ArgumentHelp("Add the OS type to the build", valueName: "value"),
transform: { val in val.split(separator: ",").map { String($0) } }
)
var os: [[String]] = {
[["linux"]]
}()
@Option(
name: .long,
help: "Add the platform to the build (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]",
transform: { val in val.split(separator: ",").map { String($0) } }
)
var platform: [[String]] = [[]]
@Option(name: .long, help: ArgumentHelp("Progress type (format: auto|plain|tty)", valueName: "type"))
var progress: ProgressType = .auto
@Flag(name: .shortAndLong, help: "Suppress build output")
var quiet: Bool = false
@Option(name: .long, help: ArgumentHelp("Set build-time secrets (format: id=<key>[,env=<ENV_VAR>|,src=<local/path>])", valueName: "id=key,..."))
var secret: [String] = []
var secrets: [String: SecretType] = [:]
@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
var targetImageNames: [String] = {
[UUID().uuidString.lowercased()]
}()
@Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage"))
var target: String = ""
@Option(name: .long, help: ArgumentHelp("Builder shim vsock port", valueName: "port"))
var vsockPort: UInt32 = 8088
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup
public var dns: Flags.DNS
@Argument(help: "Build directory")
var contextDir: String = "."
@Flag(name: .long, help: "Pull latest image")
var pull: Bool = false
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
do {
let timeout: Duration = .seconds(300)
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Dialing builder")
let dnsNameservers = self.dns.nameservers
let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in
defer {
group.cancelAll()
}
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in
let client = ContainerClient()
while true {
do {
let fh = try await client.dial(id: "buildkit", port: vsockPort)
let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let b = try await Builder(socket: fh, group: threadGroup, logger: log)
// If this call succeeds, then BuildKit is running.
let _ = try await b.info()
return b
} catch {
// If we get here, "Dialing builder" is shown for such a short period
// of time that it's invisible to the user.
progress.set(tasks: 0)
progress.set(totalTasks: 3)
try await BuilderStart.start(
cpus: cpus,
memory: memory,
log: log,
dnsNameservers: dnsNameservers,
progressUpdate: progress.handler,
containerSystemConfig: containerSystemConfig,
)
// wait (seconds) for builder to start listening on vsock
try await Task.sleep(for: .seconds(5))
continue
}
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw ValidationError(
"""
Timeout waiting for connection to builder
"""
)
}
return try await group.next()
}
guard let builder else {
throw ValidationError("builder is not running")
}
let buildFileData: Data
var ignoreFileData: Data? = nil
// Dockerfile should be read from stdin
if dockerfile == "-" {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
}
let bufferSize = 4096
while true {
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
fileHandle.write(chunk)
}
try fileHandle.close()
buildFileData = try Data(contentsOf: URL(filePath: tempFile.path()))
} else {
let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore")
buildFileData = try Data(contentsOf: URL(filePath: dockerfile))
ignoreFileData = try? Data(contentsOf: ignoreFileURL)
}
// BUG: See https://github.com/apple/container/issues/735.
// Reject dockerfiles larger than 16kb before attempting to build.
// TODO: Remove when #735 was been resolved.
let maxDockerfileSize = 16 * 1024 // 16 KiB
guard buildFileData.count < maxDockerfileSize else {
throw ContainerizationError(
.invalidArgument,
message: """
Dockerfile size (\(buildFileData.count) bytes) exceeds the maximum allowed size of \(maxDockerfileSize) bytes. \
See https://github.com/apple/container/issues/735.
"""
)
}
let secretsData: [String: Data] = try self.secrets.mapValues { secret in
switch secret {
case .data(let data):
return data
case .file(let path):
return try Data(contentsOf: URL(fileURLWithPath: path))
}
}
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportPath = systemHealth.appRoot
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
let buildID = UUID().uuidString
let tempURL = exportPath.appendingPathComponent(buildID)
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil)
defer {
try? FileManager.default.removeItem(at: tempURL)
}
let imageNames: [String] = try targetImageNames.map { name in
let parsedReference = try Reference.parse(name)
parsedReference.normalize()
return parsedReference.description
}
var terminal: Terminal?
switch self.progress {
case .tty:
terminal = try Terminal(descriptor: STDERR_FILENO)
case .auto:
terminal = try? Terminal(descriptor: STDERR_FILENO)
case .plain:
terminal = nil
}
defer { terminal?.tryReset() }
let exports: [Builder.BuildExport] = try output.map { output in
var exp = try Builder.BuildExport(from: output)
if exp.destination == nil {
exp.destination = tempURL.appendingPathComponent("out.tar")
}
return exp
}
try await withThrowingTaskGroup(of: Void.self) { [terminal] group in
defer {
group.cancelAll()
}
group.addTask {
let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2])
for await sig in handler.signals {
throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)")
}
}
let platforms: Set<Platform> = try {
var results: Set<Platform> = []
for platform in (self.platform.flatMap { $0 }) {
guard let p = try? Platform(from: platform) else {
throw ValidationError("invalid platform specified \(platform)")
}
results.insert(p)
}
if !results.isEmpty {
return results
}
if let envPlatform = try DefaultPlatform.fromEnvironment(log: log) {
return [envPlatform]
}
for o in (self.os.flatMap { $0 }) {
for a in (self.arch.flatMap { $0 }) {
guard let platform = try? Platform(from: "\(o)/\(a)") else {
throw ValidationError("invalid os/architecture combination \(o)/\(a)")
}
results.insert(platform)
}
}
return results
}()
group.addTask {
[
terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log,
] in
let config = Builder.BuildConfig(
buildID: buildID,
contentStore: RemoteContentStoreClient(),
buildArgs: buildArg,
secrets: secretsData,
contextDir: contextDir,
dockerfile: buildFileData,
dockerignore: ignoreFileData,
labels: label,
noCache: noCache,
platforms: [Platform](platforms),
terminal: terminal,
tags: imageNames,
target: target,
quiet: quiet,
exports: exports,
cacheIn: cacheIn,
cacheOut: cacheOut,
pull: pull,
containerSystemConfig: containerSystemConfig,
)
progress.finish()
try await builder.build(config)
let unpackProgressConfig = try ProgressConfig(
description: "Unpacking built image",
itemsName: "entries",
showTasks: exports.count > 1,
totalTasks: exports.count
)
let unpackProgress = ProgressBar(config: unpackProgressConfig)
defer {
unpackProgress.finish()
}
unpackProgress.start()
var finalMessage = imageNames.joined(separator: "\n")
let taskManager = ProgressTaskCoordinator()
// Currently, only a single export can be specified.
for exp in exports {
unpackProgress.add(tasks: 1)
let unpackTask = await taskManager.startTask()
switch exp.type {
case "oci":
try Task.checkCancellation()
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let result = try await ClientImage.load(from: dest.absolutePath(), force: false)
guard result.rejectedMembers.isEmpty else {
log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
throw ContainerizationError(.internalError, message: "failed to load archive")
}
for image in result.images {
try Task.checkCancellation()
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
// Tag the unpacked image with all requested tags
for tagName in imageNames {
try Task.checkCancellation()
_ = try await image.tag(new: tagName)
}
}
case "tar":
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let tarURL = tempURL.appendingPathComponent("out.tar")
try FileManager.default.moveItem(at: tarURL, to: dest)
finalMessage = dest.absolutePath()
case "local":
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let localDir = tempURL.appendingPathComponent("local")
guard FileManager.default.fileExists(atPath: localDir.path) else {
throw ContainerizationError(.invalidArgument, message: "expected local output not found")
}
try FileManager.default.copyItem(at: localDir, to: dest)
finalMessage = dest.absolutePath()
default:
throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)")
}
}
await taskManager.finish()
unpackProgress.finish()
print(finalMessage)
}
try await group.next()
}
} catch {
throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"])
}
}
public mutating func validate() throws {
// NOTE: Here we check the Dockerfile exists, and set `dockerfile` to point the valid Dockerfile path or stdin
guard FileManager.default.fileExists(atPath: contextDir) else {
throw ValidationError("context dir does not exist \(contextDir)")
}
for name in targetImageNames {
guard let _ = try? Reference.parse(name) else {
throw ValidationError("invalid reference \(name)")
}
}
switch file {
case "-":
dockerfile = "-"
break
case .some(let filepath):
let fileURL = URL(fileURLWithPath: filepath, relativeTo: .currentDirectory())
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw ValidationError("dockerfile does not exist \(filepath)")
}
dockerfile = fileURL.path
break
case .none:
guard let defaultDockerfile = try BuildFile.resolvePath(contextDir: contextDir) else {
throw ValidationError("dockerfile not found in context dir")
}
guard FileManager.default.fileExists(atPath: defaultDockerfile) else {
throw ValidationError("dockerfile does not exist \(defaultDockerfile)")
}
dockerfile = defaultDockerfile
break
}
// Parse --secret args
for secret in self.secret {
let parts = secret.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
guard parts[0].hasPrefix("id=") else {
throw ValidationError("secret must start with id=<key> \(secret)")
}
let key = String(parts[0].dropFirst(3))
guard !key.contains("=") else {
throw ValidationError("secret id cannot contain '=' \(key)")
}
if parts.count == 1 || parts[1].hasPrefix("env=") {
let env = parts.count == 1 ? key : String(parts[1].dropFirst(4))
// Using getenv/strlen over processInfo.environment to support
// non-UTF-8 env var data.
guard let ptr = getenv(env) else {
throw ValidationError("secret env var doesn't exist \(env)")
}
self.secrets[key] = .data(Data(bytes: ptr, count: strlen(ptr)))
} else if parts[1].hasPrefix("src=") {
let path = String(parts[1].dropFirst(4))
self.secrets[key] = .file(path)
} else {
throw ValidationError("secret bad value \(parts[1])")
}
}
}
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct BuilderCommand: AsyncLoggableCommand {
public init() {}
public static let builderResourceDir = "builder"
public static let configuration = CommandConfiguration(
commandName: "builder",
abstract: "Manage an image builder instance",
subcommands: [
BuilderStart.self,
BuilderStatus.self,
BuilderStop.self,
BuilderDelete.self,
])
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,61 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct BuilderDelete: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "delete"
config.aliases = ["rm"]
config.abstract = "Delete the builder container"
return config
}
@Flag(name: .shortAndLong, help: "Delete the builder even if it is running")
var force = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
do {
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 client.stop(id: container.id)
}
try await client.delete(id: container.id)
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
return
}
}
throw error
}
}
}
}
@@ -0,0 +1,340 @@
//===----------------------------------------------------------------------===//
// 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 ContainerBuild
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import Logging
import TerminalProgress
extension Application {
public struct BuilderStart: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "start"
config.abstract = "Start the builder container"
return config
}
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
var cpus: Int64?
@Option(
name: .shortAndLong,
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
)
var memory: String?
@OptionGroup
public var dns: Flags.DNS
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
totalTasks: 4
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try await BuilderStart.start(
cpus: self.cpus,
memory: self.memory,
log: log,
dnsNameservers: self.dns.nameservers,
dnsDomain: self.dns.domain,
dnsSearchDomains: self.dns.searchDomains,
dnsOptions: self.dns.options,
progressUpdate: progress.handler,
containerSystemConfig: containerSystemConfig,
)
progress.finish()
}
static func start(
cpus: Int64?,
memory: String?,
log: Logger,
dnsNameservers: [String] = [],
dnsDomain: String? = nil,
dnsSearchDomains: [String] = [],
dnsOptions: [String] = [],
progressUpdate: @escaping ProgressUpdateHandler,
containerSystemConfig: ContainerSystemConfig,
) async throws {
await progressUpdate([
.setDescription("Fetching BuildKit image"),
.setItemsName("blobs"),
])
let taskManager = ProgressTaskCoordinator()
let fetchTask = await taskManager.startTask()
let builderImage: String = containerSystemConfig.build.image
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportsMount: String = systemHealth.appRoot
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
.absolutePath()
if !FileManager.default.fileExists(atPath: exportsMount) {
try FileManager.default.createDirectory(
atPath: exportsMount,
withIntermediateDirectories: true,
attributes: nil
)
}
let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8")
var targetEnvVars: [String] = []
if let buildkitColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] {
targetEnvVars.append("BUILDKIT_COLORS=\(buildkitColors)")
}
if ProcessInfo.processInfo.environment["NO_COLOR"] != nil {
targetEnvVars.append("NO_COLOR=true")
}
targetEnvVars.sort()
let defaultBuildCPUs: Int = containerSystemConfig.build.cpus
let defaultBuildMemory = containerSystemConfig.build.memory
let resources = try Parser.resources(
cpus: cpus,
memory: memory,
defaultCPUs: defaultBuildCPUs,
defaultMemory: defaultBuildMemory,
)
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
let existingEnv = existingContainer.configuration.initProcess.environment
let existingDNS = existingContainer.configuration.dns
let existingManagedEnv = existingEnv.filter { envVar in
envVar.hasPrefix("BUILDKIT_COLORS=") || envVar.hasPrefix("NO_COLOR=")
}.sorted()
let envChanged = existingManagedEnv != targetEnvVars
// Check if we need to recreate the builder due to different image
let imageChanged = existingImage != builderImage
let cpuChanged = existingResources.cpus != resources.cpus
let memChanged = existingResources.memoryInBytes != resources.memoryInBytes
let dnsChanged = {
if !dnsNameservers.isEmpty {
return existingDNS?.nameservers != dnsNameservers
}
if dnsDomain != nil {
return existingDNS?.domain != dnsDomain
}
if !dnsSearchDomains.isEmpty {
return existingDNS?.searchDomains != dnsSearchDomains
}
if !dnsOptions.isEmpty {
return existingDNS?.options != dnsOptions
}
return false
}()
switch existingContainer.status {
case .running:
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
// If image, mem, cpu, env, and DNS are the same, continue using the existing builder
return
}
// If they changed, stop and delete the existing builder
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 startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
return
}
try await client.delete(id: existingContainer.id)
case .stopping:
throw ContainerizationError(
.invalidState,
message: "builder is stopping, please wait until it is fully stopped before proceeding"
)
case .unknown:
break
}
}
let useRosetta = containerSystemConfig.build.rosetta
let shimArguments = [
"--debug",
"--vsock",
useRosetta ? nil : "--enable-qemu",
].compactMap { $0 }
try ContainerAPIClient.Utility.validEntityName(Builder.builderContainerId)
let image = try await ClientImage.fetch(
reference: builderImage,
platform: builderPlatform,
containerSystemConfig: containerSystemConfig,
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate)
)
// Unpack fetched image before use
await progressUpdate([
.setDescription("Unpacking BuildKit image"),
.setItemsName("entries"),
])
let unpackTask = await taskManager.startTask()
_ = try await image.getCreateSnapshot(
platform: builderPlatform,
progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)
)
let imageDesc = ImageDescription(
reference: builderImage,
descriptor: image.descriptor
)
let imageConfig = try await image.config(for: builderPlatform).config
var environment = imageConfig?.env ?? []
environment.append(contentsOf: targetEnvVars)
let processConfig = ProcessConfiguration(
executable: "/usr/local/bin/container-builder-shim",
arguments: shimArguments,
environment: environment,
workingDirectory: "/",
terminal: false,
user: .id(uid: 0, gid: 0)
)
var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig)
config.resources = resources
config.labels = [
ResourceLabelKeys.plugin: "builder",
ResourceLabelKeys.role: ResourceRoleValues.builder,
]
config.capAdd = ["ALL"]
config.mounts = [
.init(
type: .tmpfs,
source: "",
destination: "/run",
options: []
),
.init(
type: .virtiofs,
source: exportsMount,
destination: "/var/lib/container-builder-shim/exports",
options: []
),
]
// Enable Rosetta only if the user didn't ask to disable it
config.rosetta = useRosetta
let networkClient = NetworkClient()
guard let defaultNetwork = try await networkClient.builtin else {
throw ContainerizationError(.invalidState, message: "default network is not present")
}
config.networks = [
AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId))
]
config.dns = ContainerConfiguration.DNSConfiguration(
nameservers: dnsNameservers,
domain: dnsDomain,
searchDomains: dnsSearchDomains,
options: dnsOptions
)
let kernel = try await {
await progressUpdate([
.setDescription("Fetching kernel"),
.setItemsName("binary"),
])
let kernel = try await ClientKernel.getDefaultKernel(for: .current)
return kernel
}()
await progressUpdate([
.setDescription("Starting BuildKit container")
])
try await client.create(
configuration: config,
options: .default,
kernel: kernel
)
try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager)
log.debug("starting BuildKit and BuildKit-shim")
}
}
}
// MARK: - BuildKit Start Helper
/// 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() }
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)
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)")
}
}
@@ -0,0 +1,93 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct BuilderStatus: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "status"
config.abstract = "Display the builder container status"
return config
}
@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 {
do {
let client = ContainerClient()
let container = try await client.get(id: "buildkit")
if format == .table && quiet && container.status != .running {
return
}
try Output.render(
payload: [ManagedContainer(container)],
display: [PrintableBuilder(container)],
format: format,
quiet: quiet
)
} catch let error as ContainerizationError where error.code == .notFound {
try Output.render(payload: [ManagedContainer](), format: format) {
quiet ? "" : "builder is not running"
}
}
}
}
}
private struct PrintableBuilder: ListDisplayable {
let snapshot: ContainerSnapshot
init(_ snapshot: ContainerSnapshot) {
self.snapshot = snapshot
}
static var tableHeader: [String] {
["ID", "IMAGE", "STATE", "IP", "CPUS", "MEMORY"]
}
var tableRow: [String] {
[
snapshot.id,
snapshot.configuration.image.reference,
snapshot.status.rawValue,
snapshot.networks.map { $0.ipv4Address.description }.joined(separator: ","),
"\(snapshot.configuration.resources.cpus)",
"\(snapshot.configuration.resources.memoryInBytes / (1024 * 1024)) MB",
]
}
var quietValue: String {
snapshot.id
}
}
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct BuilderStop: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "stop"
config.abstract = "Stop the builder container"
return config
}
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
do {
let client = ContainerClient()
try await client.stop(id: "buildkit")
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
log.warning("builder is not running")
return
}
}
throw error
}
}
}
}
@@ -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")
}
}
}
@@ -0,0 +1,111 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPlugin
import Darwin
import Foundation
import SystemPackage
struct DefaultCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: nil,
shouldDisplay: false
)
@OptionGroup(visibility: .hidden)
public var logOptions: Flags.Logging
@Argument(parsing: .captureForPassthrough)
var remaining: [String] = []
func run() async throws {
// See if we have a possible plugin command.
let pluginLoader = try? await Application.createPluginLoader()
guard let command = remaining.first else {
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
// Check for edge cases and unknown options to match the behavior in the absence of plugins.
if command.isEmpty {
throw ValidationError("unknown argument '\(command)'")
} else if command.starts(with: "-") {
throw ValidationError("unknown option '\(command)'")
}
// Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths)
let installRoot = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
let installRootPluginsPath =
installRoot
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let hintPaths = [userPluginsURL, installRootPluginsURL]
.map { $0.appendingPathComponent(command).path(percentEncoded: false) }
.joined(separator: "\n - ")
// If plugin loader couldn't be created, the system/APIServer likely isn't running.
if pluginLoader == nil {
throw ValidationError(
"""
Plugins are unavailable. Start the container system services and retry:
container system start
Check to see that the plugin exists under:
- \(hintPaths)
"""
)
}
guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else {
throw ValidationError(
"""
Plugin 'container-\(command)' not found.
- If system services are not running, start them with: container system start
- If the plugin isn't installed, ensure it exists under:
Check to see that the plugin exists under:
- \(hintPaths)
"""
)
}
// Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals.
Self.resetSignalsForPluginExec()
// Exec performs execvp (with no fork).
try plugin.exec(args: remaining)
}
}
extension DefaultCommand {
// Exposed for tests to verify signal reset semantics.
static func resetSignalsForPluginExec() {
signal(SIGINT, SIG_DFL)
signal(SIGTERM, SIG_DFL)
}
}
@@ -0,0 +1,76 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIClient
import Foundation
import TerminalProgress
extension Flags.Progress {
/// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY.
private func resolvedProgress() -> ProgressType {
switch progress {
case .auto:
return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain
case .none, .ansi, .plain, .color:
return progress
}
}
/// Creates a `ProgressConfig` based on the selected progress type.
///
/// For `.none`, progress updates are disabled. For `.ansi`, the given parameters
/// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish)
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
/// For `.auto`, the type is resolved by checking whether stderr is a TTY.
public func makeConfig(
description: String = "",
itemsName: String = "it",
showTasks: Bool = false,
showItems: Bool = false,
showSpeed: Bool = true,
ignoreSmallSize: Bool = false,
totalTasks: Int? = nil
) throws -> ProgressConfig {
let resolved = resolvedProgress()
switch resolved {
case .none:
return try ProgressConfig(disableProgressUpdates: true)
case .ansi, .plain, .color:
let isPlain = resolved == .plain
let outputMode: ProgressConfig.OutputMode
switch resolved {
case .plain: outputMode = .plain
case .color: outputMode = .color
default: outputMode = .ansi
}
return try ProgressConfig(
description: description,
itemsName: itemsName,
showSpinner: !isPlain,
showTasks: showTasks,
showItems: showItems,
showSpeed: showSpeed,
ignoreSmallSize: ignoreSmallSize,
totalTasks: totalTasks,
clearOnFinish: !isPlain,
outputMode: outputMode
)
case .auto:
fatalError("unreachable: .auto should have been resolved")
}
}
}
@@ -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
struct HelpCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "help",
shouldDisplay: false
)
@OptionGroup(visibility: .hidden)
public var logOptions: Flags.Logging
@Argument(parsing: .captureForPassthrough)
var subcommandPath: [String] = []
func run() async throws {
if subcommandPath.isEmpty {
let pluginLoader = try? await Application.createPluginLoader()
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
guard let target = Self.resolveSubcommand(path: subcommandPath) else {
throw ValidationError("unknown command '\(subcommandPath.joined(separator: " "))'")
}
print(Application.helpMessage(for: target))
}
static func resolveSubcommand(path: [String]) -> ParsableCommand.Type? {
var current: ParsableCommand.Type = Application.self
for name in path {
guard let next = childSubcommands(of: current).first(where: { matches($0, name: name) }) else {
return nil
}
current = next
}
return current
}
private static func childSubcommands(of command: ParsableCommand.Type) -> [ParsableCommand.Type] {
var all = command.configuration.subcommands
for group in command.configuration.groupedSubcommands {
all.append(contentsOf: group.subcommands)
}
return all
}
private static func matches(_ command: ParsableCommand.Type, name: String) -> Bool {
let cfg = command.configuration
if cfg.commandName == name { return true }
return cfg.aliases.contains(name)
}
}
@@ -0,0 +1,44 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct ImageCommand: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "image",
abstract: "Manage images",
subcommands: [
ImageDelete.self,
ImageInspect.self,
ImageList.self,
ImageLoad.self,
ImagePrune.self,
ImagePull.self,
ImagePush.self,
ImageSave.self,
ImageTag.self,
],
aliases: ["i"]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,116 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationError
import Foundation
import Logging
extension Application {
public struct RemoveImageOptions: ParsableArguments {
public init() {}
@Flag(name: .shortAndLong, help: "Delete all images")
var all: Bool = false
@Flag(name: .shortAndLong, help: "Ignore errors for images that are not found")
var force: Bool = false
@Argument
var images: [String] = []
}
struct DeleteImageImplementation {
static func validate(options: RemoveImageOptions) throws {
if options.images.count == 0 && !options.all {
throw ContainerizationError(.invalidArgument, message: "no images specified and --all not supplied")
}
if options.images.count > 0 && options.all {
throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag")
}
}
static func removeImage(options: RemoveImageOptions, containerSystemConfig: ContainerSystemConfig, log: Logger) async throws {
let (found, notFound) = try await {
if options.all {
let found = try await ClientImage.list()
let notFound: [String] = []
return (found, notFound)
}
return try await ClientImage.get(names: options.images, containerSystemConfig: containerSystemConfig)
}()
var failures: [String] = options.force ? [] : notFound
var didDeleteAnyImage = false
for image in found {
guard
!Utility.isInfraImage(
name: image.reference,
builderImage: containerSystemConfig.build.image,
initImage: containerSystemConfig.vminit.image
)
else {
continue
}
do {
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
print(image.reference)
didDeleteAnyImage = true
} catch {
log.error("failed to delete \(image.reference): \(error)")
failures.append(image.reference)
}
}
let (_, size) = try await ClientImage.cleanUpOrphanedBlobs()
let formatter = ByteCountFormatter()
let freed = formatter.string(fromByteCount: Int64(size))
if didDeleteAnyImage {
log.info("Reclaimed \(freed) in disk space")
}
if failures.count > 0 {
throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)")
}
}
}
public struct ImageDelete: AsyncLoggableCommand {
@OptionGroup
var options: RemoveImageOptions
@OptionGroup
public var logOptions: Flags.Logging
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more images",
aliases: ["rm"])
public init() {}
public func validate() throws {
try DeleteImageImplementation.validate(options: options)
}
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
}
}
}
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// 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 ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ImageInspect: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more images")
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Images to inspect")
var images: [String]
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let uniqueNames = Set(images)
let result = try await ClientImage.get(
names: Array(uniqueNames), containerSystemConfig: containerSystemConfig
)
if !result.error.isEmpty {
let missing = result.error.sorted()
throw ContainerizationError(
.notFound,
message: "image not found: \(missing.joined(separator: ", "))"
)
}
var printable: [ImageResource] = []
for image in result.images {
guard
!Utility.isInfraImage(
name: image.reference,
builderImage: containerSystemConfig.build.image,
initImage: containerSystemConfig.vminit.image
)
else { continue }
printable.append(
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
)
}
try Output.emit(Output.renderJSON(printable, options: .pretty))
}
}
}
@@ -0,0 +1,145 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOCI
import Foundation
extension Application {
public struct ImageList: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List images",
aliases: ["ls"])
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the image name")
var quiet = false
@Flag(name: .shortAndLong, help: "Verbose output")
var verbose = false
@OptionGroup
public var logOptions: Flags.Logging
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
try Self.validate(quiet: quiet, verbose: verbose)
var images = try await ClientImage.list().filter { img in
!Utility.isInfraImage(name: img.reference, builderImage: containerSystemConfig.build.image, initImage: containerSystemConfig.vminit.image)
}
images.sort { $0.reference < $1.reference }
// Quiet mode prints references directly and skips the more expensive
// per-image manifest resolution.
if quiet && format == .table {
for image in images {
let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig)
print(processedReferenceString)
}
return
}
let resources = try await Self.buildResources(images: images, containerSystemConfig: containerSystemConfig)
try Output.render(payload: resources, format: format) {
if verbose {
return Output.renderTable(resources.flatMap { VerboseImageRow.rows(for: $0) })
}
return Output.renderTable(resources)
}
}
private static func validate(quiet: Bool, verbose: Bool) throws {
if quiet && verbose {
throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together")
}
}
/// Builds the resource for each image, denormalizing the reference so the
/// display name omits the default registry.
private static func buildResources(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageResource] {
var resources: [ImageResource] = []
for image in images {
resources.append(
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
)
}
return resources
}
}
}
/// A single row of the verbose image listing one per platform variant.
private struct VerboseImageRow: ListDisplayable {
let name: String
let tag: String
let indexDigest: String
let os: String
let arch: String
let variant: String
let fullSize: String
let created: String
let manifestDigest: String
static var tableHeader: [String] {
["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"]
}
var tableRow: [String] {
[name, tag, indexDigest, os, arch, variant, fullSize, created, manifestDigest]
}
var quietValue: String {
name
}
/// Flattens an ImageResource into one verbose image row entry per platform variant.
static func rows(for resource: ImageResource) -> [VerboseImageRow] {
let formatter = ByteCountFormatter()
let reference = try? ContainerizationOCI.Reference.parse(resource.displayReference)
let name = reference?.name ?? resource.displayReference
let tag = reference?.tag ?? "<none>"
let indexDigest = Utility.trimDigest(digest: resource.configuration.descriptor.digest)
return
resource.variants
// Skip attestation manifests, which use the `unknown/unknown` platform.
.filter { !($0.platform.os == "unknown" && $0.platform.architecture == "unknown") }
.map { variant in
VerboseImageRow(
name: name,
tag: tag,
indexDigest: indexDigest,
os: variant.platform.os,
arch: variant.platform.architecture,
variant: variant.platform.variant ?? "",
fullSize: formatter.string(fromByteCount: variant.size),
created: variant.config.created ?? "",
manifestDigest: Utility.trimDigest(digest: variant.digest)
)
}
}
}
@@ -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 Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import TerminalProgress
extension Application {
public struct ImageLoad: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "load",
abstract: "Load images from an OCI compatible tar archive"
)
@Option(
name: .shortAndLong, help: "Path to the image tar archive", completion: .file(),
transform: { str in
FilePathOps.absolutePath(FilePath(str))
})
var input: FilePath?
@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")
public var force = false
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
// Read from stdin; otherwise read from the input file
let resolvedPath: FilePath
if let input {
guard FileManager.default.fileExists(atPath: input.string) else {
log.error("file does not exist", metadata: ["path": "\(input)"])
Application.exit(withError: ArgumentParser.ExitCode(1))
}
resolvedPath = input
} else {
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
}
let bufferSize = 4096
while true {
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
fileHandle.write(chunk)
}
try fileHandle.close()
resolvedPath = FilePath(tempFile.path())
}
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
totalTasks: 2
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Loading tar archive")
let result = try await ClientImage.load(
from: resolvedPath.string,
force: force)
if !result.rejectedMembers.isEmpty {
log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
}
let taskManager = ProgressTaskCoordinator()
let unpackTask = await taskManager.startTask()
progress.set(description: "Unpacking image")
progress.set(itemsName: "entries")
for image in result.images {
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
}
await taskManager.finish()
progress.finish()
for image in result.images {
print(image.reference)
}
}
}
}
@@ -0,0 +1,97 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOCI
import Foundation
extension Application {
public struct ImagePrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove unused or all images")
@OptionGroup
public var logOptions: Flags.Logging
@Flag(name: .shortAndLong, help: "Remove all unused images, not just dangling ones")
var all: Bool = false
public func run() async throws {
let allImages = try await ClientImage.list()
let imagesToPrune: [ClientImage]
if all {
// Find all images not used by any container
let client = ContainerClient()
let containers = try await client.list()
var imagesInUse = Set<String>()
for container in containers {
imagesInUse.insert(container.configuration.image.reference)
}
imagesToPrune = allImages.filter { image in
!imagesInUse.contains(image.reference)
}
} else {
// Find dangling images (images with no tag)
imagesToPrune = allImages.filter { image in
!hasTag(image.reference)
}
}
var prunedImages = [String]()
for image in imagesToPrune {
do {
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
prunedImages.append(image.reference)
} catch {
log.error(
"failed to prune image",
metadata: [
"ref": "\(image.reference)",
"error": "\(error)",
])
}
}
let (deletedDigests, size) = try await ClientImage.cleanUpOrphanedBlobs()
for image in imagesToPrune {
print("untagged \(image.reference)")
}
for digest in deletedDigests {
print("deleted \(digest)")
}
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let freed = formatter.string(fromByteCount: Int64(size))
log.info("Reclaimed \(freed) in disk space")
}
private func hasTag(_ reference: String) -> Bool {
do {
let ref = try ContainerizationOCI.Reference.parse(reference)
return ref.tag != nil && !ref.tag!.isEmpty
} catch {
return false
}
}
}
}
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationOCI
import TerminalProgress
extension Application {
public struct ImagePull: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "pull",
abstract: "Pull an image"
)
@OptionGroup
var registry: Flags.Registry
@OptionGroup
var progressFlags: Flags.Progress
@OptionGroup
var imageFetchFlags: Flags.ImageFetch
@Option(
name: .shortAndLong,
help: "Limit the pull to the specified architecture"
)
var arch: String?
@Option(
help: "Limit the pull to the specified OS"
)
var os: String?
@Option(
help: "Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
)
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var reference: String
public init() {}
public init(platform: String? = nil, scheme: String = "auto", reference: String) {
self.logOptions = Flags.Logging()
self.registry = Flags.Registry(scheme: scheme)
self.platform = platform
self.reference = reference
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
let processedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig)
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 2
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Fetching image")
progress.set(itemsName: "blobs")
let taskManager = ProgressTaskCoordinator()
let fetchTask = await taskManager.startTask()
let image = try await ClientImage.pull(
reference: processedReference, platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig,
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler),
maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads
)
progress.set(description: "Unpacking image")
progress.set(itemsName: "entries")
let unpackTask = await taskManager.startTask()
try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
await taskManager.finish()
progress.finish()
}
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationOCI
import TerminalProgress
extension Application {
public struct ImagePush: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "push",
abstract: "Push an image"
)
@OptionGroup
var registry: Flags.Registry
@OptionGroup
var progressFlags: Flags.Progress
@Option(
name: .shortAndLong,
help: "Limit the push to the specified architecture"
)
var arch: String?
@Option(
help: "Limit the push to the specified OS"
)
var os: String?
@Option(help: "Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]")
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var reference: String
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
let image = try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig)
let progressConfig = try self.progressFlags.makeConfig(
description: "Pushing image \(image.reference)",
itemsName: "blobs",
showItems: true,
showSpeed: false,
ignoreSmallSize: true
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
_ = try await image.push(platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
progress.finish()
print(image.reference)
}
}
}
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIClient
import ContainerResource
import ContainerizationOCI
extension ImageResource: ListDisplayable {
public static var tableHeader: [String] {
["NAME", "TAG", "DIGEST"]
}
public var tableRow: [String] {
// `displayReference` is already denormalized by the caller.
let reference = try? ContainerizationOCI.Reference.parse(displayReference)
return [
reference?.name ?? displayReference,
reference?.tag ?? "<none>",
Utility.trimDigest(digest: configuration.descriptor.digest),
]
}
public var quietValue: String {
name
}
}
@@ -0,0 +1,155 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOCI
import ContainerizationOS
import Foundation
import SystemPackage
import TerminalProgress
extension Application {
public struct ImageSave: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "save",
abstract: "Save one or more images as an OCI compatible tar archive"
)
@Option(
name: .shortAndLong,
help: "Architecture for the saved image"
)
var arch: String?
@Option(
help: "OS for the saved image"
)
var os: String?
@Option(
name: .shortAndLong, help: "Pathname for the saved image", completion: .file(),
transform: { str in
FilePathOps.absolutePath(FilePath(str))
})
var output: FilePath?
@Option(
help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
)
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var references: [String]
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let progressConfig = try ProgressConfig(
description: "Saving image(s)"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
var images: [ImageDescription] = []
for reference in references {
do {
images.append(try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig).description)
} catch {
log.error("failed to get image for reference \(reference): \(error)")
}
}
guard images.count == references.count else {
throw ContainerizationError(.invalidArgument, message: "failed to save image(s)")
}
if let p {
for (reference, description) in zip(references, images) {
let image = ClientImage(description: description)
do {
_ = try await image.manifest(for: p)
} catch {
var available: [String] = []
if let index = try? await image.index() {
available = index.manifests
.compactMap { $0.platform?.description }
.filter { $0 != "unknown/unknown" }
}
let availableStr = available.isEmpty ? "none" : available.joined(separator: ", ")
throw ContainerizationError(
.invalidArgument,
message: "image \(reference) has no content for platform \(p.description); available platforms: \(availableStr)"
)
}
}
}
// Write to stdout; otherwise write to the output file
if let output {
try await ClientImage.save(references: references, out: output.string, platform: p, containerSystemConfig: containerSystemConfig)
} else {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
try await ClientImage.save(references: references, out: tempFile.path(), platform: p, containerSystemConfig: containerSystemConfig)
guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for reading")
}
let bufferSize = 4096
while true {
let chunk = fileHandle.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
FileHandle.standardOutput.write(chunk)
}
try fileHandle.close()
}
progress.finish()
for reference in references {
if output == nil {
// stdout is carrying the OCI archive in this branch, so the
// saved-reference list goes to stderr via the logger. Printing
// it to stdout appends non-archive bytes after the tar EOF and
// corrupts the stream for redirection and pipelines (#1801).
log.info("\(reference)")
} else {
print(reference)
}
}
}
}
}
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct ImageTag: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "tag",
abstract: "Create a new reference for an existing image")
@Argument(help: "The existing image reference (format: image-name[:tag])")
var source: String
@Argument(help: "The new image reference")
var target: String
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig)
let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig)
try await existing.tag(new: targetReference)
print(target)
}
}
}
@@ -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.
//===----------------------------------------------------------------------===//
/// A type that can be rendered as a table row or quiet-mode output.
///
/// Conformers provide the column headers, row values, and a primary identifier
/// for quiet mode. JSON encoding is handled separately by each command using
/// its own data model.
public protocol ListDisplayable {
/// Column headers for table output (e.g., `["ID", "IMAGE", "STATE"]`).
static var tableHeader: [String] { get }
/// The values for each column, matching the order of ``tableHeader``.
var tableRow: [String] { get }
/// The primary identifier shown in `--quiet` mode (typically ID or name).
var quietValue: String { get }
}
@@ -0,0 +1,24 @@
//===----------------------------------------------------------------------===//
// 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
public enum ListFormat: String, CaseIterable, ExpressibleByArgument, Sendable {
case json
case table
case yaml
case toml
}
@@ -0,0 +1,32 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import Virtualization
/// Pre-flight checks for container machine settings that depend on host capabilities.
enum MachineCapabilities {
/// Throws when the host can't run a VM with `isNestedVirtualizationEnabled = true`.
/// Apple Silicon M3+ with macOS 15+ is required.
static func requireNestedVirtualizationSupported() throws {
guard VZGenericPlatformConfiguration.isNestedVirtualizationSupported else {
throw ContainerizationError(
.unsupported,
message: "nested virtualization is not supported on this host (requires Apple Silicon M3+ and macOS 15+)"
)
}
}
}
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct MachineCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "machine",
abstract: "Manage container machines",
discussion: """
EXAMPLES:
List available images and create a container machine:
$ container machine create alpine:3.22 --name my-machine
Run commands in the container machine:
$ container machine run -n my-machine uname
$ container machine run -n my-machine -- cat /proc/cpuinfo
Change the container machine configuration (takes effect after restart):
$ container machine set -n my-machine cpus=4 memory=8G home-mount=ro
$ container machine stop my-machine
$ container machine run -n my-machine -- nproc
Stop and delete the container machine:
$ container machine stop my-machine
$ container machine delete my-machine
""",
subcommands: [
MachineCreate.self,
MachineDelete.self,
MachineInspect.self,
MachineList.self,
MachineLogs.self,
MachineRun.self,
MachineSet.self,
MachineSetDefault.self,
MachineStop.self,
],
aliases: ["m"]
)
public init() {}
@OptionGroup
public var logOptions: Flags.Logging
}
}
extension Application.MachineCommand {
public enum ListFormat: String, CaseIterable, ExpressibleByArgument {
case json
case table
}
}
@@ -0,0 +1,153 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import ContainerizationError
import ContainerizationOCI
import Foundation
import MachineAPIClient
import TerminalProgress
extension Application {
public struct MachineCreate: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new container machine and boot it")
@OptionGroup(title: "Management options")
var managementFlags: Flags.MachineManagement
@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
@Option(name: [.short, .long], help: "Name for the container machine")
public var name: String?
@Flag(name: .long, help: "Set this container machine as the default")
public var setDefault: Bool = false
@Flag(name: .long, help: "Create the container machine without booting it")
public var noBoot: Bool = false
@Option(name: .long, help: "Number of virtual CPUs")
public var cpus: Int?
@Option(name: .long, help: "Memory allocation (e.g., 2G, 8G). Default: half of system memory")
public var memory: String?
@Option(name: .long, help: "User's home directory mount option (ro, rw, none). Default: rw")
public var homeMount: String?
@Flag(name: .long, help: "Enable nested virtualization (requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y)")
public var virtualization: Bool = false
@Option(name: .long, help: "Path to a custom kernel binary (e.g. vmlinux).")
public var kernel: String?
@Argument(help: "Container image reference (e.g., alpine:3.22)")
var image: String
public func run() async throws {
if virtualization {
try MachineCapabilities.requireNestedVirtualizationSupported()
}
let resolvedKernel = try kernel.map { try MachineConfig.validateKernelPath($0) }
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 3
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let defaultConfig = containerSystemConfig.machine
let bootConfig = try defaultConfig.with(
[
"cpus": cpus.map { "\($0)" },
"memory": memory,
"home-mount": homeMount,
"virtualization": virtualization ? "true" : nil,
"kernel": resolvedKernel?.string,
].compactMapValues { $0 }
)
let id: String
if let name {
id = name
} else {
let reference = try Reference.parse(image)
reference.normalize()
let imageName = reference.name.components(separatedBy: "/").last!
let suffix = reference.tag ?? reference.digest ?? "latest"
id = "\(imageName)-\(suffix)"
}
try Utility.validEntityName(id)
let client = MachineClient()
let (config, resources) = try await MachineClient.machineConfigFromFlags(
id: id,
image: image,
management: managementFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler
)
do {
try await client.create(configuration: config, resources: resources, bootConfig: bootConfig)
progress.finish() // Finish before subsequent output to avoid mangling
} catch let error as ContainerizationError {
if let cause = error.cause as? ContainerizationError, cause.isCode(.exists) {
let append = name == nil ? " (missing '-n/--name' flag)" : ""
throw ContainerizationError(.exists, message: cause.message + append)
}
throw error
}
if setDefault {
try await client.setDefault(id: id)
}
if !noBoot {
try await bootMachine(id: id, client: client, log: log, interactive: false)
}
print(id)
}
}
}
@@ -0,0 +1,66 @@
//===----------------------------------------------------------------------===//
// 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 MachineAPIClient
import TerminalProgress
extension Application {
public struct MachineDelete: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a container machine",
aliases: ["rm"]
)
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup(visibility: .hidden)
var progressFlags: Flags.Progress
@Argument(help: "Container machine ID")
var id: String
public func run() async throws {
let client = MachineClient()
let wasDefault = try await client.getDefault() == id
let progressConfig = try self.progressFlags.makeConfig(
description: "Deleting container machine"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try? await client.stop(id: id)
try await client.delete(id: id)
progress.finish()
print(id)
if wasDefault {
log.info("Deleted default container '\(id)'. Set a new default with 'container machine set-default <id>'.")
}
}
}
}
@@ -0,0 +1,106 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
import Logging
import MachineAPIClient
/// Resolves a container machine ID from an optional argument, falling back to the default machine.
func resolveMachineId(_ id: String?, client: MachineClient) async throws -> String {
if let id {
return id
}
guard let defaultId = try await client.getDefault() else {
throw ContainerizationError(
.invalidArgument,
message: "no container machine specified and no default set"
)
}
return defaultId
}
/// Boots a container machine and, on first ever boot, runs the in-VM init script
/// to set up the host user. Returns the resulting snapshot.
///
/// When `interactive` is true the init script is wired to the host's terminal
/// (used by `machine run`); otherwise it runs detached so non-TTY callers like
/// `machine create` don't require a TTY or pollute host stdout.
///
/// On any failure during user setup the machine is stopped to leave it in a clean state.
@discardableResult
func bootMachine(
id: String?,
client: MachineClient,
log: Logger,
interactive: Bool
) async throws -> MachineSnapshot {
var dynamicEnv: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
}
let snapshot = try await client.boot(id: id, dynamicEnv: dynamicEnv)
guard !snapshot.initialized else {
return snapshot
}
do {
guard let containerId = snapshot.containerId else {
throw ContainerizationError(
.invalidState,
message: "container machine is running but has no container ID"
)
}
let io = try ProcessIO.create(
tty: interactive,
interactive: interactive,
detach: !interactive
)
defer {
try? io.close()
}
let processConfig = ProcessConfiguration(
executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)",
arguments: ["-u"],
environment: snapshot.configuration.processEnvironment,
terminal: interactive
)
let process = try await ContainerClient().createProcess(
containerId: containerId,
processId: UUID().uuidString.lowercased(),
configuration: processConfig,
stdio: io.stdio)
let exitCode = try await io.handleProcess(process: process, log: log)
guard exitCode == 0 else {
throw ContainerizationError(
.invalidState,
message: "container machine failed to create user"
)
}
} catch {
try? await client.stop(id: snapshot.id)
throw error
}
return snapshot
}
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import ContainerResource
import ContainerizationOCI
import Foundation
import MachineAPIClient
extension Application {
public struct MachineInspect: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display detailed information about a container machine"
)
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container machine ID (uses default if not specified)")
var id: String?
public func run() async throws {
let client = MachineClient()
let machineId = try await resolveMachineId(id, client: client)
let snapshot = try await client.inspect(id: machineId)
let output = InspectOutput(snapshot)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode([output])
print(String(decoding: data, as: UTF8.self))
}
}
}
private struct InspectOutput: Codable {
let id: String
let image: ImageDescription
let platform: ContainerizationOCI.Platform
let userSetup: UserSetup
let status: RuntimeStatus
let startedDate: Date?
let createdDate: Date?
let containerId: String?
let cpus: Int
let memory: UInt64
let homeMount: MachineConfig.HomeMountOption
let diskSize: UInt64?
let ipAddress: String?
init(_ snapshot: MachineSnapshot) {
self.id = snapshot.id
self.image = snapshot.configuration.image
self.platform = snapshot.platform
self.userSetup = snapshot.configuration.userSetup
self.status = snapshot.status
self.startedDate = snapshot.startedDate
self.createdDate = snapshot.createdDate
self.containerId = snapshot.containerId
self.cpus = snapshot.bootConfig.cpus
self.memory = snapshot.bootConfig.memory.toUInt64(unit: .bytes)
self.homeMount = snapshot.bootConfig.homeMount
self.diskSize = snapshot.diskSize
self.ipAddress = snapshot.ipAddress
}
}
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import MachineAPIClient
extension Application {
public struct MachineList: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List container machines",
aliases: ["ls"]
)
@Option(name: .long, help: "Format of the output")
var format: MachineCommand.ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the container machine ID")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let client = MachineClient()
let machines = try await client.list()
if self.quiet {
machines.forEach { print($0.id) }
return
}
let defaultMachine = try await client.getDefault()
try printMachines(machines: machines, format: format, defaultMachine: defaultMachine)
}
private func printMachines(
machines: [MachineSnapshot],
format: MachineCommand.ListFormat,
defaultMachine: String?
) throws {
if format == .json {
let printables = machines.map {
PrintableMachine($0, isDefault: $0.id == defaultMachine)
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(printables)
print(String(decoding: data, as: UTF8.self))
return
}
var rows: [[String]] = [["NAME", "CREATED", "IP", "CPUS", "MEMORY", "DISK", "STATE", "DEFAULT"]]
for machine in machines {
rows.append([
machine.id,
machine.createdDate.map { formatDate($0) } ?? "-",
machine.ipAddress ?? "-",
"\(machine.bootConfig.cpus)",
formatMemory(machine.bootConfig.memory.toUInt64(unit: .bytes)),
machine.diskSize.map { formatMemory($0) } ?? "-",
machine.status.rawValue,
machine.id == defaultMachine ? "*" : "",
])
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
}
}
private func formatMemory(_ bytes: UInt64) -> String {
let gib: UInt64 = 1024 * 1024 * 1024
if bytes >= gib {
if bytes % gib == 0 {
return "\(bytes / gib)G"
}
let formatted = String(format: "%.1fG", Double(bytes) / Double(gib))
if formatted.hasSuffix(".0G") {
return "\(bytes / gib)G"
}
return formatted
}
return "\(bytes / (1024 * 1024))M"
}
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
private func formatDate(_ date: Date) -> String {
dateFormatter.string(from: date)
}
private struct PrintableMachine: Codable {
let id: String
let status: RuntimeStatus
let `default`: Bool
let ipAddress: String?
let cpus: Int
let memory: UInt64
let diskSize: UInt64?
let createdDate: Date?
init(_ machine: MachineSnapshot, isDefault: Bool) {
self.id = machine.id
self.status = machine.status
self.default = isDefault
self.ipAddress = machine.ipAddress
self.cpus = machine.bootConfig.cpus
self.memory = machine.bootConfig.memory.toUInt64(unit: .bytes)
self.diskSize = machine.diskSize
self.createdDate = machine.createdDate
}
}
@@ -0,0 +1,152 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import MachineAPIClient
extension Application {
public struct MachineLogs: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "logs",
abstract: "Fetch container machine logs"
)
@Flag(name: .long, help: "Display the boot log for the container machine 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: "Machine VM ID (uses default if not specified)")
var id: String?
public func run() async throws {
let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
Task {
for await _ in sigHandler.signals {
Darwin.exit(0)
}
}
let client = MachineClient()
let id = try await resolveMachineId(id, client: client)
let fhs = try await client.logs(id: id)
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,164 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation
import MachineAPIClient
import SystemPackage
extension Application {
public struct MachineRun: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run a command or interactive shell in a container machine, booting the container machine if necessary"
)
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)")
var name: String?
@Flag(name: .shortAndLong, help: "Run a process in a container machine and detach from it")
public var detach = false
@Flag(name: .long, help: "Run as root instead of matching host user")
var root: Bool = false
@Argument(help: "Command to run (default: login shell)")
var executable: String?
@Argument(parsing: .captureForPassthrough, help: "Command arguments")
var arguments: [String] = []
public func run() async throws {
let client = MachineClient()
let containerClient = ContainerClient()
let snapshot = try await bootMachine(id: name, client: client, log: log, interactive: true)
guard let containerId = snapshot.containerId else {
throw ContainerizationError(
.invalidState,
message: "container machine is running but has no container ID"
)
}
// Default runs `/sbin.machine/init -s` to find the shell for user
let executablePath = FilePath("/\(MachineBundle.sbinDirectory)").appending(MachineBundle.initFile).string
let args: [String]
let tty: Bool
let interactive: Bool
if let executable {
args = ["-s", executable] + arguments
tty = processFlags.tty
interactive = processFlags.interactive
} else {
args = ["-s"]
tty = true
interactive = true
}
// If not root user, get default user from machine configuration
let defaultUser: ProcessConfiguration.User = {
if root || getuid() == 0 {
return .id(uid: 0, gid: 0)
}
return snapshot.configuration.user
}()
let (user, additionalGroups) = Parser.user(
user: processFlags.user, uid: processFlags.uid,
gid: processFlags.gid, defaultUser: defaultUser)
let cwd = getWorkingDirectory(snapshot, user: user)
// Build environment with HOME set correctly
let envVars = try Parser.allEnv(
imageEnvs: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
envFiles: processFlags.envFile,
envs: processFlags.env
)
let processConfig = ProcessConfiguration(
executable: executablePath,
arguments: args,
environment: envVars,
workingDirectory: cwd,
terminal: tty,
user: user,
supplementalGroups: additionalGroups
)
let io = try ProcessIO.create(tty: tty, interactive: interactive, detach: detach)
defer {
try? io.close()
}
let process = try await containerClient.createProcess(
containerId: containerId,
processId: UUID().uuidString.lowercased(),
configuration: processConfig,
stdio: io.stdio
)
if !tty {
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
handler.start {
print("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
Darwin.exit(1)
}
}
if detach {
try await process.start()
try io.closeAfterStart()
print(snapshot.id)
return
}
let exitCode = try await io.handleProcess(process: process, log: log)
throw ArgumentParser.ExitCode(exitCode)
}
func getWorkingDirectory(_ snapshot: MachineSnapshot, user: ProcessConfiguration.User) -> String {
if let cwd = processFlags.cwd {
return cwd
}
let fallback = user == snapshot.configuration.user ? snapshot.configuration.home : "/"
if snapshot.bootConfig.homeMount == .none {
return fallback
}
let home = FilePath(FileManager.default.homeDirectoryForCurrentUser.path)
let cwd = FilePath(FileManager.default.currentDirectoryPath)
guard cwd.starts(with: home) else {
return fallback
}
return cwd.string
}
}
}
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import ContainerizationError
import Foundation
import MachineAPIClient
extension Application {
public struct MachineSet: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set container machine configuration values"
)
@OptionGroup
public var logOptions: Flags.Logging
@Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)")
public var name: String?
@Argument(
parsing: .remaining,
help: ArgumentHelp(
"Configuration values",
discussion: MachineConfig.helpText(),
valueName: "setting"
)
)
public var rawArgs: [String] = []
public func run() async throws {
guard !rawArgs.isEmpty else {
throw ContainerizationError(.invalidArgument, message: "expected at least one configuration value (e.g., machine set cpus=4)")
}
let client = MachineClient()
let resolvedName = try await resolveMachineId(name, client: client)
let snapshot = try await client.inspect(id: resolvedName)
let kwargs = Dictionary(
try rawArgs.map { arg in
let parts = arg.split(separator: "=", maxSplits: 1)
guard parts.count == 2 else {
throw ContainerizationError(.invalidArgument, message: "invalid argument format '\(arg)'. Expected 'key=value'")
}
return (String(parts[0]), String(parts[1]))
},
uniquingKeysWith: { _, last in last }
)
if kwargs["virtualization"] == "true" {
try MachineCapabilities.requireNestedVirtualizationSupported()
}
var validatedKwargs = kwargs
if let raw = kwargs["kernel"], !raw.isEmpty {
validatedKwargs["kernel"] = try MachineConfig.validateKernelPath(raw).string
}
let newConfig = try snapshot.bootConfig.with(validatedKwargs)
try await client.setConfig(id: resolvedName, bootConfig: newConfig)
if snapshot.status == .running {
FileHandle.standardError.write(
Data("Note: Changes will take effect after stopping and restarting '\(resolvedName)'.\n".utf8))
}
print(resolvedName)
}
}
}
@@ -0,0 +1,41 @@
//===----------------------------------------------------------------------===//
// 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 MachineAPIClient
extension Application {
public struct MachineSetDefault: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "set-default",
abstract: "Set the default container machine")
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container machine ID")
public var id: String
public func run() async throws {
let client = MachineClient()
try await client.setDefault(id: id)
print(id)
}
}
}
@@ -0,0 +1,61 @@
//===----------------------------------------------------------------------===//
// 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 MachineAPIClient
import TerminalProgress
extension Application {
public struct MachineStop: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "stop",
abstract: "Stop a running container machine"
)
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup(visibility: .hidden)
var progressFlags: Flags.Progress
@Argument(help: "container machine ID (uses default if not specified)")
var id: String?
public func run() async throws {
let client = MachineClient()
let machineId = try await resolveMachineId(id, client: client)
let progressConfig = try self.progressFlags.makeConfig(
description: "Stopping container machine"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try await client.stop(id: machineId)
progress.finish()
print(machineId)
}
}
}
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct NetworkCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "network",
abstract: "Manage container networks",
subcommands: [
NetworkCreate.self,
NetworkDelete.self,
NetworkList.self,
NetworkInspect.self,
NetworkPrune.self,
],
aliases: ["n"]
)
public init() {}
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,83 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import TerminalProgress
extension Application {
public struct NetworkCreate: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new network")
@Flag(name: .customLong("internal"), help: "Restrict to host-only network")
var hostOnly: Bool = false
@Option(name: .customLong("label"), help: "Set metadata for a network")
var labels: [String] = []
@Option(name: .customLong("option"), help: "Set a plugin-specific option (key=value)")
var options: [String] = []
@Option(name: .long, help: "Set the plugin to use to create this network.")
var plugin: String = "container-network-vmnet"
@Option(
name: .customLong("subnet"), help: "Set subnet for a network",
transform: {
try CIDRv4($0)
})
var ipv4Subnet: CIDRv4? = nil
@Option(
name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network",
transform: {
try CIDRv6($0)
})
var ipv6Subnet: CIDRv6? = nil
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Network name")
var name: String
public init() {}
public func run() async throws {
let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels))
let parsedOptions = Utility.parseKeyValuePairs(options)
let mode: NetworkMode = hostOnly ? .hostOnly : .nat
let config = try NetworkConfiguration(
name: self.name,
mode: mode,
ipv4Subnet: ipv4Subnet,
ipv6Subnet: ipv6Subnet,
labels: parsedLabels,
plugin: self.plugin,
options: parsedOptions
)
let networkClient = NetworkClient()
let network = try await networkClient.create(configuration: config)
print(network.id)
}
}
}
@@ -0,0 +1,128 @@
//===----------------------------------------------------------------------===//
// 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 NetworkDelete: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more networks",
aliases: ["rm"])
@Flag(name: .shortAndLong, help: "Delete all networks")
var all = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Network names")
var networkNames: [String] = []
public init() {}
public func validate() throws {
if networkNames.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied")
}
if networkNames.count > 0 && all {
throw ContainerizationError(
.invalidArgument,
message: "explicitly supplied network name(s) conflict with the --all flag"
)
}
}
public mutating func run() async throws {
let networkClient = NetworkClient()
let uniqueNetworkNames = Set<String>(networkNames)
let networks: [NetworkResource]
if all {
networks = try await networkClient.list()
.filter { !$0.isBuiltin }
} else {
networks = try await networkClient.list()
.filter { c in
guard uniqueNetworkNames.contains(c.id) else {
return false
}
guard !c.isBuiltin else {
throw ContainerizationError(
.invalidArgument,
message: "cannot delete a builtin network: \(c.id)"
)
}
return true
}
// If one of the networks requested isn't present lets throw. We don't need to do
// this for --all as --all should be perfectly usable with no networks to remove,
// otherwise it'd be quite clunky.
if networks.count != uniqueNetworkNames.count {
let missing = uniqueNetworkNames.filter { id in
!networks.contains { n in
n.id == id
}
}
throw ContainerizationError(
.notFound,
message: "failed to delete one or more networks: \(missing)"
)
}
}
var failed = [String]()
let _log = log
try await withThrowingTaskGroup(of: NetworkResource?.self) { group in
for network in networks {
group.addTask {
do {
// Delete atomically disables the IP allocator, then deletes
// the allocator. The disable fails if any IPs are still in use.
try await networkClient.delete(id: network.id)
print(network.id)
return nil
} catch {
_log.error(
"failed to delete network",
metadata: [
"id": "\(network.id)",
"error": "\(error)",
])
return network
}
}
}
for try await network in group {
guard let network else {
continue
}
failed.append(network.id)
}
}
if failed.count > 0 {
throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)")
}
}
}
}
@@ -0,0 +1,53 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct NetworkInspect: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more networks")
@Argument(help: "Networks to inspect")
var networks: [String]
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let networkClient = NetworkClient()
let uniqueNames = Set(networks)
let items = try await networkClient.list().filter { uniqueNames.contains($0.id) }
if items.count != uniqueNames.count {
let found = Set(items.map { $0.id })
let missing = uniqueNames.subtracting(found).sorted()
throw ContainerizationError(
.notFound,
message: "network not found: \(missing.joined(separator: ", "))"
)
}
try Output.emit(Output.renderJSON(items, options: .pretty))
}
}
}
@@ -0,0 +1,45 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct NetworkList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List networks",
aliases: ["ls"])
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the network name")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let networkClient = NetworkClient()
let networks = try await networkClient.list()
try Output.render(payload: networks, display: networks, format: format, quiet: quiet)
}
}
}
@@ -0,0 +1,73 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application.NetworkCommand {
public struct NetworkPrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove networks with no container connections"
)
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let networkClient = NetworkClient()
let client = ContainerClient()
let allContainers = try await client.list()
let allNetworks = try await networkClient.list()
var networksInUse = Set<String>()
for container in allContainers {
for network in container.configuration.networks {
networksInUse.insert(network.network)
}
}
let networksToPrune = allNetworks.filter { network in
!network.isBuiltin && !networksInUse.contains(network.id)
}
var prunedNetworks = [String]()
for network in networksToPrune {
do {
try await networkClient.delete(id: network.id)
prunedNetworks.append(network.id)
} catch {
// Note: This failure may occur due to a race condition between the network/
// container collection above and a container run command that attaches to a
// network listed in the networksToPrune collection.
log.error(
"failed to prune network",
metadata: [
"id": "\(network.id)",
"error": "\(error)",
])
}
}
for name in prunedNetworks {
print(name)
}
}
}
}
@@ -0,0 +1,31 @@
//===----------------------------------------------------------------------===//
// 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
extension NetworkResource: ListDisplayable {
public static var tableHeader: [String] {
["NETWORK", "SUBNET"]
}
public var tableRow: [String] {
[id, status.ipv4Subnet.description]
}
public var quietValue: String {
id
}
}
@@ -0,0 +1,126 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import TOML
import Yams
/// Options for JSON rendering, wrapping the knobs on `JSONEncoder`.
public struct JSONOptions: Sendable {
public var outputFormatting: JSONEncoder.OutputFormatting = []
public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
public static let compact = JSONOptions(outputFormatting: [.sortedKeys], dateEncodingStrategy: .iso8601)
public static let pretty = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys], dateEncodingStrategy: .iso8601)
public init(
outputFormatting: JSONEncoder.OutputFormatting = [],
dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
) {
self.outputFormatting = outputFormatting
self.dateEncodingStrategy = dateEncodingStrategy
}
}
/// Shared rendering helpers for CLI output.
///
/// All list commands route their output through these methods. The machine-readable
/// payload is encoded separately from table/quiet output, since the payload model
/// often differs from the display model.
public enum Output {
/// Renders an `Encodable` value as a JSON string.
public static func renderJSON<T: Encodable>(_ value: T, options: JSONOptions = .compact) throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = options.outputFormatting
encoder.dateEncodingStrategy = options.dateEncodingStrategy
let data = try encoder.encode(value)
return String(decoding: data, as: UTF8.self)
}
public static func renderYAML<T: Encodable>(_ value: T) throws -> String {
let encoder = YAMLEncoder()
let data = try encoder.encode(value)
return data
}
/// Renders an `Encodable` value as a TOML string.
///
/// TOML has no top-level array, so array payloads are wrapped under a stable
/// `items` key (JSON and YAML emit a top-level array instead); a non-array
/// value encodes as a normal top-level table.
public static func renderTOML<T: Encodable>(_ value: T) throws -> String {
let encoder = TOMLEncoder()
encoder.outputFormatting = .sortedKeys
if value is [Any] {
return try encoder.encodeToString(["items": value])
}
return try encoder.encodeToString(value)
}
/// Renders a list of displayable items as a table (with header) or quiet-mode identifiers.
public static func renderList<T: ListDisplayable>(_ items: [T], quiet: Bool) -> String {
if quiet {
return items.map(\.quietValue).joined(separator: "\n")
}
return renderTable(items)
}
/// Renders a list of displayable items as a column-aligned table with a header row.
public static func renderTable<T: ListDisplayable>(_ items: [T]) -> String {
var rows: [[String]] = [T.tableHeader]
for item in items {
rows.append(item.tableRow)
}
return TableOutput(rows: rows).format()
}
/// Renders `payload` in the requested format, encoding it for the
/// machine-readable formats and delegating `.table` to the caller.
///
/// This is the single place where `ListFormat` is matched exhaustively, so
/// adopting commands handle every format by construction: a new `ListFormat`
/// case becomes a compile error here until it is given an encoder.
public static func render<J: Encodable>(
payload: J, format: ListFormat, jsonOptions: JSONOptions = .compact, table: () throws -> String
) throws {
switch format {
case .json: try emit(renderJSON(payload, options: jsonOptions))
case .yaml: try emit(renderYAML(payload))
case .toml: try emit(renderTOML(payload))
case .table: try emit(table())
}
}
/// Renders list output in the requested format.
///
/// The machine-readable payload and the display model may be the same type
/// (e.g., `ManagedContainer`) or different types.
public static func render<J: Encodable, D: ListDisplayable>(
payload: J, display: [D], format: ListFormat, quiet: Bool, jsonOptions: JSONOptions = .compact
) throws {
try render(payload: payload, format: format, jsonOptions: jsonOptions) {
renderList(display, quiet: quiet)
}
}
/// Writes rendered output to stdout. No-ops on empty strings to avoid blank lines
/// (e.g., `container list -q` with zero results should produce no output, not a newline).
public static func emit(_ output: String) {
if !output.isEmpty {
print(output)
}
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct RegistryCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "registry",
abstract: "Manage registry logins",
subcommands: [
RegistryLogin.self,
RegistryLogout.self,
RegistryList.self,
],
aliases: ["r"]
)
public init() {}
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOCI
import ContainerizationOS
import Foundation
extension Application {
public struct RegistryList: AsyncLoggableCommand {
@OptionGroup
public var logOptions: Flags.Logging
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the registry hostname")
var quiet = false
public init() {}
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List image registry logins",
aliases: ["ls"])
public func run() async throws {
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
let registryInfos = try keychain.list()
let registries = registryInfos.map { RegistryResource(from: $0) }
try Output.render(
payload: registries,
display: registries.map { PrintableRegistry($0) },
format: format, quiet: quiet
)
}
}
}
private struct PrintableRegistry: ListDisplayable {
let registry: RegistryResource
init(_ registry: RegistryResource) {
self.registry = registry
}
static var tableHeader: [String] {
["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]
}
var tableRow: [String] {
[
registry.name,
registry.username,
registry.modificationDate.ISO8601Format(),
registry.creationDate.ISO8601Format(),
]
}
var quietValue: String {
registry.name
}
}
@@ -0,0 +1,100 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation
extension Application {
public struct RegistryLogin: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "login",
abstract: "Log in to a registry"
)
@OptionGroup
var registry: Flags.Registry
@Flag(help: "Take the password from stdin")
var passwordStdin: Bool = false
@Option(name: .shortAndLong, help: "Registry user name")
var username: String = ""
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Registry server name")
var server: String
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
var username = self.username
var password = ""
if passwordStdin {
if username == "" {
throw ContainerizationError(
.invalidArgument, message: "must provide --username with --password-stdin")
}
guard let passwordData = try FileHandle.standardInput.readToEnd() else {
throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin")
}
password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
if username == "" {
username = try keychain.userPrompt(hostname: server)
}
if password == "" {
password = try keychain.passwordPrompt()
print()
}
let server = Reference.resolveDomain(domain: server)
let scheme = try RequestScheme(registry.scheme).schemeFor(host: server, internalDnsDomain: containerSystemConfig.dns.domain)
let _url = "\(scheme)://\(server)"
guard let url = URL(string: _url) else {
throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL")
}
guard let host = url.host else {
throw ContainerizationError(.invalidArgument, message: "invalid host \(server)")
}
let client = RegistryClient(
host: host,
scheme: scheme.rawValue,
port: url.port,
authentication: BasicAuthentication(username: username, password: password),
retryOptions: .init(
maxRetries: 10,
retryInterval: 300_000_000,
shouldRetry: ({ response in
response.status.code >= 500
})
)
)
try await client.ping()
try keychain.save(hostname: server, username: username, password: password)
log.info("Login succeeded")
}
}
}
@@ -0,0 +1,42 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationOCI
extension Application {
public struct RegistryLogout: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "logout",
abstract: "Log out from a registry"
)
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Registry server name")
var registry: String
public func run() async throws {
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
let r = Reference.resolveDomain(domain: registry)
try keychain.delete(hostname: r)
}
}
}
@@ -0,0 +1,94 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import ContainerizationExtras
import DNSServer
import Foundation
extension Application {
public struct DNSCreate: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a local DNS domain for containers (must run as an administrator)"
)
@OptionGroup
public var logOptions: Flags.Logging
@Option(name: .long, help: "Set the ip address to be redirected to localhost")
var localhost: String?
@Argument(help: "The local domain name")
var domainName: String
public init() {}
public func run() async throws {
var localhostIP: IPAddress? = nil
if let localhost {
localhostIP = try? IPAddress(localhost)
guard let localhostIP, case .v4(_) = localhostIP else {
throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)")
}
}
guard let domainName = try? DNSName(domainName) else {
throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)")
}
let resolver: HostDNSResolver = HostDNSResolver()
do {
try resolver.createDomain(name: domainName, localhost: localhostIP)
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)")
}
let pf = PacketFilter()
if let from = localhostIP {
let to = try! IPAddress("127.0.0.1")
do {
try pf.createRedirectRule(from: from, to: to, domain: domainName)
} catch {
_ = try resolver.deleteDomain(name: domainName)
throw error
}
}
print(domainName.pqdn)
if localhostIP != nil {
do {
try pf.reinitialize()
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
}
}
do {
try HostDNSResolver.reinitialize()
} catch {
throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain")
}
}
}
}
@@ -0,0 +1,77 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationExtras
import DNSServer
import Foundation
extension Application {
public struct DNSDelete: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a local DNS domain (must run as an administrator)",
aliases: ["rm"]
)
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "The local domain name")
var domainName: String
public init() {}
public func run() async throws {
guard let domainName = try? DNSName(domainName) else {
throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)")
}
let resolver = HostDNSResolver()
var localhostIP: IPAddress?
do {
localhostIP = try resolver.deleteDomain(name: domainName)
} catch {
throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)")
}
do {
try HostDNSResolver.reinitialize()
} catch {
throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain")
}
guard let localhostIP else {
print(domainName.pqdn)
return
}
let pf = PacketFilter()
try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName)
do {
try pf.reinitialize()
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
}
print(domainName.pqdn)
}
}
}
@@ -0,0 +1,72 @@
//===----------------------------------------------------------------------===//
// 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 DNSServer
import Foundation
extension Application {
public struct DNSList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List local DNS domains",
aliases: ["ls"]
)
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the domain")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let resolver = HostDNSResolver()
let domains = resolver.listDomains()
try Output.render(
payload: domains.map { $0.pqdn },
display: domains.map { PrintableDomain($0) },
format: format, quiet: quiet
)
}
}
}
private struct PrintableDomain: ListDisplayable {
let domain: DNSName
init(_ domain: DNSName) {
self.domain = domain
}
static var tableHeader: [String] {
["DOMAIN"]
}
var tableRow: [String] {
[domain.pqdn]
}
var quietValue: String {
domain.pqdn
}
}
@@ -0,0 +1,126 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import TerminalProgress
extension Application {
public struct KernelSet: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "set",
abstract: "Set the default kernel"
)
@Option(name: .long, help: "The architecture of the kernel binary (values: amd64, arm64)")
var arch: String = ContainerizationOCI.Platform.current.architecture.description
@Option(name: .customLong("binary"), help: "Path to the kernel file (or archive member, if used with --tar)")
var binaryPath: String? = nil
@Flag(name: .long, help: "Overwrites an existing kernel with the same name")
var force: Bool = false
@Flag(name: .long, help: "Download and install the recommended kernel as the default (takes precedence over all other flags)")
var recommended: Bool = false
@Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file")
var tarPath: String? = nil
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
if recommended {
let url = containerSystemConfig.kernel.url
let path: String = containerSystemConfig.kernel.binaryPath
log.info("Installing the recommended kernel from \(url)...")
try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force)
return
}
guard tarPath != nil else {
return try await self.setKernelFromBinary()
}
try await self.setKernelFromTar()
}
private func setKernelFromBinary() async throws {
guard let binaryPath else {
throw ArgumentParser.ValidationError("missing argument '--binary'")
}
let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString
let platform = try getSystemPlatform()
try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform, force: force)
}
private func setKernelFromTar() async throws {
guard let binaryPath else {
throw ArgumentParser.ValidationError("missing argument '--binary'")
}
guard let tarPath else {
throw ArgumentParser.ValidationError("missing argument '--tar")
}
let platform = try getSystemPlatform()
let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path
let fm = FileManager.default
if fm.fileExists(atPath: localTarPath) {
try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force)
return
}
guard let remoteURL = URL(string: tarPath) else {
throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?")
}
try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force)
}
private func getSystemPlatform() throws -> SystemPlatform {
switch arch {
case "arm64":
return .linuxArm
case "amd64":
return .linuxAmd
default:
throw ContainerizationError(.unsupported, message: "unsupported architecture \(arch)")
}
}
static func downloadAndInstallWithProgressBar(tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws {
let progressConfig = try ProgressConfig(
showTasks: true,
totalTasks: 2
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try await ClientKernel.installKernelFromTar(
tarFile: tarRemoteURL.absoluteString, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force)
progress.finish()
}
}
}
@@ -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 ContainerPersistence
import ContainerPlugin
import Foundation
enum ListOutputFormat: String, Decodable, ExpressibleByArgument {
case json
case toml
}
extension Application {
public struct PropertyList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List system properties",
aliases: ["ls"]
)
@Option(name: .long, help: "Format of the output")
var format: ListOutputFormat = .toml
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let output =
switch format {
case .json: try Output.renderJSON(containerSystemConfig)
case .toml: try Output.renderTOML(containerSystemConfig)
}
Output.emit(output)
}
}
}
@@ -0,0 +1,43 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct SystemCommand: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "system",
abstract: "Manage system components",
subcommands: [
SystemDF.self,
SystemDNS.self,
SystemKernel.self,
SystemLogs.self,
SystemProperty.self,
SystemStart.self,
SystemStatus.self,
SystemStop.self,
SystemVersion.self,
],
aliases: ["s"]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,101 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct SystemDF: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "df",
abstract: "Show disk usage for images, containers, and volumes"
)
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let stats = try await ClientDiskUsage.get()
try Output.render(payload: stats, format: format, jsonOptions: .pretty) {
diskUsageTable(stats: stats)
}
}
private func diskUsageTable(stats: DiskUsageStats) -> String {
var rows: [[String]] = []
// Header row
rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"])
// Images row
rows.append([
"Images",
"\(stats.images.total)",
"\(stats.images.active)",
formatSize(stats.images.sizeInBytes),
formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes),
])
// Containers row
rows.append([
"Containers",
"\(stats.containers.total)",
"\(stats.containers.active)",
formatSize(stats.containers.sizeInBytes),
formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes),
])
// Volumes row
rows.append([
"Local Volumes",
"\(stats.volumes.total)",
"\(stats.volumes.active)",
formatSize(stats.volumes.sizeInBytes),
formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes),
])
let tableFormatter = TableOutput(rows: rows)
return tableFormatter.format()
}
private func formatSize(_ bytes: UInt64) -> String {
if bytes == 0 {
return "0 B"
}
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(bytes))
}
private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String {
let sizeStr = formatSize(reclaimable)
if total == 0 {
return "\(sizeStr) (0%)"
}
// Cap at 100% in case reclaimable > total (shouldn't happen but be defensive)
let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0)))
return "\(sizeStr) (\(percentage)%)"
}
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application {
public struct SystemDNS: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "dns",
abstract: "Manage local DNS domains",
subcommands: [
DNSCreate.self,
DNSDelete.self,
DNSList.self,
]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,34 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct SystemKernel: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "kernel",
abstract: "Manage the default kernel configuration",
subcommands: [
KernelSet.self
]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,97 @@
//===----------------------------------------------------------------------===//
// 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 OSLog
extension Application {
public struct SystemLogs: AsyncLoggableCommand {
public static let subsystem = "com.apple.container"
public static let configuration = CommandConfiguration(
commandName: "logs",
abstract: "Fetch system logs for `container` services"
)
@Flag(name: .shortAndLong, help: "Follow log output")
var follow: Bool = false
@Option(
name: .long,
help: "Fetch logs starting from the specified time period (minus the current time); supported formats: <number>[m|h|d] (defaults to seconds)"
)
var last: String = "5m"
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func validate() throws {
if follow { return }
if let value = Int(last), value > 0 { return }
guard let unit = last.last, "mhd".contains(unit),
let value = Int(last.dropLast()), value > 0
else {
throw ContainerizationError(
.invalidArgument,
message: "invalid --last value '\(last)': expected a positive integer (seconds) or a value with suffix m, h, or d (e.g. 30, 5m, 1h, 2d)"
)
}
}
public func run() async throws {
let process = Process()
let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
Task {
for await _ in sigHandler.signals {
process.terminate()
Darwin.exit(0)
}
}
do {
var args = ["log"]
args.append(self.follow ? "stream" : "show")
args.append(contentsOf: ["--info", logOptions.debug ? "--debug" : nil].compactMap { $0 })
if !self.follow {
args.append(contentsOf: ["--last", last])
}
args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"])
process.launchPath = "/usr/bin/env"
process.arguments = args
process.standardOutput = FileHandle.standardOutput
process.standardError = FileHandle.standardError
try process.run()
process.waitUntilExit()
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to system logs: \(error)"
)
}
throw ArgumentParser.ExitCode(process.terminationStatus)
}
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationError
import Foundation
extension Application {
public struct SystemProperty: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "property",
abstract: "Manage system property values",
subcommands: [
PropertyList.self
]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,219 @@
//===----------------------------------------------------------------------===//
// 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 ContainerXPC
import ContainerizationError
import Foundation
import MachineAPIClient
import SystemPackage
import TerminalProgress
extension Application {
public struct SystemStart: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start `container` services"
)
@Option(
name: .shortAndLong,
help: "Path to the root directory for application data",
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var appRoot = ApplicationRoot.defaultPath
@Option(
name: .long,
help: "Path to the root directory for application executables and plugins",
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var installRoot = InstallRoot.defaultPath
@Option(
name: .long,
help: "Path to the root directory for log data, using macOS log facility if not set",
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
var logRoot: FilePath? = nil
@Flag(
name: .long,
inversion: .prefixedEnableDisable,
help: "Specify whether the default kernel should be installed or not (default: prompt user)")
var kernelInstall: Bool?
@Option(
help: "Number of seconds to wait for API service to become responsive",
transform: {
guard let timeoutSeconds = Double($0) else {
throw ValidationError("Invalid timeout value: \($0)")
}
return .seconds(timeoutSeconds)
}
)
var timeout: Duration = XPCClient.xpcRegistrationTimeout
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot)
// Pass appRoot before installRoot: ConfigurationLoader uses first-match-wins
// precedence, so user-provided config in appRoot overrides the defaults
// shipped under installRoot. Both layers are passed explicitly because
// users can override --app-root and --install-root from the CLI, and the
// loader's default search would otherwise ignore those overrides.
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
])
// Without the true path to the binary in the plist, `container-apiserver` won't launch properly.
// Resolve the symlink to get the true binary path before writing the launchd plist.
// Gatekeeper / amfid validates code signatures relative to the enclosing .app bundle
// hierarchy; launching via a symlink outside the bundle fails that check.
// TODO: Can we use the plugin loader to bootstrap the API server?
let executablePath = try CommandLine.executablePath
.removingLastComponent()
.appending(FilePath.Component("container-apiserver"))
.resolvingSymlinks()
var args = [executablePath.string]
args.append("start")
if logOptions.debug {
args.append("--debug")
}
let apiServerDataPath = appRoot.appending(FilePath.Component("apiserver"))
let apiServerDataURL = URL(fileURLWithPath: apiServerDataPath.string)
try FileManager.default.createDirectory(at: apiServerDataURL, withIntermediateDirectories: true)
var env = PluginLoader.filterEnvironment()
env[ApplicationRoot.environmentName] = appRoot.string
env[InstallRoot.environmentName] = installRoot.string
if let logRoot {
env[LogRoot.environmentName] = logRoot.string
}
let plist = LaunchPlist(
label: "com.apple.container.apiserver",
arguments: args,
environment: env,
limitLoadToSessionType: [.Aqua, .Background, .System],
runAtLoad: true,
machServices: ["com.apple.container.apiserver"]
)
let plistPath = apiServerDataPath.appending(FilePath.Component("apiserver.plist"))
let plistURL = URL(fileURLWithPath: plistPath.string)
let data = try plist.encode()
try data.write(to: plistURL)
log.info("Launching container-apiserver...")
try ServiceManager.register(plistPath: plistURL.path)
// Now ping our friendly daemon. Fail if we don't get a response.
do {
log.info("Testing access to container-apiserver...")
_ = try await ClientHealthCheck.ping(timeout: timeout)
} catch {
throw ContainerizationError(
.internalError,
message: "failed to get a response from apiserver: \(error)"
)
}
do {
print("Verifying machine API server is running...")
_ = try await MachineClient().list()
} catch {
throw ContainerizationError(
.internalError,
message: "failed to get a response from machine API server: \(error)"
)
}
if await !initImageExists(containerSystemConfig: containerSystemConfig) {
try? await installInitialFilesystem(initImage: containerSystemConfig.vminit.image)
}
guard await !kernelExists() else {
return
}
try await installDefaultKernel(kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath)
}
private func installInitialFilesystem(initImage: String) async throws {
var pullCommand = try ImagePull.parse()
pullCommand.reference = initImage
log.info("Installing base container filesystem...")
do {
try await pullCommand.run()
} catch {
log.error("failed to install base container filesystem", metadata: ["error": "\(error)"])
}
}
private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String) async throws {
var shouldInstallKernel = false
if kernelInstall == nil {
print("No default kernel configured.")
print("Install the recommended default kernel from [\(kernelURL)]? [Y/n]: ", terminator: "")
guard let read = readLine(strippingNewline: true) else {
throw ContainerizationError(.internalError, message: "failed to read user input")
}
guard read.lowercased() == "y" || read.count == 0 else {
log.info("Please use the `container system kernel set --recommended` command to configure the default kernel")
return
}
shouldInstallKernel = true
} else {
shouldInstallKernel = kernelInstall ?? false
}
guard shouldInstallKernel else {
return
}
log.info("Installing kernel...")
try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, force: true)
}
private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool {
do {
let img = try await ClientImage.get(
reference: containerSystemConfig.vminit.image,
containerSystemConfig: containerSystemConfig
)
let _ = try await img.getSnapshot(platform: .current)
return true
} catch {
return false
}
}
private func kernelExists() async -> Bool {
do {
try await ClientKernel.getDefaultKernel(for: .current)
return true
} catch {
return false
}
}
}
}
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPlugin
import ContainerizationError
import Foundation
import Logging
extension Application {
public struct SystemStatus: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "status",
abstract: "Show the status of `container` services"
)
@Option(name: .shortAndLong, help: "Launchd prefix for services")
var prefix: String = "com.apple.container."
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
struct PrintableStatus: Codable {
let status: String
let appRoot: String
let installRoot: String
let logRoot: String?
let apiServerVersion: String
let apiServerCommit: String
let apiServerBuild: String
let apiServerAppName: String
init(
status: String,
appRoot: String = "",
installRoot: String = "",
logRoot: String? = nil,
apiServerVersion: String = "",
apiServerCommit: String = "",
apiServerBuild: String = "",
apiServerAppName: String = ""
) {
self.status = status
self.appRoot = appRoot
self.installRoot = installRoot
self.logRoot = logRoot
self.apiServerVersion = apiServerVersion
self.apiServerCommit = apiServerCommit
self.apiServerBuild = apiServerBuild
self.apiServerAppName = apiServerAppName
}
}
public func run() async throws {
let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver")
if !isRegistered {
try Output.render(payload: PrintableStatus(status: "unregistered"), format: format) {
"apiserver is not running and not registered with launchd"
}
Application.exit(withError: ExitCode(1))
}
// Now ping our friendly daemon. Fail after 10 seconds with no response.
do {
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let status = PrintableStatus(
status: "running",
appRoot: systemHealth.appRoot.path(percentEncoded: false),
installRoot: systemHealth.installRoot.path(percentEncoded: false),
logRoot: systemHealth.logRoot?.string,
apiServerVersion: systemHealth.apiServerVersion,
apiServerCommit: systemHealth.apiServerCommit,
apiServerBuild: systemHealth.apiServerBuild,
apiServerAppName: systemHealth.apiServerAppName
)
try Output.render(payload: status, format: format) {
Self.statusTable(status)
}
} catch {
try Output.render(payload: PrintableStatus(status: "not running"), format: format) {
"apiserver is not running"
}
Application.exit(withError: ExitCode(1))
}
}
private static func statusTable(_ status: PrintableStatus) -> String {
let rows: [[String]] = [
["FIELD", "VALUE"],
["status", status.status],
["appRoot", status.appRoot],
["installRoot", status.installRoot],
["logRoot", status.logRoot ?? ""],
["apiserver.version", status.apiServerVersion],
["apiserver.commit", status.apiServerCommit],
["apiserver.build", status.apiServerBuild],
["apiserver.appName", status.apiServerAppName],
]
return TableOutput(rows: rows).format()
}
}
}
@@ -0,0 +1,109 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationOS
import Foundation
import Logging
extension Application {
public struct SystemStop: AsyncLoggableCommand {
private static let stopTimeoutSeconds: Int32 = 5
private static let shutdownTimeoutSeconds: Int32 = 20
public static let configuration = CommandConfiguration(
commandName: "stop",
abstract: "Stop all `container` services"
)
@Option(name: .shortAndLong, help: "Launchd prefix for services")
var prefix: String = "com.apple.container."
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let log = Logger(
label: "com.apple.container.cli",
factory: { label in
StreamLogHandler.standardOutput(label: label)
}
)
let launchdDomainString = try ServiceManager.getDomainString()
let fullLabel = "\(launchdDomainString)/\(prefix)apiserver"
var running = true
do {
log.info("checking if APIServer is alive")
_ = try await ClientHealthCheck.ping(timeout: .seconds(5))
} catch {
log.info("APIServer health check failed, skipping bootout")
running = false
}
if running {
let client = ContainerClient()
log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"])
do {
let containers = try await client.list().map { $0.id }
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: nil)
try await ContainerStop.stopContainers(
client: client,
containers: containers,
stopOptions: opts,
)
} catch {
log.warning("failed to stop all containers", metadata: ["error": "\(error)"])
}
log.info("waiting for containers to exit")
do {
for _ in 0..<Self.shutdownTimeoutSeconds {
let runningContainers = try await client.list(filters: ContainerListFilters(status: .running))
guard !runningContainers.isEmpty else {
break
}
try await Task.sleep(for: .seconds(1))
}
log.info("stopping service", metadata: ["label": "\(fullLabel)"])
try ServiceManager.deregister(fullServiceLabel: fullLabel)
} catch {
log.warning("failed to wait for all containers", metadata: ["error": "\(error)"])
}
}
// Note: The assumption here is that we would have registered the launchd services
// in the same domain as `launchdDomainString`. This is a fairly sane assumption since
// if somehow the launchd domain changed, XPC interactions would not be possible.
try ServiceManager.enumerate()
.filter { $0.hasPrefix(prefix) }
.filter { $0 != fullLabel }
.map { "\(launchdDomainString)/\($0)" }
.forEach {
log.info("stopping service", metadata: ["label": "\($0)"])
try? ServiceManager.deregister(fullServiceLabel: $0)
}
}
}
}
@@ -0,0 +1,79 @@
//===----------------------------------------------------------------------===//
// 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 ContainerVersion
import Foundation
extension Application {
public struct SystemVersion: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "version",
abstract: "Show version information"
)
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let cliInfo = VersionInfo(
version: ReleaseVersion.version(),
buildType: ReleaseVersion.buildType(),
commit: ReleaseVersion.gitCommit() ?? "unspecified",
appName: "container"
)
// Try to get API server version info
let serverInfo: VersionInfo?
do {
let health = try await ClientHealthCheck.ping(timeout: .seconds(2))
serverInfo = VersionInfo(
version: health.apiServerVersion,
buildType: health.apiServerBuild,
commit: health.apiServerCommit,
appName: health.apiServerAppName
)
} catch {
serverInfo = nil
}
let versions = [cliInfo, serverInfo].compactMap { $0 }
try Output.render(payload: versions, format: format) {
Self.versionTable(versions)
}
}
private static func versionTable(_ versions: [VersionInfo]) -> String {
let header = ["COMPONENT", "VERSION", "BUILD", "COMMIT"]
let rows = [header] + versions.map { [$0.appName, $0.version, $0.buildType, $0.commit] }
return TableOutput(rows: rows).format()
}
}
public struct VersionInfo: Codable {
let version: String
let buildType: String
let commit: String
let appName: String
}
}
@@ -0,0 +1,60 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
public struct TableOutput: Sendable {
private let rows: [[String]]
private let spacing: Int
public init(
rows: [[String]],
spacing: Int = 2
) {
self.rows = rows
self.spacing = spacing
}
public func format() -> String {
var output = ""
let maxLengths = self.maxLength()
for rowIndex in 0..<self.rows.count {
let row = self.rows[rowIndex]
for columnIndex in 0..<row.count - 1 {
let currentLength = (maxLengths[columnIndex] ?? 0) + self.spacing
let padded = row[columnIndex].padding(toLength: currentLength, withPad: " ", startingAt: 0)
output += padded
}
// Skip padding for the last column.
output += row.last ?? ""
output += (rowIndex == self.rows.count - 1) ? "" : "\n"
}
return output
}
/// Returns a mapping of column index and the maximum length of all elements belonging under that column.
private func maxLength() -> [Int: Int] {
var output: [Int: Int] = [:]
for row in self.rows {
for (i, column) in row.enumerated() {
let currentMax = output[i] ?? 0
output[i] = (column.count > currentMax) ? column.count : currentMax
}
}
return output
}
}
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// 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
extension Application {
public struct VolumeCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "volume",
abstract: "Manage container volumes",
subcommands: [
VolumeCreate.self,
VolumeDelete.self,
VolumeList.self,
VolumeInspect.self,
VolumePrune.self,
],
aliases: ["v"]
)
public init() {}
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,63 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application.VolumeCommand {
public struct VolumeCreate: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new volume"
)
@Option(name: .customLong("label"), help: "Set metadata for a volume")
var labels: [String] = []
@Option(name: .customLong("opt"), help: "Set driver specific options")
var driverOpts: [String] = []
@Option(name: .short, help: "Size of the volume in bytes, with optional K, M, G, T, or P suffix")
var size: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Volume name")
var name: String
public init() {}
public func run() async throws {
var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts)
let parsedLabels = Utility.parseKeyValuePairs(labels)
// If --size is specified, add it to driver options
if let size = size {
parsedDriverOpts["size"] = size
}
let volume = try await ClientVolume.create(
name: name,
driver: "local",
driverOpts: parsedDriverOpts,
labels: parsedLabels
)
print(volume.name)
}
}
}
@@ -0,0 +1,116 @@
//===----------------------------------------------------------------------===//
// 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.VolumeCommand {
public struct VolumeDelete: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more volumes",
aliases: ["rm"]
)
@Flag(name: .shortAndLong, help: "Delete all volumes")
var all = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Volume names")
var names: [String] = []
public init() {}
public func validate() throws {
if names.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no volumes specified and --all not supplied")
}
if names.count > 0 && all {
throw ContainerizationError(
.invalidArgument,
message: "explicitly supplied volume name(s) conflict with the --all flag"
)
}
}
public func run() async throws {
let uniqueVolumeNames = Set<String>(names)
let volumes: [VolumeConfiguration]
if all {
volumes = try await ClientVolume.list()
} else {
volumes = try await ClientVolume.list()
.filter { v in
uniqueVolumeNames.contains(v.id)
}
// If one of the volumes requested isn't present lets throw. We don't need to do
// this for --all as --all should be perfectly usable with no volumes to remove,
// otherwise it'd be quite clunky.
if volumes.count != uniqueVolumeNames.count {
let missing = uniqueVolumeNames.filter { id in
!volumes.contains { v in
v.id == id
}
}
throw ContainerizationError(
.notFound,
message: "failed to delete one or more volumes: \(missing)"
)
}
}
var failed = [String]()
let _log = log
try await withThrowingTaskGroup(of: VolumeConfiguration?.self) { group in
for volume in volumes {
group.addTask {
do {
try await ClientVolume.delete(name: volume.id)
print(volume.id)
return nil
} catch {
_log.error(
"failed to delete volume",
metadata: [
"id": "\(volume.id)",
"error": "\(error)",
])
return volume
}
}
}
for try await volume in group {
guard let volume else {
continue
}
failed.append(volume.id)
}
}
if failed.count > 0 {
throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)")
}
}
}
}
@@ -0,0 +1,55 @@
//===----------------------------------------------------------------------===//
// 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.VolumeCommand {
public struct VolumeInspect: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more volumes"
)
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Volumes to inspect")
var names: [String]
public init() {}
public func run() async throws {
let uniqueNames = Set(names)
let volumes = try await ClientVolume.list().filter { uniqueNames.contains($0.id) }
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
if volumes.count != uniqueNames.count {
let found = Set(volumes.map { $0.id })
let missing = uniqueNames.subtracting(found).sorted()
throw ContainerizationError(
.notFound,
message: "volume not found: \(missing.joined(separator: ", "))"
)
}
try Output.emit(Output.renderJSON(volumeResources, options: .pretty))
}
}
}
@@ -0,0 +1,48 @@
//===----------------------------------------------------------------------===//
// 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
extension Application.VolumeCommand {
public struct VolumeList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List volumes",
aliases: ["ls"]
)
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the volume name")
var quiet: Bool = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let volumes = try await ClientVolume.list()
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
try Output.render(payload: volumeResources, display: volumeResources, format: format, quiet: quiet)
}
}
}
@@ -0,0 +1,78 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
extension Application.VolumeCommand {
public struct VolumePrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove volumes with no container references")
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let allVolumes = try await ClientVolume.list()
// Find all volumes not used by any container
let client = ContainerClient()
let containers = try await client.list()
var volumesInUse = Set<String>()
for container in containers {
for mount in container.configuration.mounts {
if mount.isVolume, let volumeName = mount.volumeName {
volumesInUse.insert(volumeName)
}
}
}
let volumesToPrune = allVolumes.filter { volume in
!volumesInUse.contains(volume.name)
}
var prunedVolumes = [String]()
var totalSize: UInt64 = 0
for volume in volumesToPrune {
do {
let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name)
totalSize += actualSize
try await ClientVolume.delete(name: volume.name)
prunedVolumes.append(volume.name)
} catch {
log.error(
"failed to prune volume",
metadata: [
"id": "\(volume.name)",
"error": "\(error)",
])
}
}
for name in prunedVolumes {
print(name)
}
let formatter = ByteCountFormatter()
let freed = formatter.string(fromByteCount: Int64(totalSize))
log.info("Reclaimed \(freed) in disk space")
}
}
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// 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
extension VolumeResource: ListDisplayable {
public static var tableHeader: [String] {
["NAME", "TYPE", "DRIVER", "OPTIONS"]
}
public var tableRow: [String] {
[
name,
isAnonymous ? "anonymous" : "named",
configuration.driver,
configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
]
}
public var quietValue: String {
name
}
}