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
+398
View File
@@ -0,0 +1,398 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIService
import ContainerLog
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import ContainerXPC
import ContainerizationExtras
import DNSServer
import Foundation
import Logging
import SystemPackage
extension APIServer {
struct Start: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start helper for the API server"
)
static let listenAddress = "127.0.0.1"
static let localhostDNSPort = 1053
static let dnsPort = 2053
@Flag(name: .long, help: "Enable debug logging")
var debug = false
var appRoot = ApplicationRoot.path
var installRoot = InstallRoot.path
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let commandName = APIServer._commandName
let logPath = logRoot.map { $0.appending(FilePath.Component("\(commandName).log") ?? "unknown") }
let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath)
log.info("starting helper", metadata: ["name": "\(commandName)"])
defer {
log.info("stopping helper", metadata: ["name": "\(commandName)"])
}
do {
log.info("configuring XPC server")
var routes = [XPCRoute: XPCServer.RouteHandler]()
let pluginLoader = try initializePluginLoader(log: log)
try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes, debug: debug)
let containersService = try initializeContainersService(
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
routes: &routes
)
let networkService = try await initializeNetworksService(
pluginLoader: pluginLoader,
containersService: containersService,
containerSystemConfig: containerSystemConfig,
log: log,
routes: &routes
)
await containersService.setNetworksService(networkService)
initializeHealthCheckService(log: log, routes: &routes)
try initializeKernelService(log: log, routes: &routes)
let volumesService = try await initializeVolumeService(containersService: containersService, log: log, routes: &routes)
try initializeDiskUsageService(
containersService: containersService,
volumesService: volumesService,
log: log,
routes: &routes
)
let server = XPCServer(
identifier: "com.apple.container.apiserver",
routes: routes.reduce(
into: [String: XPCServer.RouteHandler](),
{
$0[$1.key.rawValue] = $1.value
}), log: log)
await withTaskGroup(of: Result<Void, Error>.self) { group in
group.addTask {
log.info("starting XPC server")
do {
try await server.listen()
return .success(())
} catch {
return .failure(error)
}
}
// start up host table DNS
group.addTask {
let hostsResolver = ContainerDNSHandler(networkService: networkService)
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for container hostnames",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.dnsPort)",
]
)
do {
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
return .success(())
} catch {
return .failure(error)
}
}
// start up realhost DNS
group.addTask {
do {
let localhostResolver = LocalhostDNSHandler(log: log)
await localhostResolver.monitorResolvers()
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for localhost",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.localhostDNSPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
return .success(())
} catch {
return .failure(error)
}
}
for await result in group {
switch result {
case .success():
continue
case .failure(let error):
log.error("API server task failed: \(error)")
}
}
}
} catch {
log.error(
"helper failed",
metadata: [
"name": "\(commandName)",
"error": "\(error)",
])
APIServer.exit(withError: error)
}
}
private func initializePluginLoader(log: Logger) throws -> PluginLoader {
log.info(
"initializing plugin loader",
metadata: [
"installRoot": "\(installRoot.string)"
])
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
log.info("detecting user plugins directory", metadata: ["path": "\(pluginsURL.path(percentEncoded: false))"])
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 Unix-like application
let installRootPluginsPath =
installRoot
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
installRootPluginsURL,
].compactMap { $0 }
let pluginFactories: [PluginFactory] = [
DefaultPluginFactory(logger: log),
AppBundlePluginFactory(logger: log),
]
for pluginDirectory in pluginDirectories {
log.info("discovered plugin directory", metadata: ["path": "\(pluginDirectory.path(percentEncoded: false))"])
}
let appRootURL = URL(fileURLWithPath: appRoot.string)
return try PluginLoader(
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
pluginDirectories: pluginDirectories,
pluginFactories: pluginFactories,
log: log
)
}
// First load all of the plugins we can find. Then just expose
// the handlers for clients to do whatever they want.
private func initializePlugins(
pluginLoader: PluginLoader,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler],
debug: Bool = false
) async throws {
log.info("initializing plugins")
let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot }
let service = PluginsService(pluginLoader: pluginLoader, log: log)
try await service.loadAll(bootPlugins, debug: debug)
let harness = PluginsHarness(service: service, log: log)
routes[XPCRoute.pluginGet] = XPCServer.route(harness.get)
routes[XPCRoute.pluginList] = XPCServer.route(harness.list)
routes[XPCRoute.pluginLoad] = XPCServer.route(harness.load)
routes[XPCRoute.pluginUnload] = XPCServer.route(harness.unload)
routes[XPCRoute.pluginRestart] = XPCServer.route(harness.restart)
}
private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) {
log.info("initializing health check service")
// TODO: Remove when we convert HealthCheckHarness to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = HealthCheckHarness(
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
log: log
)
routes[XPCRoute.ping] = XPCServer.route(svc.ping)
}
private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws {
log.info("initializing kernel service")
// TODO: Remove when we convert KernelService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = try KernelService(log: log, appRoot: appRootURL)
let harness = KernelHarness(service: svc, log: log)
routes[XPCRoute.installKernel] = XPCServer.route(harness.install)
routes[XPCRoute.getDefaultKernel] = XPCServer.route(harness.getDefaultKernel)
}
private func initializeContainersService(
pluginLoader: PluginLoader,
containerSystemConfig: ContainerSystemConfig,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) throws -> ContainersService {
log.info("initializing containers service")
// TODO: Remove when we convert ContainersService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let service = try ContainersService(
appRoot: appRootURL,
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
debugHelpers: debug
)
let harness = ContainersHarness(service: service, log: log)
routes[XPCRoute.containerList] = XPCServer.route(harness.list)
routes[XPCRoute.containerCreate] = XPCServer.route(harness.create)
routes[XPCRoute.containerDelete] = XPCServer.route(harness.delete)
routes[XPCRoute.containerLogs] = XPCServer.route(harness.logs)
routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap)
routes[XPCRoute.containerDial] = XPCServer.route(harness.dial)
routes[XPCRoute.containerStop] = XPCServer.route(harness.stop)
routes[XPCRoute.containerStartProcess] = XPCServer.route(harness.startProcess)
routes[XPCRoute.containerCreateProcess] = XPCServer.route(harness.createProcess)
routes[XPCRoute.containerResize] = XPCServer.route(harness.resize)
routes[XPCRoute.containerWait] = XPCServer.route(harness.wait)
routes[XPCRoute.containerKill] = XPCServer.route(harness.kill)
routes[XPCRoute.containerStats] = XPCServer.route(harness.stats)
routes[XPCRoute.containerDiskUsage] = XPCServer.route(harness.diskUsage)
routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn)
routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut)
routes[XPCRoute.containerExport] = XPCServer.route(harness.export)
return service
}
private func initializeNetworksService(
pluginLoader: PluginLoader,
containersService: ContainersService,
containerSystemConfig: ContainerSystemConfig,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) async throws -> NetworksService {
log.info("initializing networks service")
let resourceRoot = appRoot.appending(FilePath.Component("networks"))
let defaultNetworkConfig = try NetworkConfiguration(
name: NetworkClient.defaultNetworkName,
mode: .nat,
ipv4Subnet: containerSystemConfig.network.subnet,
ipv6Subnet: containerSystemConfig.network.subnetv6,
labels: try .init([ResourceLabelKeys.role: ResourceRoleValues.builtin]),
plugin: "container-network-vmnet"
)
let service = try await NetworksService(
pluginLoader: pluginLoader,
resourceRoot: resourceRoot,
containersService: containersService,
defaultNetworkConfiguration: defaultNetworkConfig,
log: log,
debugHelpers: debug
)
let defaultNetwork = try await service.list()
.filter { $0.isBuiltin }
.first
if defaultNetwork == nil {
// FIXME: default network should be configurable elsewhere
_ = try await service.create(configuration: defaultNetworkConfig)
}
let harness = NetworksHarness(service: service, log: log)
if #available(macOS 26, *) {
routes[XPCRoute.networkCreate] = XPCServer.route(harness.create)
}
routes[XPCRoute.networkList] = XPCServer.route(harness.list)
routes[XPCRoute.networkDelete] = XPCServer.route(harness.delete)
return service
}
private func initializeVolumeService(
containersService: ContainersService,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) async throws -> VolumesService {
log.info("initializing volume service")
let resourceRoot = appRoot.appending(FilePath.Component("volumes"))
let service = try await VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let harness = VolumesHarness(service: service, log: log)
routes[XPCRoute.volumeCreate] = XPCServer.route(harness.create)
routes[XPCRoute.volumeDelete] = XPCServer.route(harness.delete)
routes[XPCRoute.volumeList] = XPCServer.route(harness.list)
routes[XPCRoute.volumeInspect] = XPCServer.route(harness.inspect)
routes[XPCRoute.volumeDiskUsage] = XPCServer.route(harness.diskUsage)
return service
}
private func initializeDiskUsageService(
containersService: ContainersService,
volumesService: VolumesService,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) throws {
log.info("initializing disk usage service")
let service = DiskUsageService(
containersService: containersService,
volumesService: volumesService,
log: log
)
let harness = DiskUsageHarness(service: service, log: log)
routes[XPCRoute.systemDiskUsage] = XPCServer.route(harness.get)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
//===----------------------------------------------------------------------===//
// 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 ContainerVersion
@main
struct APIServer: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "container-apiserver",
abstract: "Container management API server",
version: ReleaseVersion.singleLine(appName: "container-apiserver"),
subcommands: [Start.self],
)
}
+104
View File
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
// 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 ContainerAPIService
import ContainerizationExtras
import DNSServer
/// Handler that uses table lookup to resolve hostnames.
struct ContainerDNSHandler: DNSHandler {
private let networkService: NetworksService
private let ttl: UInt32
public init(networkService: NetworksService, ttl: UInt32 = 5) {
self.networkService = networkService
self.ttl = ttl
}
public func answer(query: Message) async throws -> Message? {
guard let question = query.questions.first else {
return nil
}
let record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
record = try await answerHost(question: question)
case ResourceRecordType.host6:
let result = try await answerHost6(question: question)
if result.record == nil && result.hostnameExists {
// Return NODATA (noError with empty answers) when hostname exists but has no IPv6.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
}
record = result.record
default:
return Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
}
guard let record else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: [record]
)
}
private func answerHost(question: Question) async throws -> ResourceRecord? {
guard let ipAllocation = try await networkService.lookup(hostname: question.name) else {
return nil
}
let ipv4 = ipAllocation.ipv4Address.address.description
guard let ip = try? IPv4Address(ipv4) else {
throw DNSResolverError.serverError("failed to parse IP address: \(ipv4)")
}
return HostRecord<IPv4Address>(name: question.name, ttl: ttl, ip: ip)
}
private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) {
guard let ipAllocation = try await networkService.lookup(hostname: question.name) else {
return (nil, false)
}
guard let ipv6Address = ipAllocation.ipv6Address else {
return (nil, true)
}
let ipv6 = ipv6Address.address.description
guard let ip = try? IPv6Address(ipv6) else {
throw DNSResolverError.serverError("failed to parse IPv6 address: \(ipv6)")
}
return (HostRecord<IPv6Address>(name: question.name, ttl: ttl, ip: ip), true)
}
}
+119
View File
@@ -0,0 +1,119 @@
//===----------------------------------------------------------------------===//
// 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 ContainerOS
import ContainerPersistence
import ContainerizationError
import ContainerizationExtras
import DNSServer
import Foundation
import Logging
import Synchronization
import SystemPackage
actor LocalhostDNSHandler: DNSHandler {
private let ttl: UInt32
private let watcher: DirectoryWatcher
private var dns: [DNSName: IPv4Address]
public init(configPath: FilePath = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) {
self.ttl = ttl
self.watcher = DirectoryWatcher(directoryPath: configPath, log: log)
self.dns = [DNSName: IPv4Address]()
}
public func monitorResolvers() async {
await self.watcher.startWatching { [weak self] filePaths in
var dns: [DNSName: IPv4Address] = [:]
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
for file in filePaths.filter({
$0.lastComponent?.string.starts(with: HostDNSResolver.containerizationPrefix) == true
}) {
let content = try String(contentsOfFile: file.string, encoding: .utf8)
if let match = content.firstMatch(of: regex),
let ipv4 = (match[1].substring.flatMap { try? IPv4Address(String($0)) })
{
guard let lastName = file.lastComponent?.string else {
continue
}
let name = String(lastName.dropFirst(HostDNSResolver.containerizationPrefix.count))
guard let dnsName = try? DNSName(name) else {
continue
}
dns[dnsName] = ipv4
}
}
if let self {
Task { await self.updateDNS(dns) }
}
}
}
public func answer(query: Message) async throws -> Message? {
guard let question = query.questions.first else {
return nil
}
let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name
let key = try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))
var record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
if let ip = dns[key] {
record = HostRecord<IPv4Address>(name: question.name, ttl: ttl, ip: ip)
}
case ResourceRecordType.host6:
guard dns[key] != nil else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
default:
return Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
}
guard let record else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: [record]
)
}
private func updateDNS(_ dns: [DNSName: IPv4Address]) {
self.dns = dns
}
}
+17
View File
@@ -0,0 +1,17 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
// This file is required for Xcode to generate `CAuditToken.o`.
+20
View File
@@ -0,0 +1,20 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#include <xpc/xpc.h>
#include <bsm/libbsm.h>
void xpc_dictionary_get_audit_token(xpc_object_t xdict, audit_token_t *token);
+39
View File
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// 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 ContainerCommands
@main
public struct ContainerCLI: AsyncParsableCommand {
public init() {}
@Argument(parsing: .captureForPassthrough)
var arguments: [String] = []
public static let configuration = Application.configuration
public static func main() async throws {
try await Application.main()
}
public func run() async throws {
var application = try Application.parse(arguments)
try application.validate()
try application.run()
}
}
+33
View File
@@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#include "Version.h"
const char* get_git_commit() {
return GIT_COMMIT;
}
const char* get_release_version() {
return RELEASE_VERSION;
}
const char* get_swift_containerization_version() {
return CZ_VERSION;
}
const char* get_container_builder_shim_version() {
return BUILDER_SHIM_VERSION;
}
+39
View File
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// 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.
//===----------------------------------------------------------------------===//
#ifndef CZ_VERSION
#define CZ_VERSION "latest"
#endif
#ifndef GIT_COMMIT
#define GIT_COMMIT "unspecified"
#endif
#ifndef RELEASE_VERSION
#define RELEASE_VERSION "0.0.0"
#endif
#ifndef BUILDER_SHIM_VERSION
#define BUILDER_SHIM_VERSION "0.0.0"
#endif
const char* get_git_commit();
const char* get_release_version();
const char* get_swift_containerization_version();
const char* get_container_builder_shim_version();
@@ -0,0 +1,150 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationOCI
public typealias IO = Com_Apple_Container_Build_V1_IO
public typealias InfoRequest = Com_Apple_Container_Build_V1_InfoRequest
public typealias InfoResponse = Com_Apple_Container_Build_V1_InfoResponse
public typealias ClientStream = Com_Apple_Container_Build_V1_ClientStream
public typealias ServerStream = Com_Apple_Container_Build_V1_ServerStream
public typealias ImageTransfer = Com_Apple_Container_Build_V1_ImageTransfer
public typealias BuildTransfer = Com_Apple_Container_Build_V1_BuildTransfer
extension BuildTransfer {
func stage() -> String? {
let stage = self.metadata["stage"]
return stage == "" ? nil : stage
}
func method() -> String? {
let method = self.metadata["method"]
return method == "" ? nil : method
}
func includePatterns() -> [String]? {
guard let includePatternsString = self.metadata["include-patterns"] else {
return nil
}
return includePatternsString == "" ? nil : includePatternsString.components(separatedBy: ",")
}
func followPaths() -> [String]? {
guard let followPathString = self.metadata["followpaths"] else {
return nil
}
return followPathString == "" ? nil : followPathString.components(separatedBy: ",")
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
guard let sizeStr = self.metadata["size"] else {
return nil
}
return sizeStr == "" ? nil : Int(sizeStr)
}
func offset() -> UInt64? {
guard let offsetStr = self.metadata["offset"] else {
return nil
}
return offsetStr == "" ? nil : UInt64(offsetStr)
}
func len() -> Int? {
guard let lenStr = self.metadata["length"] else {
return nil
}
return lenStr == "" ? nil : Int(lenStr)
}
}
extension ImageTransfer {
func stage() -> String? {
self.metadata["stage"]
}
func method() -> String? {
self.metadata["method"]
}
func ref() -> String? {
self.metadata["ref"]
}
func platform() throws -> Platform? {
let metadata = self.metadata
guard let platform = metadata["platform"] else {
return nil
}
return try Platform(from: platform)
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
let metadata = self.metadata
guard let sizeStr = metadata["size"] else {
return nil
}
return Int(sizeStr)
}
func len() -> Int? {
let metadata = self.metadata
guard let lenStr = metadata["length"] else {
return nil
}
return Int(lenStr)
}
func offset() -> UInt64? {
let metadata = self.metadata
guard let offsetStr = metadata["offset"] else {
return nil
}
return UInt64(offsetStr)
}
}
extension ServerStream {
func getImageTransfer() -> ImageTransfer? {
if case .imageTransfer(let v) = self.packetType {
return v
}
return nil
}
func getBuildTransfer() -> BuildTransfer? {
if case .buildTransfer(let v) = self.packetType {
return v
}
return nil
}
func getIO() -> IO? {
if case .io(let v) = self.packetType {
return v
}
return nil
}
}
+536
View File
@@ -0,0 +1,536 @@
//===----------------------------------------------------------------------===//
// 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 Collections
import ContainerAPIClient
import ContainerizationArchive
import ContainerizationOCI
import CryptoKit
import Foundation
import GRPCCore
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL
init(_ contextDir: URL) throws {
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}
self.contextDir = contextDir
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let buildTransfer = packet.getBuildTransfer() else {
return false
}
guard buildTransfer.stage() == "fssync" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let buildTransfer = packet.getBuildTransfer() else {
throw Error.buildTransferMissing
}
guard let method = buildTransfer.method() else {
throw Error.methodMissing
}
switch try FSSyncMethod(method) {
case .read:
try await self.read(sender, buildTransfer, packet.buildID)
case .info:
try await self.info(sender, buildTransfer, packet.buildID)
case .walk:
try await self.walk(sender, buildTransfer, packet.buildID)
}
}
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
var path: URL
if packet.source.hasPrefix("/") {
path = URL(fileURLWithPath: packet.source).standardizedFileURL
} else {
path =
contextDir
.appendingPathComponent(packet.source)
.standardizedFileURL
}
if !FileManager.default.fileExists(atPath: path.cleanPath) {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let data = try {
if try path.isDir() {
return Data()
}
let file = try LocalContent(path: path.standardizedFileURL)
return try file.data(offset: offset, length: size) ?? Data()
}()
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true, data: data)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
path = URL(fileURLWithPath: packet.source).standardizedFileURL
} else {
path =
contextDir
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
private struct DirEntry: Hashable {
let url: URL
let isDirectory: Bool
let relativePath: String
func hash(into hasher: inout Hasher) {
hasher.combine(relativePath)
}
static func == (lhs: DirEntry, rhs: DirEntry) -> Bool {
lhs.relativePath == rhs.relativePath
}
}
func walk(
_ sender: AsyncStream<ClientStream>.Continuation,
_ packet: BuildTransfer,
_ buildID: String
) async throws {
let wantsTar = packet.mode() == "tar"
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths() ?? []
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
continue
}
guard self.contextDir.parentOf(url) else {
continue
}
let relPath = try url.relativeChildPath(to: contextDir)
let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir)
let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath)
entries[parentPath, default: []].insert(entry)
if url.isSymlink {
let target: URL = url.resolvingSymlinksInPath()
if self.contextDir.parentOf(target) {
let relPath = try target.relativeChildPath(to: self.contextDir)
let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath)
let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir)
entries[parentPath, default: []].insert(entry)
}
}
}
var fileOrder = [String]()
try processDirectory("", inputEntries: entries, processedPaths: &fileOrder)
if !wantsTar {
let fileInfos = try fileOrder.map { rel -> FileInfo in
try FileInfo(path: contextDir.appendingPathComponent(rel), contextDir: contextDir)
}
let data = try JSONEncoder().encode(fileInfos)
let transfer = BuildTransfer(
id: packet.id,
source: packet.source,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "json",
],
data: data
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = transfer
resp.packetType = .buildTransfer(transfer)
sender.yield(resp)
return
}
let tarURL = URL.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".tar")
defer { try? FileManager.default.removeItem(at: tarURL) }
let writerCfg = ArchiveWriterConfiguration(
format: .paxRestricted,
filter: .none)
let tarHash = try Archiver.compress(
source: contextDir,
destination: tarURL,
writerConfiguration: writerCfg
) { url in
guard let rel = try? url.relativeChildPath(to: contextDir) else {
return nil
}
guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else {
return nil
}
guard let items = entries[parent] else {
return nil
}
let include = items.contains { item in
item.relativePath == rel
}
guard include else {
return nil
}
return Archiver.ArchiveEntryInfo(
pathOnHost: url,
pathInArchive: URL(fileURLWithPath: rel))
}
let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined()
let header = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
"hash": hash,
]
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = header
resp.packetType = .buildTransfer(header)
sender.yield(resp)
for try await chunk in try tarURL.bufferedCopyReader() {
let part = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: chunk
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = part
resp.packetType = .buildTransfer(part)
sender.yield(resp)
}
let done = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: Data()
)
var finalResp = ClientStream()
finalResp.buildID = buildID
finalResp.buildTransfer = done
finalResp.packetType = .buildTransfer(done)
sender.yield(finalResp)
}
func walk(root: URL, includePatterns: [String]) throws -> [URL] {
let globber = Globber(root)
for p in includePatterns {
try globber.match(p)
}
return Array(globber.results)
}
private func processDirectory(
_ currentDir: String,
inputEntries: [String: Set<DirEntry>],
processedPaths: inout [String]
) throws {
guard let entries = inputEntries[currentDir] else {
return
}
// Sort purely by lexicographical order of relativePath
let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath }
for entry in sortedEntries {
processedPaths.append(entry.relativePath)
if entry.isDirectory {
try processDirectory(
entry.relativePath,
inputEntries: inputEntries,
processedPaths: &processedPaths
)
}
}
}
struct FileInfo: Codable {
let name: String
let modTime: String
let mode: UInt32
let size: UInt64
let isDir: Bool
let uid: UInt32
let gid: UInt32
let target: String
init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
self.name = try path.relativeChildPath(to: contextDir)
self.modTime = try path.modTime()
self.mode = try path.mode()
self.size = try path.size()
self.isDir = path.hasDirectoryPath
self.uid = 0
self.gid = 0
}
}
enum FSSyncMethod: String {
case read = "Read"
case info = "Info"
case walk = "Walk"
init(_ method: String) throws {
switch method {
case "Read":
self = .read
case "Info":
self = .info
case "Walk":
self = .walk
default:
throw Error.unknownMethod(method)
}
}
}
}
extension BuildFSSync {
enum Error: Swift.Error, CustomStringConvertible, Equatable {
case buildTransferMissing
case methodMissing
case unknownMethod(String)
case contextNotFound(String)
case contextIsNotDirectory(String)
case couldNotDetermineFileSize(String)
case couldNotDetermineModTime(String)
case couldNotDetermineFileMode(String)
case invalidOffsetSizeForFile(String, UInt64, Int)
case couldNotDetermineUID(String)
case couldNotDetermineGID(String)
case pathIsNotChild(String, String)
var description: String {
switch self {
case .buildTransferMissing:
return "buildTransfer field missing in packet"
case .methodMissing:
return "method is missing in request"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .contextNotFound(let path):
return "context dir \(path) not found"
case .contextIsNotDirectory(let path):
return "context \(path) not a directory"
case .couldNotDetermineFileSize(let path):
return "could not determine size of file \(path)"
case .couldNotDetermineModTime(let path):
return "could not determine last modified time of \(path)"
case .couldNotDetermineFileMode(let path):
return "could not determine posix permissions (FileMode) of \(path)"
case .invalidOffsetSizeForFile(let digest, let offset, let size):
return "invalid request for file: \(digest) with offset: \(offset) size: \(size)"
case .couldNotDetermineUID(let path):
return "could not determine UID of file at path: \(path)"
case .couldNotDetermineGID(let path):
return "could not determine GID of file at path: \(path)"
case .pathIsNotChild(let path, let parent):
return "\(path) is not a child of \(parent)"
}
}
}
}
extension BuildTransfer {
fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) {
self.init()
self.id = id
self.source = source
self.direction = .outof
self.complete = complete
self.metadata = metadata
self.isDirectory = isDir
if let data {
self.data = data
}
}
}
extension URL {
fileprivate func size() throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let size = attrs[FileAttributeKey.size] as? UInt64 {
return size
}
throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath)
}
fileprivate func modTime() throws -> String {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let date = attrs[FileAttributeKey.modificationDate] as? Date {
return date.rfc3339()
}
throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath)
}
fileprivate func isDir() throws -> Bool {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
guard let t = attrs[.type] as? FileAttributeType, t == .typeDirectory else {
return false
}
return true
}
fileprivate func mode() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber {
return mode.uint32Value
}
throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath)
}
fileprivate func uid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let uid = attrs[.ownerAccountID] as? UInt32 {
return uid
}
throw BuildFSSync.Error.couldNotDetermineUID(self.cleanPath)
}
fileprivate func gid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let gid = attrs[.groupOwnerAccountID] as? UInt32 {
return gid
}
throw BuildFSSync.Error.couldNotDetermineGID(self.cleanPath)
}
fileprivate func buildTransfer(
id: String,
contextDir: URL? = nil,
complete: Bool = false,
data: Data = Data()
) throws -> BuildTransfer {
let p = try {
if let contextDir { return try self.relativeChildPath(to: contextDir) }
return self.cleanPath
}()
return BuildTransfer(
id: id,
source: String(p),
complete: complete,
isDir: try self.isDir(),
metadata: [
"os": "linux",
"stage": "fssync",
"mode": String(try self.mode()),
"size": String(try self.size()),
"modified_at": try self.modTime(),
"uid": String(try self.uid()),
"gid": String(try self.gid()),
],
data: data
)
}
}
extension Date {
fileprivate func rfc3339() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary
return dateFormatter.string(from: self)
}
}
extension String {
var cleanPathComponent: String {
let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if let clean = trimmed.removingPercentEncoding {
return clean
}
return trimmed
}
}
+46
View File
@@ -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 Foundation
import Logging
public struct BuildFile {
/// Tries to resolve either a Dockerfile or Containerfile relative to contextDir.
/// Checks for Dockerfile, then falls back to Containerfile.
public static func resolvePath(contextDir: String, log: Logger? = nil) throws -> String? {
// Check for Dockerfile then Containerfile in context directory
let dockerfilePath = URL(filePath: contextDir).appendingPathComponent("Dockerfile").path
let containerfilePath = URL(filePath: contextDir).appendingPathComponent("Containerfile").path
let dockerfileExists = FileManager.default.fileExists(atPath: dockerfilePath)
let containerfileExists = FileManager.default.fileExists(atPath: containerfilePath)
if dockerfileExists && containerfileExists {
log?.info("Detected both Dockerfile and Containerfile, choosing Dockerfile")
return dockerfilePath
}
if dockerfileExists {
return dockerfilePath
}
if containerfileExists {
return containerfilePath
}
return nil
}
}
@@ -0,0 +1,178 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import Containerization
import ContainerizationOCI
import Foundation
import GRPCCore
import Logging
import TerminalProgress
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
let output: FileHandle
let pull: Bool
let containerSystemConfig: ContainerSystemConfig
public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false, containerSystemConfig: ContainerSystemConfig)
throws
{
self.contentStore = contentStore
self.quiet = quiet
self.output = output
self.pull = pull
self.containerSystemConfig = containerSystemConfig
}
func accept(_ packet: ServerStream) throws -> Bool {
guard let imageTransfer = packet.getImageTransfer() else {
return false
}
guard imageTransfer.stage() == "resolver" else {
return false
}
guard imageTransfer.method() == "/resolve" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let ref = imageTransfer.ref() else {
throw Error.tagMissing
}
guard let platform = try imageTransfer.platform() else {
throw Error.platformMissing
}
let img = try await {
let progressConfig = try ProgressConfig(
terminal: self.output,
description: "Pulling \(ref)",
showPercent: true,
showProgressBar: true,
showSize: true,
showSpeed: true,
disableProgressUpdates: self.quiet
)
let progress = ProgressBar(config: progressConfig)
defer { progress.finish() }
progress.start()
if self.pull {
return try await ClientImage.pull(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
}
// Use fetch() which checks cache first, then pulls if needed
return try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
}()
let index: Index = try await img.index()
let buildID = packet.buildID
let platforms = index.manifests.compactMap { $0.platform }
for pl in platforms {
if pl == platform {
let manifest = try await img.manifest(for: pl)
guard let ociImage: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest) else {
continue
}
let enc = JSONEncoder()
let data = try enc.encode(ociImage)
let transfer = try ImageTransfer(
id: imageTransfer.id,
digest: img.descriptor.digest,
ref: ref,
platform: platform.description,
data: data
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
return
}
}
throw Error.unknownPlatformForImage(platform.description, ref)
}
}
extension ImageTransfer {
fileprivate init(id: String, digest: String, ref: String, platform: String, data: Data) throws {
self.init()
self.id = id
self.tag = digest
self.metadata = [
"os": "linux",
"stage": "resolver",
"method": "/resolve",
"ref": ref,
"platform": platform,
]
self.complete = true
self.direction = .into
self.data = data
}
}
extension BuildImageResolver {
enum Error: Swift.Error, CustomStringConvertible {
case imageTransferMissing
case tagMissing
case platformMissing
case imageNameMissing
case imageTagMissing
case imageNotFound
case indexDigestMissing(String)
case unknownRegistry(String)
case digestIsNotIndex(String)
case digestIsNotManifest(String)
case unknownPlatformForImage(String, String)
var description: String {
switch self {
case .imageTransferMissing:
return "imageTransfer is missing"
case .tagMissing:
return "tag parameter missing in metadata"
case .platformMissing:
return "platform parameter missing in metadata"
case .imageNameMissing:
return "image name missing in $ref parameter"
case .imageTagMissing:
return "image tag missing in $ref parameter"
case .imageNotFound:
return "image not found"
case .indexDigestMissing(let ref):
return "index digest is missing for image: \(ref)"
case .unknownRegistry(let registry):
return "registry \(registry) is unknown"
case .digestIsNotIndex(let digest):
return "digest \(digest) is not a descriptor to an index"
case .digestIsNotManifest(let digest):
return "digest \(digest) is not a descriptor to a manifest"
case .unknownPlatformForImage(let platform, let ref):
return "platform \(platform) for image \(ref) not found"
}
}
}
}
@@ -0,0 +1,198 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import GRPCCore
import NIO
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
self.handlers =
[
try BuildFSSync(URL(filePath: config.contextDir)),
try BuildRemoteContentProxy(config.contentStore),
try BuildImageResolver(
config.contentStore,
quiet: config.quiet,
output: config.terminal?.handle ?? FileHandle.standardError,
pull: config.pull,
containerSystemConfig: config.containerSystemConfig
),
try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError),
]
}
public func run<S: AsyncSequence & Sendable>(
sender: AsyncStream<ClientStream>.Continuation,
receiver: S
) async throws where S.Element == ServerStream {
defer { sender.finish() }
try await untilFirstError { group in
for try await packet in receiver {
try Task.checkCancellation()
for handler in self.handlers {
try Task.checkCancellation()
guard try handler.accept(packet) else {
continue
}
try Task.checkCancellation()
try await handler.handle(sender, packet)
break
}
}
}
}
/// untilFirstError() throws when any one of its submitted tasks fail.
/// This is useful for asynchronous packet processing scenarios which
/// have the following 3 requirements:
/// - the packet should be processed without blocking I/O
/// - the packet stream is never-ending
/// - when the first task fails, the error needs to be propagated to the caller
///
/// Usage:
///
/// ```
/// try await untilFirstError { group in
/// for try await packet in receiver {
/// group.addTask {
/// try await handler.handle(sender, packet)
/// }
/// }
/// }
/// ```
///
///
/// WithThrowingTaskGroup cannot accomplish this because it
/// doesn't provide a mechanism to exit when one of the tasks fail
/// before all the tasks have been added. i.e. it is more suitable for
/// tasks that are limited. Here's a sample code where withThrowingTaskGroup
/// doesn't solve the problem:
///
/// ```
/// withThrowingTaskGroup { group in
/// for try await packet in receiver {
/// group.addTask {
/// /* process packet */
/// }
/// } /* this loop blocks forever waiting for more packets */
/// try await group.next() /* this never gets called */
/// }
/// ```
/// The above closure never returns even when a handler encounters an error
/// because the blocking operation `try await group.next()` cannot be
/// called while iterating over the receiver stream.
private func untilFirstError(body: @Sendable @escaping (UntilFirstError) async throws -> Void) async throws {
let group = try await UntilFirstError()
var taskContinuation: AsyncStream<Task<(), Error>>.Continuation?
let tasks = AsyncStream<Task<(), Error>> { continuation in
taskContinuation = continuation
}
guard let taskContinuation else {
throw NSError(
domain: "untilFirstError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "failed to initialize task continuation"])
}
defer { taskContinuation.finish() }
let stream = AsyncStream<Error> { continuation in
let processTasks = Task {
let taskStream = await group.tasks()
defer {
continuation.finish()
}
for await item in taskStream {
try Task.checkCancellation()
let addedTask = Task {
try Task.checkCancellation()
do {
try await item()
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(addedTask)
}
}
taskContinuation.yield(processTasks)
let mainTask = Task { @Sendable in
defer {
continuation.finish()
processTasks.cancel()
taskContinuation.finish()
}
do {
try Task.checkCancellation()
try await body(group)
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(mainTask)
}
// when the first handler fails, cancel all tasks and throw error
for await item in stream {
try Task.checkCancellation()
Task {
for await task in tasks {
task.cancel()
}
}
throw item
}
// if none of the handlers fail, wait for all subtasks to complete
for await task in tasks {
try Task.checkCancellation()
try await task.value
}
}
private actor UntilFirstError {
var stream: AsyncStream<@Sendable () async throws -> Void>?
var continuation: AsyncStream<@Sendable () async throws -> Void>.Continuation?
init() async throws {
self.stream = AsyncStream { cont in
self.continuation = cont
}
guard let _ = continuation else {
throw NSError()
}
}
func addTask(body: @Sendable @escaping () async throws -> Void) {
if !Task.isCancelled {
self.continuation?.yield(body)
}
}
func tasks() -> AsyncStream<@Sendable () async throws -> Void> {
self.stream!
}
}
}
@@ -0,0 +1,188 @@
//===----------------------------------------------------------------------===//
// 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 Containerization
import ContainerizationArchive
import ContainerizationOCI
import Foundation
import GRPCCore
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore
public init(_ contentStore: ContentStore) throws {
self.local = contentStore
}
func accept(_ packet: ServerStream) throws -> Bool {
guard let imageTransfer = packet.getImageTransfer() else {
return false
}
guard imageTransfer.stage() == "content-store" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let method = imageTransfer.method() else {
throw Error.methodMissing
}
switch try ContentStoreMethod(method) {
case .info:
try await self.info(sender, imageTransfer, packet.buildID)
case .readerAt:
try await self.readerAt(sender, imageTransfer, packet.buildID)
default:
throw Error.unknownMethod(method)
}
}
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let descriptor = try await local.get(digest: packet.tag)
let size = try descriptor?.size()
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.info.rawValue,
size: size
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func readerAt(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let digest = packet.descriptor.digest
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
guard let descriptor = try await local.get(digest: digest) else {
throw Error.contentMissing
}
if offset == 0 && size == 0 { // Metadata request
var transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: descriptor.size(),
data: Data()
)
transfer.complete = true
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
return
}
guard let data = try descriptor.data(offset: offset, length: size) else {
throw Error.invalidOffsetSizeForContent(packet.descriptor.digest, offset, size)
}
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: UInt64(data.count),
data: data
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func delete(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"])
}
func update(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"])
}
func walk(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.walk)"])
}
enum ContentStoreMethod: String {
case info = "/containerd.services.content.v1.Content/Info"
case readerAt = "/containerd.services.content.v1.Content/ReaderAt"
case delete = "/containerd.services.content.v1.Content/Delete"
case update = "/containerd.services.content.v1.Content/Update"
case walk = "/containerd.services.content.v1.Content/Walk"
init(_ method: String) throws {
guard let value = ContentStoreMethod(rawValue: method) else {
throw Error.unknownMethod(method)
}
self = value
}
}
}
extension ImageTransfer {
fileprivate init(id: String, digest: String, method: String, size: UInt64? = nil, data: Data = Data()) throws {
self.init()
self.id = id
self.tag = digest
self.metadata = [
"os": "linux",
"stage": "content-store",
"method": method,
]
if let size {
self.metadata["size"] = String(size)
}
self.complete = true
self.direction = .into
self.data = data
}
}
extension BuildRemoteContentProxy {
enum Error: Swift.Error, CustomStringConvertible {
case imageTransferMissing
case methodMissing
case contentMissing
case unknownMethod(String)
case invalidOffsetSizeForContent(String, UInt64, Int)
var description: String {
switch self {
case .imageTransferMissing:
return "imageTransfer is missing"
case .methodMissing:
return "method is missing in request"
case .contentMissing:
return "content cannot be found"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .invalidOffsetSizeForContent(let digest, let offset, let size):
return "invalid request for content: \(digest) with offset: \(offset) size: \(size)"
}
}
}
}
+70
View File
@@ -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 ContainerizationOS
import Foundation
import GRPCCore
import NIO
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
init(quiet: Bool = false, output: FileHandle = FileHandle.standardError) throws {
self.quiet = quiet
self.handle = output
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let _ = packet.getIO() else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard !quiet else {
return
}
guard let io = packet.getIO() else {
throw Error.ioMissing
}
if let cmdString = try TerminalCommand().json() {
var response = ClientStream()
response.buildID = packet.buildID
response.command = .init()
response.command.id = packet.buildID
response.command.command = cmdString
sender.yield(response)
}
handle.write(io.data)
}
}
extension BuildStdio {
enum Error: Swift.Error, CustomStringConvertible {
case ioMissing
case invalidContinuation
var description: String {
switch self {
case .ioMissing:
return "io field missing in packet"
case .invalidContinuation:
return "continuation could not created"
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
//===----------------------------------------------------------------------===//
// 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 ContainerPersistence
import Containerization
import ContainerizationOCI
import ContainerizationOS
import Foundation
import GRPCCore
import GRPCNIOTransportHTTP2
import Logging
import NIO
import NIOPosix
public struct Builder: Sendable {
public static let builderContainerId = "buildkit"
let client: Com_Apple_Container_Build_V1_Builder.Client<HTTP2ClientTransport.WrappedChannel>
let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
let group: EventLoopGroup
let builderShimSocket: FileHandle
let clientTask: Task<Void, any Swift.Error>
let logger: Logger
public init(socket: FileHandle, group: EventLoopGroup, logger: Logger) async throws {
try socket.setSendBufSize(4 << 20)
try socket.setRecvBufSize(2 << 20)
let transport = try await HTTP2ClientTransport.WrappedChannel.wrapping(
config: .defaults,
serviceConfig: .init()
) { configure in
try await withCheckedThrowingContinuation { continuation in
ClientBootstrap(group: group)
.channelInitializer { channel in
configure(channel).map { configured in
continuation.resume(returning: configured)
}
}
.withConnectedSocket(socket.fileDescriptor)
.whenFailure { error in
continuation.resume(throwing: error)
}
}
}
let grpcClient = GRPCClient(transport: transport)
self.grpcClient = grpcClient
self.client = Com_Apple_Container_Build_V1_Builder.Client(wrapping: grpcClient)
self.group = group
self.builderShimSocket = socket
self.logger = logger
// Start the client connection loop in a background task
self.clientTask = Task {
do {
try await grpcClient.runConnections()
} catch is CancellationError {
// Expected during graceful shutdown - re-throw
throw CancellationError()
} catch let error as RPCError where error.code == .unavailable {
// Connection closed - this is expected when the container stops
logger.debug("gRPC connection closed: \(error)")
throw error
} catch {
// Log unexpected connection errors
logger.error("gRPC client connection error: \(error)")
throw error
}
}
}
public func info() async throws -> InfoResponse {
var opts = CallOptions.defaults
opts.timeout = .seconds(30)
return try await self.client.info(InfoRequest(), options: opts)
}
// TODO
// - Symlinks in build context dir
// - cache-to, cache-from
// - output (other than the default OCI image output, e.g., local, tar, Docker)
public func build(_ config: BuildConfig) async throws {
var continuation: AsyncStream<ClientStream>.Continuation?
let reqStream = AsyncStream<ClientStream> { (cont: AsyncStream<ClientStream>.Continuation) in
continuation = cont
}
guard let continuation else {
throw Error.invalidContinuation
}
defer {
continuation.finish()
}
if let terminal = config.terminal {
Task {
let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH])
let setWinch = { (rows: UInt16, cols: UInt16) in
var winch = ClientStream()
winch.command = .init()
if let cmdString = try TerminalCommand(rows: rows, cols: cols).json() {
winch.command.command = cmdString
continuation.yield(winch)
}
}
let size = try terminal.size
var width = size.width
var height = size.height
try setWinch(height, width)
for await _ in winchHandler.signals {
let size = try terminal.size
let cols = size.width
let rows = size.height
if cols != width || rows != height {
width = cols
height = rows
try setWinch(height, width)
}
}
}
}
let pipeline = try await BuildPipeline(config)
do {
try await self.client.performBuild(
metadata: try Self.buildMetadata(config),
options: .defaults,
requestProducer: { writer in
for await message in reqStream {
try await writer.write(message)
}
},
onResponse: { response in
try await pipeline.run(sender: continuation, receiver: response.messages)
}
)
} catch Error.buildComplete {
self.grpcClient.beginGracefulShutdown()
self.clientTask.cancel()
try await group.shutdownGracefully()
return
}
}
public struct BuildExport: Sendable {
public let type: String
public var destination: URL?
public let additionalFields: [String: String]
public let rawValue: String
public init(type: String, destination: URL?, additionalFields: [String: String], rawValue: String) {
self.type = type
self.destination = destination
self.additionalFields = additionalFields
self.rawValue = rawValue
}
public init(from input: String) throws {
var typeValue: String?
var destinationValue: URL?
var additionalFields: [String: String] = [:]
let pairs = input.components(separatedBy: ",")
for pair in pairs {
let parts = pair.components(separatedBy: "=")
guard parts.count == 2 else { continue }
let key = parts[0].trimmingCharacters(in: .whitespaces)
let value = parts[1].trimmingCharacters(in: .whitespaces)
switch key {
case "type":
typeValue = value
case "dest":
destinationValue = try Self.resolveDestination(dest: value)
default:
additionalFields[key] = value
}
}
guard let type = typeValue else {
throw Builder.Error.invalidExport(input, "type field is required")
}
switch type {
case "oci":
break
case "tar":
if destinationValue == nil {
throw Builder.Error.invalidExport(input, "dest field is required")
}
case "local":
if destinationValue == nil {
throw Builder.Error.invalidExport(input, "dest field is required")
}
default:
throw Builder.Error.invalidExport(input, "unsupported output type")
}
self.init(type: type, destination: destinationValue, additionalFields: additionalFields, rawValue: input)
}
public var stringValue: String {
get throws {
var components = ["type=\(type)"]
switch type {
case "oci", "tar", "local":
break // ignore destination
default:
throw Builder.Error.invalidExport(rawValue, "unsupported output type")
}
for (key, value) in additionalFields {
components.append("\(key)=\(value)")
}
return components.joined(separator: ",")
}
}
static func resolveDestination(dest: String) throws -> URL {
let destination = URL(fileURLWithPath: dest)
let fileManager = FileManager.default
if fileManager.fileExists(atPath: destination.path) {
let resourceValues = try destination.resourceValues(forKeys: [.isDirectoryKey])
let isDir = resourceValues.isDirectory
if isDir != nil && isDir == false {
throw Builder.Error.invalidExport(dest, "dest path already exists")
}
var finalDestination = destination.appendingPathComponent("out.tar")
var index = 1
while fileManager.fileExists(atPath: finalDestination.path) {
let path = "out.tar.\(index)"
finalDestination = destination.appendingPathComponent(path)
index += 1
}
return finalDestination
} else {
let parentDirectory = destination.deletingLastPathComponent()
try? fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
}
return destination
}
}
public struct BuildConfig: Sendable {
public let buildID: String
public let contentStore: ContentStore
public let buildArgs: [String]
public let secrets: [String: Data]
public let contextDir: String
public let dockerfile: Data
public let dockerignore: Data?
public let labels: [String]
public let noCache: Bool
public let platforms: [Platform]
public let terminal: Terminal?
public let tags: [String]
public let target: String
public let quiet: Bool
public let exports: [BuildExport]
public let cacheIn: [String]
public let cacheOut: [String]
public let pull: Bool
public let containerSystemConfig: ContainerSystemConfig
public init(
buildID: String,
contentStore: ContentStore,
buildArgs: [String],
secrets: [String: Data],
contextDir: String,
dockerfile: Data,
dockerignore: Data?,
labels: [String],
noCache: Bool,
platforms: [Platform],
terminal: Terminal?,
tags: [String],
target: String,
quiet: Bool,
exports: [BuildExport],
cacheIn: [String],
cacheOut: [String],
pull: Bool,
containerSystemConfig: ContainerSystemConfig
) {
self.buildID = buildID
self.contentStore = contentStore
self.buildArgs = buildArgs
self.secrets = secrets
self.contextDir = contextDir
self.dockerfile = dockerfile
self.dockerignore = dockerignore
self.labels = labels
self.noCache = noCache
self.platforms = platforms
self.terminal = terminal
self.tags = tags
self.target = target
self.quiet = quiet
self.exports = exports
self.cacheIn = cacheIn
self.cacheOut = cacheOut
self.pull = pull
self.containerSystemConfig = containerSystemConfig
}
}
static func buildMetadata(_ config: BuildConfig) throws -> Metadata {
var metadata = Metadata()
metadata.addString(config.buildID, forKey: "build-id")
metadata.addString(URL(filePath: config.contextDir).path(percentEncoded: false), forKey: "context")
metadata.addString(config.dockerfile.base64EncodedString(), forKey: "dockerfile")
metadata.addString(config.terminal != nil ? "tty" : "plain", forKey: "progress")
metadata.addString(config.target, forKey: "target")
if let dockerignore = config.dockerignore {
metadata.addString(dockerignore.base64EncodedString(), forKey: "dockerignore")
}
for tag in config.tags {
metadata.addString(tag, forKey: "tag")
}
for platform in config.platforms {
metadata.addString(platform.description, forKey: "platforms")
}
if config.noCache {
metadata.addString("", forKey: "no-cache")
}
for label in config.labels {
metadata.addString(label, forKey: "labels")
}
for buildArg in config.buildArgs {
metadata.addString(buildArg, forKey: "build-args")
}
for (id, data) in config.secrets {
metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets")
}
for output in config.exports {
metadata.addString(try output.stringValue, forKey: "outputs")
}
for cacheIn in config.cacheIn {
metadata.addString(cacheIn, forKey: "cache-in")
}
for cacheOut in config.cacheOut {
metadata.addString(cacheOut, forKey: "cache-out")
}
return metadata
}
}
extension Builder {
enum Error: Swift.Error, CustomStringConvertible {
case invalidContinuation
case buildComplete
case invalidExport(String, String)
var description: String {
switch self {
case .invalidContinuation:
return "continuation could not created"
case .buildComplete:
return "build completed"
case .invalidExport(let exp, let reason):
return "export entry \(exp) is invalid: \(reason)"
}
}
}
}
extension FileHandle {
@discardableResult
func setSendBufSize(_ bytes: Int) throws -> Int {
try setSockOpt(
level: SOL_SOCKET,
name: SO_SNDBUF,
value: bytes)
return bytes
}
@discardableResult
func setRecvBufSize(_ bytes: Int) throws -> Int {
try setSockOpt(
level: SOL_SOCKET,
name: SO_RCVBUF,
value: bytes)
return bytes
}
private func setSockOpt(level: Int32, name: Int32, value: Int) throws {
var v = Int32(value)
let res = withUnsafePointer(to: &v) { ptr -> Int32 in
ptr.withMemoryRebound(
to: UInt8.self,
capacity: MemoryLayout<Int32>.size
) { raw in
#if canImport(Darwin)
return setsockopt(
self.fileDescriptor,
level, name,
raw,
socklen_t(MemoryLayout<Int32>.size))
#else
fatalError("unsupported platform")
#endif
}
}
if res == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
}
}
}
+121
View File
@@ -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 Foundation
public class Globber {
let input: URL
var results: Set<URL> = .init()
public init(_ input: URL) {
self.input = input
}
public func match(_ pattern: String) throws {
let adjustedPattern =
pattern
.replacingOccurrences(of: #"^\./(?=.)"#, with: "", options: .regularExpression)
.replacingOccurrences(of: "^\\.[/]?$", with: "*", options: .regularExpression)
.replacingOccurrences(of: "\\*{2,}[/]", with: "*/**/", options: .regularExpression)
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)
for child in input.children {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
private func match(input: URL, components: [String]) throws {
if components.isEmpty {
var dir = input.standardizedFileURL
while dir != self.input.standardizedFileURL {
results.insert(dir)
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
}
let head = components.first ?? ""
let tail = components.tail
if head == "**" {
var tail: [String] = tail
while tail.first == "**" {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
try self.match(input: child, components: components)
}
return
}
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)
for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}
func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
+ NSRegularExpression.escapedPattern(for: pattern)
.replacingOccurrences(of: "\\*", with: "[^/]*")
.replacingOccurrences(of: "\\?", with: "[^/]")
.replacingOccurrences(of: "[\\^", with: "[^")
.replacingOccurrences(of: "\\[", with: "[")
.replacingOccurrences(of: "\\]", with: "]") + "$"
// validate the regex pattern created
let _ = try Regex(regexPattern)
return input.range(of: regexPattern, options: .regularExpression) != nil
}
}
extension URL {
var children: [URL] {
(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}
var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}
extension [String] {
var tail: [String] {
if self.count <= 1 {
return []
}
return Array(self.dropFirst())
}
}
@@ -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 Foundation
struct TerminalCommand: Codable {
let commandType: String
let code: String
let rows: UInt16
let cols: UInt16
enum CodingKeys: String, CodingKey {
case commandType = "command_type"
case code
case rows
case cols
}
init(rows: UInt16, cols: UInt16) {
self.commandType = "terminal"
self.code = "winch"
self.rows = rows
self.cols = cols
}
init() {
self.commandType = "terminal"
self.code = "ack"
self.rows = 0
self.cols = 0
}
func json() throws -> String? {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
return data.base64EncodedString().trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
}
+284
View File
@@ -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 Foundation
extension String {
fileprivate var fs_cleaned: String {
var value = self
if value.hasPrefix("file://") {
value.removeFirst("file://".count)
}
if value.count > 1 && value.last == "/" {
value.removeLast()
}
return value.removingPercentEncoding ?? value
}
fileprivate var fs_components: [String] {
var parts: [String] = []
for segment in self.split(separator: "/", omittingEmptySubsequences: true) {
switch segment {
case ".":
continue
case "..":
if !parts.isEmpty { parts.removeLast() }
default:
parts.append(String(segment))
}
}
return parts
}
fileprivate var fs_isAbsolute: Bool { first == "/" }
}
extension URL {
var cleanPath: String {
self.path.fs_cleaned
}
func parentOf(_ url: URL) -> Bool {
let parentPath = self.absoluteURL.cleanPath
let childPath = url.absoluteURL.cleanPath
guard parentPath.fs_isAbsolute else {
return true
}
let parentParts = parentPath.fs_components
let childParts = childPath.fs_components
guard parentParts.count <= childParts.count else { return false }
return zip(parentParts, childParts).allSatisfy { $0 == $1 }
}
func relativeChildPath(to context: URL) throws -> String {
guard context.parentOf(self) else {
throw BuildFSSync.Error.pathIsNotChild(cleanPath, context.cleanPath)
}
let ctxParts = context.cleanPath.fs_components
let selfParts = cleanPath.fs_components
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
}
func relativePathFrom(from base: URL) -> String {
let destParts = cleanPath.fs_components
let baseParts = base.cleanPath.fs_components
let common = zip(destParts, baseParts).prefix { $0 == $1 }.count
guard common > 0 else { return cleanPath }
let ups = Array(repeating: "..", count: baseParts.count - common)
let remainder = destParts.dropFirst(common)
return (ups + remainder).joined(separator: "/")
}
func zeroCopyReader(
chunk: Int = 1024 * 1024,
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
) throws -> AsyncStream<Data> {
let path = self.cleanPath
let fd = open(path, O_RDONLY | O_NONBLOCK)
guard fd >= 0 else { throw POSIXError.fromErrno() }
let channel = DispatchIO(
type: .stream,
fileDescriptor: fd,
queue: .global(qos: .userInitiated)
) { errno in
close(fd)
}
channel.setLimit(highWater: chunk)
return AsyncStream(bufferingPolicy: buffer) { continuation in
channel.read(
offset: 0, length: Int.max,
queue: .global(qos: .userInitiated)
) { done, ddata, err in
if err != 0 {
continuation.finish()
return
}
if let ddata, ddata.count > -1 {
let data = Data(ddata)
switch continuation.yield(data) {
case .terminated:
channel.close(flags: .stop)
default: break
}
}
if done {
channel.close(flags: .stop)
continuation.finish()
}
}
}
}
func bufferedCopyReader(chunkSize: Int = 4 * 1024 * 1024) throws -> BufferedCopyReader {
try BufferedCopyReader(url: self, chunkSize: chunkSize)
}
}
/// A synchronous buffered reader that reads one chunk at a time from a file
/// Uses a configurable buffer size (default 4MB) and only reads when nextChunk() is called
/// Implements AsyncSequence for use with `for await` loops
public final class BufferedCopyReader: AsyncSequence {
public typealias Element = Data
public typealias AsyncIterator = BufferedCopyReaderIterator
private let inputStream: InputStream
private let chunkSize: Int
private var isFinished: Bool = false
private let reusableBuffer: UnsafeMutablePointer<UInt8>
/// Initialize a buffered copy reader for the given URL
/// - Parameters:
/// - url: The file URL to read from
/// - chunkSize: Size of each chunk to read (default: 4MB)
public init(url: URL, chunkSize: Int = 4 * 1024 * 1024) throws {
guard let stream = InputStream(url: url) else {
throw CocoaError(.fileReadNoSuchFile)
}
self.inputStream = stream
self.chunkSize = chunkSize
self.reusableBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: chunkSize)
self.inputStream.open()
}
deinit {
inputStream.close()
reusableBuffer.deallocate()
}
/// Create an async iterator for this sequence
public func makeAsyncIterator() -> BufferedCopyReaderIterator {
BufferedCopyReaderIterator(reader: self)
}
/// Read the next chunk of data from the file
/// - Returns: Data chunk, or nil if end of file reached
/// - Throws: Any file reading errors
public func nextChunk() throws -> Data? {
guard !isFinished else { return nil }
// Read directly into our reusable buffer
let bytesRead = inputStream.read(reusableBuffer, maxLength: chunkSize)
// Check for errors
if bytesRead < 0 {
if let error = inputStream.streamError {
throw error
}
throw CocoaError(.fileReadUnknown)
}
// If we read no data, we've reached the end
if bytesRead == 0 {
isFinished = true
return nil
}
// If we read less than the chunk size, this is the last chunk
if bytesRead < chunkSize {
isFinished = true
}
// Create Data object only with the bytes actually read
return Data(bytes: reusableBuffer, count: bytesRead)
}
/// Check if the reader has finished reading the file
public var hasFinished: Bool {
isFinished
}
/// Reset the reader to the beginning of the file
/// Note: InputStream doesn't support seeking, so this recreates the stream
/// - Throws: Any file opening errors
public func reset() throws {
inputStream.close()
// Note: InputStream doesn't provide a way to get the original URL,
// so reset functionality is limited. Consider removing this method
// or storing the original URL if reset is needed.
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "reset not supported with InputStream-based implementation"
])
}
/// Get the current file offset
/// Note: InputStream doesn't provide offset information
/// - Returns: Current position in the file
/// - Throws: Unsupported operation error
public func currentOffset() throws -> UInt64 {
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "offset tracking not supported with InputStream-based implementation"
])
}
/// Seek to a specific offset in the file
/// Note: InputStream doesn't support seeking
/// - Parameter offset: The byte offset to seek to
/// - Throws: Unsupported operation error
public func seek(to offset: UInt64) throws {
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "seeking not supported with InputStream-based implementation"
])
}
/// Close the input stream explicitly (called automatically in deinit)
public func close() {
inputStream.close()
isFinished = true
}
}
/// AsyncIteratorProtocol implementation for BufferedCopyReader
public struct BufferedCopyReaderIterator: AsyncIteratorProtocol {
public typealias Element = Data
private let reader: BufferedCopyReader
init(reader: BufferedCopyReader) {
self.reader = reader
}
/// Get the next chunk of data asynchronously
/// - Returns: Next data chunk, or nil when finished
/// - Throws: Any file reading errors
public mutating func next() async throws -> Data? {
// Yield control to allow other tasks to run, then read synchronously
await Task.yield()
return try reader.nextChunk()
}
}
@@ -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()
}
}
}

Some files were not shown because too many files have changed in this diff Show More