chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s
container project - merge build / Invoke build (push) Successful in 1s
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerLog
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerVersion
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import SystemPackage
|
||||
import TerminalProgress
|
||||
|
||||
// This logger is only used until `asyncCommand.run()`.
|
||||
// `log` is updated only once in the `validate()` method.
|
||||
private nonisolated(unsafe) var bootstrapLogger = {
|
||||
LoggingSystem.bootstrap({ _ in StderrLogHandler() })
|
||||
var log = Logger(label: "com.apple.container")
|
||||
log.logLevel = .info
|
||||
return log
|
||||
}()
|
||||
|
||||
public struct Application: AsyncLoggableCommand {
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "container",
|
||||
abstract: "A container platform for macOS",
|
||||
version: ReleaseVersion.singleLine(appName: "container CLI"),
|
||||
subcommands: [
|
||||
DefaultCommand.self,
|
||||
HelpCommand.self,
|
||||
],
|
||||
groupedSubcommands: [
|
||||
CommandGroup(
|
||||
name: "Container",
|
||||
subcommands: [
|
||||
ContainerCopy.self,
|
||||
ContainerCreate.self,
|
||||
ContainerDelete.self,
|
||||
ContainerExec.self,
|
||||
ContainerExport.self,
|
||||
ContainerInspect.self,
|
||||
ContainerKill.self,
|
||||
ContainerList.self,
|
||||
ContainerLogs.self,
|
||||
ContainerRun.self,
|
||||
ContainerStart.self,
|
||||
ContainerStats.self,
|
||||
ContainerStop.self,
|
||||
ContainerPrune.self,
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Image",
|
||||
subcommands: [
|
||||
BuildCommand.self,
|
||||
ImageCommand.self,
|
||||
RegistryCommand.self,
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Machine",
|
||||
subcommands: [
|
||||
MachineCommand.self
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Volume",
|
||||
subcommands: [
|
||||
VolumeCommand.self
|
||||
]
|
||||
),
|
||||
CommandGroup(
|
||||
name: "Other",
|
||||
subcommands: Self.otherCommands()
|
||||
),
|
||||
],
|
||||
// Hidden command to handle plugins on unrecognized input.
|
||||
defaultSubcommand: DefaultCommand.self
|
||||
)
|
||||
|
||||
public static func main() async throws {
|
||||
restoreCursorAtExit()
|
||||
|
||||
#if DEBUG
|
||||
let warning = "Running debug build. Performance may be degraded."
|
||||
let formattedWarning: String
|
||||
if isatty(FileHandle.standardError.fileDescriptor) == 1 {
|
||||
formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n"
|
||||
} else {
|
||||
formattedWarning = "Warning! \(warning)\n"
|
||||
}
|
||||
let warningData = Data(formattedWarning.utf8)
|
||||
FileHandle.standardError.write(warningData)
|
||||
#endif
|
||||
|
||||
let fullArgs = CommandLine.arguments
|
||||
let args = Array(fullArgs.dropFirst())
|
||||
|
||||
do {
|
||||
var command = try Application.parseAsRoot(args)
|
||||
if var asyncCommand = command as? AsyncParsableCommand {
|
||||
try await asyncCommand.run()
|
||||
} else {
|
||||
try command.run()
|
||||
}
|
||||
} catch {
|
||||
// --help/-h on the root command (e.g. `container --help`) is intercepted
|
||||
// by ArgumentParser and lands here.
|
||||
let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help")
|
||||
if fullArgs.count <= 2 && containsHelp {
|
||||
let pluginLoader = try? await createPluginLoader()
|
||||
await Self.printModifiedHelpText(pluginLoader: pluginLoader)
|
||||
return
|
||||
}
|
||||
let errorAsString: String = String(describing: error)
|
||||
if errorAsString.contains("XPC connection error") {
|
||||
let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.")
|
||||
Application.exit(withError: modifiedError)
|
||||
} else {
|
||||
Application.exit(withError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static func createPluginLoader() async throws -> PluginLoader {
|
||||
let installRootPath = CommandLine.executablePath
|
||||
.removingLastComponent()
|
||||
.removingLastComponent()
|
||||
// TODO: Remove when we convert PluginLoader to FilePath.
|
||||
let installRootURL = URL(fileURLWithPath: installRootPath.string)
|
||||
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
|
||||
var directoryExists: ObjCBool = false
|
||||
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
|
||||
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
|
||||
|
||||
// plugins built into the application installed as a macOS app bundle
|
||||
let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins")
|
||||
|
||||
// plugins built into the application installed as a Unix-like application
|
||||
let installRootPluginsPath =
|
||||
installRootPath
|
||||
.appending(FilePath.Component("libexec"))
|
||||
.appending(FilePath.Component("container"))
|
||||
.appending(FilePath.Component("plugins"))
|
||||
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
|
||||
let pluginDirectories = [
|
||||
userPluginsURL,
|
||||
appBundlePluginsURL,
|
||||
installRootPluginsURL,
|
||||
].compactMap { $0 }
|
||||
|
||||
let pluginFactories: [any PluginFactory] = [
|
||||
DefaultPluginFactory(logger: bootstrapLogger),
|
||||
AppBundlePluginFactory(logger: bootstrapLogger),
|
||||
]
|
||||
|
||||
guard let systemHealth = try? await ClientHealthCheck.ping(timeout: .seconds(10)) else {
|
||||
throw ContainerizationError(.timeout, message: "unable to retrieve application data root from API server")
|
||||
}
|
||||
return try PluginLoader(
|
||||
appRoot: systemHealth.appRoot,
|
||||
installRoot: systemHealth.installRoot,
|
||||
logRoot: systemHealth.logRoot,
|
||||
pluginDirectories: pluginDirectories,
|
||||
pluginFactories: pluginFactories,
|
||||
log: bootstrapLogger
|
||||
)
|
||||
}
|
||||
|
||||
/// Load the system configuration using `appRoot` / `installRoot` reported by the
|
||||
/// daemon. `container system start` MUST have previously been run to start the daemon.
|
||||
public static func loadContainerSystemConfig() async throws -> ContainerSystemConfig {
|
||||
let health = try await ClientHealthCheck.ping(timeout: .seconds(10))
|
||||
let appRoot = FilePath(health.appRoot.path(percentEncoded: false))
|
||||
let installRoot = FilePath(health.installRoot.path(percentEncoded: false))
|
||||
return try await ConfigurationLoader.load(
|
||||
configurationFiles: [
|
||||
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
|
||||
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
public func validate() throws {
|
||||
// Not really a "validation", but a cheat to run this before
|
||||
// any of the commands do their business.
|
||||
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
|
||||
if self.logOptions.debug || debugEnvVar != nil {
|
||||
bootstrapLogger.logLevel = .debug
|
||||
}
|
||||
// Ensure we're not running under Rosetta.
|
||||
if try isTranslated() {
|
||||
throw ValidationError(
|
||||
"""
|
||||
`container` is currently running under Rosetta Translation, which could be
|
||||
caused by your terminal application. Please ensure this is turned off.
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func otherCommands() -> [any ParsableCommand.Type] {
|
||||
guard #available(macOS 26, *) else {
|
||||
return [
|
||||
BuilderCommand.self,
|
||||
SystemCommand.self,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
BuilderCommand.self,
|
||||
NetworkCommand.self,
|
||||
SystemCommand.self,
|
||||
]
|
||||
}
|
||||
|
||||
private static func restoreCursorAtExit() {
|
||||
let signalHandler: @convention(c) (Int32) -> Void = { signal in
|
||||
let exitCode = ExitCode(signal + 128)
|
||||
Application.exit(withError: exitCode)
|
||||
}
|
||||
// Termination by Ctrl+C.
|
||||
signal(SIGINT, signalHandler)
|
||||
// Termination using `kill`.
|
||||
signal(SIGTERM, signalHandler)
|
||||
// Normal and explicit exit.
|
||||
atexit {
|
||||
if let progressConfig = try? ProgressConfig() {
|
||||
let progressBar = ProgressBar(config: progressConfig)
|
||||
progressBar.resetCursor()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Application {
|
||||
// Because we support plugins, we need to modify the help text to display
|
||||
// any if we found some.
|
||||
static func printModifiedHelpText(pluginLoader: PluginLoader?) async {
|
||||
let original = Application.helpMessage(for: Application.self)
|
||||
guard let pluginLoader else {
|
||||
print(addGroupSpacing(original))
|
||||
print("\nPLUGINS: not available, run `container system start`")
|
||||
return
|
||||
}
|
||||
let altered = pluginLoader.alterCLIHelpText(original: original)
|
||||
print(addGroupSpacing(altered))
|
||||
}
|
||||
|
||||
private static func addGroupSpacing(_ text: String) -> String {
|
||||
text
|
||||
.replacingOccurrences(of: "\n([A-Z].+SUBCOMMANDS:)", with: "\n\n$1", options: .regularExpression)
|
||||
.replacingOccurrences(of: "\nPLUGINS:", with: "\n\nPLUGINS:")
|
||||
}
|
||||
|
||||
func isTranslated() throws -> Bool {
|
||||
do {
|
||||
return try Sysctl.byName("sysctl.proc_translated") == 1
|
||||
} catch let posixErr as POSIXError {
|
||||
if posixErr.code == .ENOENT {
|
||||
return false
|
||||
}
|
||||
throw posixErr
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerLog
|
||||
import Logging
|
||||
|
||||
public protocol AsyncLoggableCommand: AsyncParsableCommand {
|
||||
var logOptions: Flags.Logging { get }
|
||||
}
|
||||
|
||||
extension AsyncLoggableCommand {
|
||||
/// A shared logger instance configured based on the command's options
|
||||
public var log: Logger {
|
||||
var logger = Logger(label: "container", factory: { _ in StderrLogHandler() })
|
||||
|
||||
logger.logLevel = logOptions.debug ? .debug : .info
|
||||
|
||||
return logger
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerBuild
|
||||
import ContainerImagesServiceClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import NIO
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct BuildCommand: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static var configuration: CommandConfiguration {
|
||||
var config = CommandConfiguration()
|
||||
config.commandName = "build"
|
||||
config.abstract = "Build an image from a Dockerfile or Containerfile"
|
||||
config._superCommandName = "container"
|
||||
config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help"))
|
||||
return config
|
||||
}
|
||||
|
||||
enum ProgressType: String, ExpressibleByArgument {
|
||||
case auto
|
||||
case plain
|
||||
case tty
|
||||
}
|
||||
|
||||
enum SecretType: Decodable {
|
||||
case data(Data)
|
||||
case file(String)
|
||||
}
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: ArgumentHelp("Add the architecture type to the build", valueName: "value"),
|
||||
transform: { val in val.split(separator: ",").map { String($0) } }
|
||||
)
|
||||
var arch: [[String]] = {
|
||||
[[Arch.hostArchitecture().rawValue]]
|
||||
}()
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val"))
|
||||
var buildArg: [String] = []
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden))
|
||||
var cacheIn: [String] = {
|
||||
[]
|
||||
}()
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden))
|
||||
var cacheOut: [String] = {
|
||||
[]
|
||||
}()
|
||||
|
||||
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
|
||||
var cpus: Int64?
|
||||
|
||||
@Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path"))
|
||||
var file: String?
|
||||
|
||||
var dockerfile: String = "-"
|
||||
|
||||
@Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val"))
|
||||
var label: [String] = []
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
|
||||
)
|
||||
var memory: String?
|
||||
|
||||
@Flag(name: .long, help: "Do not use cache")
|
||||
var noCache: Bool = false
|
||||
|
||||
@Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build (format: type=<oci|tar|local>[,dest=])", valueName: "value"))
|
||||
var output: [String] = {
|
||||
["type=oci"]
|
||||
}()
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: ArgumentHelp("Add the OS type to the build", valueName: "value"),
|
||||
transform: { val in val.split(separator: ",").map { String($0) } }
|
||||
)
|
||||
var os: [[String]] = {
|
||||
[["linux"]]
|
||||
}()
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Add the platform to the build (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]",
|
||||
transform: { val in val.split(separator: ",").map { String($0) } }
|
||||
)
|
||||
var platform: [[String]] = [[]]
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Progress type (format: auto|plain|tty)", valueName: "type"))
|
||||
var progress: ProgressType = .auto
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Suppress build output")
|
||||
var quiet: Bool = false
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Set build-time secrets (format: id=<key>[,env=<ENV_VAR>|,src=<local/path>])", valueName: "id=key,..."))
|
||||
var secret: [String] = []
|
||||
|
||||
var secrets: [String: SecretType] = [:]
|
||||
|
||||
@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
|
||||
var targetImageNames: [String] = {
|
||||
[UUID().uuidString.lowercased()]
|
||||
}()
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage"))
|
||||
var target: String = ""
|
||||
|
||||
@Option(name: .long, help: ArgumentHelp("Builder shim vsock port", valueName: "port"))
|
||||
var vsockPort: UInt32 = 8088
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@OptionGroup
|
||||
public var dns: Flags.DNS
|
||||
|
||||
@Argument(help: "Build directory")
|
||||
var contextDir: String = "."
|
||||
|
||||
@Flag(name: .long, help: "Pull latest image")
|
||||
var pull: Bool = false
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
do {
|
||||
let timeout: Duration = .seconds(300)
|
||||
let progressConfig = try ProgressConfig(
|
||||
showTasks: true,
|
||||
showItems: true
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
progress.set(description: "Dialing builder")
|
||||
|
||||
let dnsNameservers = self.dns.nameservers
|
||||
let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in
|
||||
defer {
|
||||
group.cancelAll()
|
||||
}
|
||||
|
||||
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in
|
||||
let client = ContainerClient()
|
||||
while true {
|
||||
do {
|
||||
let fh = try await client.dial(id: "buildkit", port: vsockPort)
|
||||
|
||||
let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
let b = try await Builder(socket: fh, group: threadGroup, logger: log)
|
||||
|
||||
// If this call succeeds, then BuildKit is running.
|
||||
let _ = try await b.info()
|
||||
return b
|
||||
} catch {
|
||||
// If we get here, "Dialing builder" is shown for such a short period
|
||||
// of time that it's invisible to the user.
|
||||
progress.set(tasks: 0)
|
||||
progress.set(totalTasks: 3)
|
||||
|
||||
try await BuilderStart.start(
|
||||
cpus: cpus,
|
||||
memory: memory,
|
||||
log: log,
|
||||
dnsNameservers: dnsNameservers,
|
||||
progressUpdate: progress.handler,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
)
|
||||
|
||||
// wait (seconds) for builder to start listening on vsock
|
||||
try await Task.sleep(for: .seconds(5))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
try await Task.sleep(for: timeout)
|
||||
throw ValidationError(
|
||||
"""
|
||||
Timeout waiting for connection to builder
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
return try await group.next()
|
||||
}
|
||||
|
||||
guard let builder else {
|
||||
throw ValidationError("builder is not running")
|
||||
}
|
||||
|
||||
let buildFileData: Data
|
||||
var ignoreFileData: Data? = nil
|
||||
// Dockerfile should be read from stdin
|
||||
if dockerfile == "-" {
|
||||
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempFile)
|
||||
}
|
||||
|
||||
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to create temporary file")
|
||||
}
|
||||
|
||||
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
|
||||
}
|
||||
|
||||
let bufferSize = 4096
|
||||
while true {
|
||||
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
|
||||
if chunk.isEmpty { break }
|
||||
fileHandle.write(chunk)
|
||||
}
|
||||
try fileHandle.close()
|
||||
buildFileData = try Data(contentsOf: URL(filePath: tempFile.path()))
|
||||
} else {
|
||||
let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore")
|
||||
buildFileData = try Data(contentsOf: URL(filePath: dockerfile))
|
||||
ignoreFileData = try? Data(contentsOf: ignoreFileURL)
|
||||
}
|
||||
|
||||
// BUG: See https://github.com/apple/container/issues/735.
|
||||
// Reject dockerfiles larger than 16kb before attempting to build.
|
||||
// TODO: Remove when #735 was been resolved.
|
||||
let maxDockerfileSize = 16 * 1024 // 16 KiB
|
||||
guard buildFileData.count < maxDockerfileSize else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: """
|
||||
Dockerfile size (\(buildFileData.count) bytes) exceeds the maximum allowed size of \(maxDockerfileSize) bytes. \
|
||||
See https://github.com/apple/container/issues/735.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
let secretsData: [String: Data] = try self.secrets.mapValues { secret in
|
||||
switch secret {
|
||||
case .data(let data):
|
||||
return data
|
||||
case .file(let path):
|
||||
return try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
}
|
||||
|
||||
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
|
||||
let exportPath = systemHealth.appRoot
|
||||
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
|
||||
let buildID = UUID().uuidString
|
||||
let tempURL = exportPath.appendingPathComponent(buildID)
|
||||
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
}
|
||||
|
||||
let imageNames: [String] = try targetImageNames.map { name in
|
||||
let parsedReference = try Reference.parse(name)
|
||||
parsedReference.normalize()
|
||||
return parsedReference.description
|
||||
}
|
||||
|
||||
var terminal: Terminal?
|
||||
switch self.progress {
|
||||
case .tty:
|
||||
terminal = try Terminal(descriptor: STDERR_FILENO)
|
||||
case .auto:
|
||||
terminal = try? Terminal(descriptor: STDERR_FILENO)
|
||||
case .plain:
|
||||
terminal = nil
|
||||
}
|
||||
|
||||
defer { terminal?.tryReset() }
|
||||
|
||||
let exports: [Builder.BuildExport] = try output.map { output in
|
||||
var exp = try Builder.BuildExport(from: output)
|
||||
if exp.destination == nil {
|
||||
exp.destination = tempURL.appendingPathComponent("out.tar")
|
||||
}
|
||||
return exp
|
||||
}
|
||||
|
||||
try await withThrowingTaskGroup(of: Void.self) { [terminal] group in
|
||||
defer {
|
||||
group.cancelAll()
|
||||
}
|
||||
group.addTask {
|
||||
let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2])
|
||||
for await sig in handler.signals {
|
||||
throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)")
|
||||
}
|
||||
}
|
||||
let platforms: Set<Platform> = try {
|
||||
var results: Set<Platform> = []
|
||||
for platform in (self.platform.flatMap { $0 }) {
|
||||
guard let p = try? Platform(from: platform) else {
|
||||
throw ValidationError("invalid platform specified \(platform)")
|
||||
}
|
||||
results.insert(p)
|
||||
}
|
||||
|
||||
if !results.isEmpty {
|
||||
return results
|
||||
}
|
||||
|
||||
if let envPlatform = try DefaultPlatform.fromEnvironment(log: log) {
|
||||
return [envPlatform]
|
||||
}
|
||||
|
||||
for o in (self.os.flatMap { $0 }) {
|
||||
for a in (self.arch.flatMap { $0 }) {
|
||||
guard let platform = try? Platform(from: "\(o)/\(a)") else {
|
||||
throw ValidationError("invalid os/architecture combination \(o)/\(a)")
|
||||
}
|
||||
results.insert(platform)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}()
|
||||
group.addTask {
|
||||
[
|
||||
terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log,
|
||||
] in
|
||||
let config = Builder.BuildConfig(
|
||||
buildID: buildID,
|
||||
contentStore: RemoteContentStoreClient(),
|
||||
buildArgs: buildArg,
|
||||
secrets: secretsData,
|
||||
contextDir: contextDir,
|
||||
dockerfile: buildFileData,
|
||||
dockerignore: ignoreFileData,
|
||||
labels: label,
|
||||
noCache: noCache,
|
||||
platforms: [Platform](platforms),
|
||||
terminal: terminal,
|
||||
tags: imageNames,
|
||||
target: target,
|
||||
quiet: quiet,
|
||||
exports: exports,
|
||||
cacheIn: cacheIn,
|
||||
cacheOut: cacheOut,
|
||||
pull: pull,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
)
|
||||
progress.finish()
|
||||
|
||||
try await builder.build(config)
|
||||
|
||||
let unpackProgressConfig = try ProgressConfig(
|
||||
description: "Unpacking built image",
|
||||
itemsName: "entries",
|
||||
showTasks: exports.count > 1,
|
||||
totalTasks: exports.count
|
||||
)
|
||||
let unpackProgress = ProgressBar(config: unpackProgressConfig)
|
||||
defer {
|
||||
unpackProgress.finish()
|
||||
}
|
||||
unpackProgress.start()
|
||||
|
||||
var finalMessage = imageNames.joined(separator: "\n")
|
||||
let taskManager = ProgressTaskCoordinator()
|
||||
// Currently, only a single export can be specified.
|
||||
for exp in exports {
|
||||
unpackProgress.add(tasks: 1)
|
||||
let unpackTask = await taskManager.startTask()
|
||||
switch exp.type {
|
||||
case "oci":
|
||||
try Task.checkCancellation()
|
||||
guard let dest = exp.destination else {
|
||||
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
|
||||
}
|
||||
let result = try await ClientImage.load(from: dest.absolutePath(), force: false)
|
||||
guard result.rejectedMembers.isEmpty else {
|
||||
log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
|
||||
throw ContainerizationError(.internalError, message: "failed to load archive")
|
||||
}
|
||||
for image in result.images {
|
||||
try Task.checkCancellation()
|
||||
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
|
||||
|
||||
// Tag the unpacked image with all requested tags
|
||||
for tagName in imageNames {
|
||||
try Task.checkCancellation()
|
||||
_ = try await image.tag(new: tagName)
|
||||
}
|
||||
}
|
||||
case "tar":
|
||||
guard let dest = exp.destination else {
|
||||
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
|
||||
}
|
||||
let tarURL = tempURL.appendingPathComponent("out.tar")
|
||||
try FileManager.default.moveItem(at: tarURL, to: dest)
|
||||
finalMessage = dest.absolutePath()
|
||||
case "local":
|
||||
guard let dest = exp.destination else {
|
||||
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
|
||||
}
|
||||
let localDir = tempURL.appendingPathComponent("local")
|
||||
|
||||
guard FileManager.default.fileExists(atPath: localDir.path) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "expected local output not found")
|
||||
}
|
||||
try FileManager.default.copyItem(at: localDir, to: dest)
|
||||
finalMessage = dest.absolutePath()
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)")
|
||||
}
|
||||
}
|
||||
await taskManager.finish()
|
||||
unpackProgress.finish()
|
||||
print(finalMessage)
|
||||
}
|
||||
|
||||
try await group.next()
|
||||
}
|
||||
} catch {
|
||||
throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"])
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func validate() throws {
|
||||
// NOTE: Here we check the Dockerfile exists, and set `dockerfile` to point the valid Dockerfile path or stdin
|
||||
guard FileManager.default.fileExists(atPath: contextDir) else {
|
||||
throw ValidationError("context dir does not exist \(contextDir)")
|
||||
}
|
||||
for name in targetImageNames {
|
||||
guard let _ = try? Reference.parse(name) else {
|
||||
throw ValidationError("invalid reference \(name)")
|
||||
}
|
||||
}
|
||||
|
||||
switch file {
|
||||
case "-":
|
||||
dockerfile = "-"
|
||||
break
|
||||
case .some(let filepath):
|
||||
let fileURL = URL(fileURLWithPath: filepath, relativeTo: .currentDirectory())
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
throw ValidationError("dockerfile does not exist \(filepath)")
|
||||
}
|
||||
|
||||
dockerfile = fileURL.path
|
||||
break
|
||||
case .none:
|
||||
guard let defaultDockerfile = try BuildFile.resolvePath(contextDir: contextDir) else {
|
||||
throw ValidationError("dockerfile not found in context dir")
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: defaultDockerfile) else {
|
||||
throw ValidationError("dockerfile does not exist \(defaultDockerfile)")
|
||||
}
|
||||
|
||||
dockerfile = defaultDockerfile
|
||||
break
|
||||
}
|
||||
|
||||
// Parse --secret args
|
||||
for secret in self.secret {
|
||||
let parts = secret.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard parts[0].hasPrefix("id=") else {
|
||||
throw ValidationError("secret must start with id=<key> \(secret)")
|
||||
}
|
||||
let key = String(parts[0].dropFirst(3))
|
||||
guard !key.contains("=") else {
|
||||
throw ValidationError("secret id cannot contain '=' \(key)")
|
||||
}
|
||||
if parts.count == 1 || parts[1].hasPrefix("env=") {
|
||||
let env = parts.count == 1 ? key : String(parts[1].dropFirst(4))
|
||||
// Using getenv/strlen over processInfo.environment to support
|
||||
// non-UTF-8 env var data.
|
||||
guard let ptr = getenv(env) else {
|
||||
throw ValidationError("secret env var doesn't exist \(env)")
|
||||
}
|
||||
self.secrets[key] = .data(Data(bytes: ptr, count: strlen(ptr)))
|
||||
} else if parts[1].hasPrefix("src=") {
|
||||
let path = String(parts[1].dropFirst(4))
|
||||
self.secrets[key] = .file(path)
|
||||
} else {
|
||||
throw ValidationError("secret bad value \(parts[1])")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct BuilderCommand: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let builderResourceDir = "builder"
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "builder",
|
||||
abstract: "Manage an image builder instance",
|
||||
subcommands: [
|
||||
BuilderStart.self,
|
||||
BuilderStatus.self,
|
||||
BuilderStop.self,
|
||||
BuilderDelete.self,
|
||||
])
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct BuilderDelete: AsyncLoggableCommand {
|
||||
public static var configuration: CommandConfiguration {
|
||||
var config = CommandConfiguration()
|
||||
config.commandName = "delete"
|
||||
config.aliases = ["rm"]
|
||||
config.abstract = "Delete the builder container"
|
||||
return config
|
||||
}
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete the builder even if it is running")
|
||||
var force = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
do {
|
||||
let client = ContainerClient()
|
||||
let container = try await client.get(id: "buildkit")
|
||||
if container.status != .stopped {
|
||||
guard force else {
|
||||
throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override")
|
||||
}
|
||||
try await client.stop(id: container.id)
|
||||
}
|
||||
try await client.delete(id: container.id)
|
||||
} catch {
|
||||
if error is ContainerizationError {
|
||||
if (error as? ContainerizationError)?.code == .notFound {
|
||||
return
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerBuild
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import Logging
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct BuilderStart: AsyncLoggableCommand {
|
||||
public static var configuration: CommandConfiguration {
|
||||
var config = CommandConfiguration()
|
||||
config.commandName = "start"
|
||||
config.abstract = "Start the builder container"
|
||||
return config
|
||||
}
|
||||
|
||||
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
|
||||
var cpus: Int64?
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
|
||||
)
|
||||
var memory: String?
|
||||
|
||||
@OptionGroup
|
||||
public var dns: Flags.DNS
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let progressConfig = try ProgressConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
totalTasks: 4
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
try await BuilderStart.start(
|
||||
cpus: self.cpus,
|
||||
memory: self.memory,
|
||||
log: log,
|
||||
dnsNameservers: self.dns.nameservers,
|
||||
dnsDomain: self.dns.domain,
|
||||
dnsSearchDomains: self.dns.searchDomains,
|
||||
dnsOptions: self.dns.options,
|
||||
progressUpdate: progress.handler,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
)
|
||||
progress.finish()
|
||||
}
|
||||
|
||||
static func start(
|
||||
cpus: Int64?,
|
||||
memory: String?,
|
||||
log: Logger,
|
||||
dnsNameservers: [String] = [],
|
||||
dnsDomain: String? = nil,
|
||||
dnsSearchDomains: [String] = [],
|
||||
dnsOptions: [String] = [],
|
||||
progressUpdate: @escaping ProgressUpdateHandler,
|
||||
containerSystemConfig: ContainerSystemConfig,
|
||||
) async throws {
|
||||
await progressUpdate([
|
||||
.setDescription("Fetching BuildKit image"),
|
||||
.setItemsName("blobs"),
|
||||
])
|
||||
let taskManager = ProgressTaskCoordinator()
|
||||
let fetchTask = await taskManager.startTask()
|
||||
|
||||
let builderImage: String = containerSystemConfig.build.image
|
||||
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
|
||||
let exportsMount: String = systemHealth.appRoot
|
||||
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
|
||||
.absolutePath()
|
||||
|
||||
if !FileManager.default.fileExists(atPath: exportsMount) {
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: exportsMount,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
}
|
||||
|
||||
let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
|
||||
var targetEnvVars: [String] = []
|
||||
if let buildkitColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] {
|
||||
targetEnvVars.append("BUILDKIT_COLORS=\(buildkitColors)")
|
||||
}
|
||||
if ProcessInfo.processInfo.environment["NO_COLOR"] != nil {
|
||||
targetEnvVars.append("NO_COLOR=true")
|
||||
}
|
||||
targetEnvVars.sort()
|
||||
|
||||
let defaultBuildCPUs: Int = containerSystemConfig.build.cpus
|
||||
let defaultBuildMemory = containerSystemConfig.build.memory
|
||||
let resources = try Parser.resources(
|
||||
cpus: cpus,
|
||||
memory: memory,
|
||||
defaultCPUs: defaultBuildCPUs,
|
||||
defaultMemory: defaultBuildMemory,
|
||||
)
|
||||
|
||||
let client = ContainerClient()
|
||||
let existingContainer = try? await client.get(id: "buildkit")
|
||||
if let existingContainer {
|
||||
let existingImage = existingContainer.configuration.image.reference
|
||||
let existingResources = existingContainer.configuration.resources
|
||||
let existingEnv = existingContainer.configuration.initProcess.environment
|
||||
let existingDNS = existingContainer.configuration.dns
|
||||
|
||||
let existingManagedEnv = existingEnv.filter { envVar in
|
||||
envVar.hasPrefix("BUILDKIT_COLORS=") || envVar.hasPrefix("NO_COLOR=")
|
||||
}.sorted()
|
||||
|
||||
let envChanged = existingManagedEnv != targetEnvVars
|
||||
|
||||
// Check if we need to recreate the builder due to different image
|
||||
let imageChanged = existingImage != builderImage
|
||||
let cpuChanged = existingResources.cpus != resources.cpus
|
||||
let memChanged = existingResources.memoryInBytes != resources.memoryInBytes
|
||||
let dnsChanged = {
|
||||
if !dnsNameservers.isEmpty {
|
||||
return existingDNS?.nameservers != dnsNameservers
|
||||
}
|
||||
if dnsDomain != nil {
|
||||
return existingDNS?.domain != dnsDomain
|
||||
}
|
||||
if !dnsSearchDomains.isEmpty {
|
||||
return existingDNS?.searchDomains != dnsSearchDomains
|
||||
}
|
||||
if !dnsOptions.isEmpty {
|
||||
return existingDNS?.options != dnsOptions
|
||||
}
|
||||
return false
|
||||
}()
|
||||
|
||||
switch existingContainer.status {
|
||||
case .running:
|
||||
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
|
||||
// If image, mem, cpu, env, and DNS are the same, continue using the existing builder
|
||||
return
|
||||
}
|
||||
// If they changed, stop and delete the existing builder
|
||||
try await client.stop(id: existingContainer.id)
|
||||
try await client.delete(id: existingContainer.id)
|
||||
case .stopped:
|
||||
// If the builder is stopped and matches our requirements, start it
|
||||
// Otherwise, delete it and create a new one
|
||||
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
|
||||
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
|
||||
return
|
||||
}
|
||||
try await client.delete(id: existingContainer.id)
|
||||
case .stopping:
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "builder is stopping, please wait until it is fully stopped before proceeding"
|
||||
)
|
||||
case .unknown:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let useRosetta = containerSystemConfig.build.rosetta
|
||||
let shimArguments = [
|
||||
"--debug",
|
||||
"--vsock",
|
||||
useRosetta ? nil : "--enable-qemu",
|
||||
].compactMap { $0 }
|
||||
|
||||
try ContainerAPIClient.Utility.validEntityName(Builder.builderContainerId)
|
||||
|
||||
let image = try await ClientImage.fetch(
|
||||
reference: builderImage,
|
||||
platform: builderPlatform,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate)
|
||||
)
|
||||
// Unpack fetched image before use
|
||||
await progressUpdate([
|
||||
.setDescription("Unpacking BuildKit image"),
|
||||
.setItemsName("entries"),
|
||||
])
|
||||
|
||||
let unpackTask = await taskManager.startTask()
|
||||
_ = try await image.getCreateSnapshot(
|
||||
platform: builderPlatform,
|
||||
progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)
|
||||
)
|
||||
|
||||
let imageDesc = ImageDescription(
|
||||
reference: builderImage,
|
||||
descriptor: image.descriptor
|
||||
)
|
||||
|
||||
let imageConfig = try await image.config(for: builderPlatform).config
|
||||
var environment = imageConfig?.env ?? []
|
||||
environment.append(contentsOf: targetEnvVars)
|
||||
|
||||
let processConfig = ProcessConfiguration(
|
||||
executable: "/usr/local/bin/container-builder-shim",
|
||||
arguments: shimArguments,
|
||||
environment: environment,
|
||||
workingDirectory: "/",
|
||||
terminal: false,
|
||||
user: .id(uid: 0, gid: 0)
|
||||
)
|
||||
|
||||
var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig)
|
||||
config.resources = resources
|
||||
config.labels = [
|
||||
ResourceLabelKeys.plugin: "builder",
|
||||
ResourceLabelKeys.role: ResourceRoleValues.builder,
|
||||
]
|
||||
config.capAdd = ["ALL"]
|
||||
config.mounts = [
|
||||
.init(
|
||||
type: .tmpfs,
|
||||
source: "",
|
||||
destination: "/run",
|
||||
options: []
|
||||
),
|
||||
.init(
|
||||
type: .virtiofs,
|
||||
source: exportsMount,
|
||||
destination: "/var/lib/container-builder-shim/exports",
|
||||
options: []
|
||||
),
|
||||
]
|
||||
// Enable Rosetta only if the user didn't ask to disable it
|
||||
config.rosetta = useRosetta
|
||||
|
||||
let networkClient = NetworkClient()
|
||||
guard let defaultNetwork = try await networkClient.builtin else {
|
||||
throw ContainerizationError(.invalidState, message: "default network is not present")
|
||||
}
|
||||
config.networks = [
|
||||
AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId))
|
||||
]
|
||||
config.dns = ContainerConfiguration.DNSConfiguration(
|
||||
nameservers: dnsNameservers,
|
||||
domain: dnsDomain,
|
||||
searchDomains: dnsSearchDomains,
|
||||
options: dnsOptions
|
||||
)
|
||||
|
||||
let kernel = try await {
|
||||
await progressUpdate([
|
||||
.setDescription("Fetching kernel"),
|
||||
.setItemsName("binary"),
|
||||
])
|
||||
|
||||
let kernel = try await ClientKernel.getDefaultKernel(for: .current)
|
||||
return kernel
|
||||
}()
|
||||
|
||||
await progressUpdate([
|
||||
.setDescription("Starting BuildKit container")
|
||||
])
|
||||
|
||||
try await client.create(
|
||||
configuration: config,
|
||||
options: .default,
|
||||
kernel: kernel
|
||||
)
|
||||
|
||||
try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager)
|
||||
log.debug("starting BuildKit and BuildKit-shim")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BuildKit Start Helper
|
||||
|
||||
/// Starts the BuildKit process within the container
|
||||
/// This function handles bootstrapping the container and starting the BuildKit process
|
||||
private func startBuildKit(
|
||||
client: ContainerClient,
|
||||
id: String,
|
||||
_ progress: @escaping ProgressUpdateHandler,
|
||||
_ taskManager: ProgressTaskCoordinator? = nil
|
||||
) async throws {
|
||||
do {
|
||||
let io = try ProcessIO.create(
|
||||
tty: false,
|
||||
interactive: false,
|
||||
detach: true
|
||||
)
|
||||
defer { try? io.close() }
|
||||
|
||||
var dynamicEnv: [String: String] = [:]
|
||||
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
|
||||
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
|
||||
}
|
||||
|
||||
let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv)
|
||||
try await process.start()
|
||||
await taskManager?.finish()
|
||||
try io.closeAfterStart()
|
||||
} catch {
|
||||
try? await client.stop(id: id)
|
||||
try? await client.delete(id: id)
|
||||
if error is ContainerizationError {
|
||||
throw error
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct BuilderStatus: AsyncLoggableCommand {
|
||||
public static var configuration: CommandConfiguration {
|
||||
var config = CommandConfiguration()
|
||||
config.commandName = "status"
|
||||
config.abstract = "Display the builder container status"
|
||||
return config
|
||||
}
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the container ID")
|
||||
var quiet = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
do {
|
||||
let client = ContainerClient()
|
||||
let container = try await client.get(id: "buildkit")
|
||||
|
||||
if format == .table && quiet && container.status != .running {
|
||||
return
|
||||
}
|
||||
|
||||
try Output.render(
|
||||
payload: [ManagedContainer(container)],
|
||||
display: [PrintableBuilder(container)],
|
||||
format: format,
|
||||
quiet: quiet
|
||||
)
|
||||
} catch let error as ContainerizationError where error.code == .notFound {
|
||||
try Output.render(payload: [ManagedContainer](), format: format) {
|
||||
quiet ? "" : "builder is not running"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrintableBuilder: ListDisplayable {
|
||||
let snapshot: ContainerSnapshot
|
||||
|
||||
init(_ snapshot: ContainerSnapshot) {
|
||||
self.snapshot = snapshot
|
||||
}
|
||||
|
||||
static var tableHeader: [String] {
|
||||
["ID", "IMAGE", "STATE", "IP", "CPUS", "MEMORY"]
|
||||
}
|
||||
|
||||
var tableRow: [String] {
|
||||
[
|
||||
snapshot.id,
|
||||
snapshot.configuration.image.reference,
|
||||
snapshot.status.rawValue,
|
||||
snapshot.networks.map { $0.ipv4Address.description }.joined(separator: ","),
|
||||
"\(snapshot.configuration.resources.cpus)",
|
||||
"\(snapshot.configuration.resources.memoryInBytes / (1024 * 1024)) MB",
|
||||
]
|
||||
}
|
||||
|
||||
var quietValue: String {
|
||||
snapshot.id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct BuilderStop: AsyncLoggableCommand {
|
||||
public static var configuration: CommandConfiguration {
|
||||
var config = CommandConfiguration()
|
||||
config.commandName = "stop"
|
||||
config.abstract = "Stop the builder container"
|
||||
return config
|
||||
}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
do {
|
||||
let client = ContainerClient()
|
||||
try await client.stop(id: "buildkit")
|
||||
} catch {
|
||||
if error is ContainerizationError {
|
||||
if (error as? ContainerizationError)?.code == .notFound {
|
||||
log.warning("builder is not running")
|
||||
return
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
extension Application {
|
||||
public struct ContainerCopy: AsyncLoggableCommand {
|
||||
enum PathRef {
|
||||
case local(String)
|
||||
case container(id: String, path: String)
|
||||
}
|
||||
|
||||
static func parsePathRef(_ ref: String) throws -> PathRef {
|
||||
let parts = ref.components(separatedBy: ":")
|
||||
switch parts.count {
|
||||
case 1:
|
||||
return .local(ref)
|
||||
case 2 where !parts[0].isEmpty && parts[1].starts(with: "/"):
|
||||
return .container(id: parts[0], path: parts[1])
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid path given: \(ref)")
|
||||
}
|
||||
}
|
||||
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "copy",
|
||||
abstract: "Copy files/folders between a container and the local filesystem",
|
||||
aliases: ["cp"])
|
||||
|
||||
@OptionGroup()
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Source path (container:path or local path)")
|
||||
var source: String
|
||||
|
||||
@Argument(help: "Destination path (container:path or local path)")
|
||||
var destination: String
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let srcRef = try Self.parsePathRef(source)
|
||||
let dstRef = try Self.parsePathRef(destination)
|
||||
|
||||
switch (srcRef, dstRef) {
|
||||
case (.container(let id, let path), .local(let localPath)):
|
||||
let srcPath = FilePath(path)
|
||||
let destPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
|
||||
var isDirectory: ObjCBool = false
|
||||
let exists = FileManager.default.fileExists(atPath: destPath.string, isDirectory: &isDirectory)
|
||||
|
||||
var finalDestPath = destPath
|
||||
if exists && isDirectory.boolValue {
|
||||
guard let lastComponent = srcPath.lastComponent else {
|
||||
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(path)")
|
||||
}
|
||||
finalDestPath = destPath.appending(lastComponent)
|
||||
try await client.copyOut(id: id, source: path, destination: finalDestPath.string)
|
||||
} else if localPath.hasSuffix("/") {
|
||||
try await client.copyOut(id: id, source: path, destination: destPath.string)
|
||||
var resultIsDir: ObjCBool = false
|
||||
if FileManager.default.fileExists(atPath: destPath.string, isDirectory: &resultIsDir),
|
||||
!resultIsDir.boolValue
|
||||
{
|
||||
try? FileManager.default.removeItem(atPath: destPath.string)
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "destination is not a directory: \(localPath)")
|
||||
}
|
||||
} else {
|
||||
try await client.copyOut(id: id, source: path, destination: destPath.string)
|
||||
}
|
||||
print(finalDestPath.string)
|
||||
case (.local(let localPath), .container(let id, let path)):
|
||||
let srcPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
|
||||
var isDirectory: ObjCBool = false
|
||||
|
||||
guard let lastComponent = srcPath.lastComponent else {
|
||||
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(localPath)")
|
||||
}
|
||||
|
||||
guard FileManager.default.fileExists(atPath: srcPath.string, isDirectory: &isDirectory) else {
|
||||
throw ContainerizationError(.notFound, message: "source path does not exist: \(localPath)")
|
||||
}
|
||||
if localPath.hasSuffix("/") && !isDirectory.boolValue {
|
||||
throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)")
|
||||
}
|
||||
|
||||
try await client.copyIn(id: id, source: srcPath.string, destination: path, createParents: true)
|
||||
let printedDest = path.hasSuffix("/") ? "\(id):\(path)\(lastComponent.string)" : "\(id):\(path)"
|
||||
print(printedDest)
|
||||
case (.container, .container):
|
||||
throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported")
|
||||
case (.local, .local):
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "one of source or destination must be a container reference (container_id:path)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ContainerCreate: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a new container")
|
||||
|
||||
@OptionGroup(title: "Process options")
|
||||
var processFlags: Flags.Process
|
||||
|
||||
@OptionGroup(title: "Resource options")
|
||||
var resourceFlags: Flags.Resource
|
||||
|
||||
@OptionGroup(title: "Management options")
|
||||
var managementFlags: Flags.Management
|
||||
|
||||
@OptionGroup(title: "Registry options")
|
||||
var registryFlags: Flags.Registry
|
||||
|
||||
@OptionGroup(title: "Image fetch options")
|
||||
var imageFetchFlags: Flags.ImageFetch
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Image name")
|
||||
var image: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
|
||||
var arguments: [String] = []
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let progressConfig = try ProgressConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
ignoreSmallSize: true,
|
||||
totalTasks: 3
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
let id = Utility.createContainerID(name: self.managementFlags.name)
|
||||
try Utility.validEntityName(id)
|
||||
|
||||
let ck = try await Utility.containerConfigFromFlags(
|
||||
id: id,
|
||||
image: image,
|
||||
arguments: arguments,
|
||||
process: processFlags,
|
||||
management: managementFlags,
|
||||
resource: resourceFlags,
|
||||
registry: registryFlags,
|
||||
imageFetch: imageFetchFlags,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
progressUpdate: progress.handler,
|
||||
log: log
|
||||
)
|
||||
|
||||
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
|
||||
let client = ContainerClient()
|
||||
try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
|
||||
|
||||
if !self.managementFlags.cidfile.isEmpty {
|
||||
let path = self.managementFlags.cidfile
|
||||
let data = id.data(using: .utf8)
|
||||
var attributes = [FileAttributeKey: Any]()
|
||||
attributes[.posixPermissions] = 0o644
|
||||
let success = FileManager.default.createFile(
|
||||
atPath: path,
|
||||
contents: data,
|
||||
attributes: attributes
|
||||
)
|
||||
guard success else {
|
||||
throw ContainerizationError(
|
||||
.internalError, message: "failed to create cidfile at \(path): \(errno)")
|
||||
}
|
||||
}
|
||||
progress.finish()
|
||||
|
||||
print(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerDelete: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete one or more containers",
|
||||
aliases: ["rm"])
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete all containers")
|
||||
var all = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete containers even if they are running")
|
||||
var force = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container IDs")
|
||||
var containerIds: [String] = []
|
||||
|
||||
public func validate() throws {
|
||||
if containerIds.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
|
||||
}
|
||||
if containerIds.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "explicitly supplied container ID(s) conflict with the --all flag"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let force = self.force
|
||||
|
||||
let containers: [String]
|
||||
if all {
|
||||
let filters = ContainerListFilters().withoutMachines()
|
||||
containers = try await client.list(filters: filters).compactMap { c in
|
||||
// Skip running containers when using --all without --force
|
||||
if c.status == .running && !force {
|
||||
return nil
|
||||
}
|
||||
return c.id
|
||||
}
|
||||
} else {
|
||||
containers = Array(Set(containerIds))
|
||||
}
|
||||
|
||||
var errors: [any Error] = []
|
||||
try await withThrowingTaskGroup(of: (any Error)?.self) { group in
|
||||
for container in containers {
|
||||
group.addTask {
|
||||
do {
|
||||
try await client.delete(id: container, force: force)
|
||||
print(container)
|
||||
return nil
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await error in group {
|
||||
if let error {
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.isEmpty {
|
||||
throw AggregateError(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerExec: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "exec",
|
||||
abstract: "Run a new command in a running container")
|
||||
|
||||
@OptionGroup(title: "Process options")
|
||||
var processFlags: Flags.Process
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Run the process and detach from it")
|
||||
var detach = false
|
||||
|
||||
@Argument(help: "Container ID")
|
||||
var containerId: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "New process arguments")
|
||||
var arguments: [String]
|
||||
|
||||
public func run() async throws {
|
||||
var exitCode: Int32 = 127
|
||||
let client = ContainerClient()
|
||||
let container = try await client.get(id: containerId)
|
||||
try ensureRunning(container: container)
|
||||
|
||||
let stdin = self.processFlags.interactive
|
||||
let tty = self.processFlags.tty
|
||||
|
||||
guard let executable = arguments.first else {
|
||||
throw ContainerizationError(.invalidArgument, message: "no command specified for exec")
|
||||
}
|
||||
|
||||
var config = container.configuration.initProcess
|
||||
config.executable = executable
|
||||
config.arguments = [String](self.arguments.dropFirst())
|
||||
config.terminal = tty
|
||||
config.environment.append(
|
||||
contentsOf: try Parser.allEnv(
|
||||
imageEnvs: [],
|
||||
envFiles: self.processFlags.envFile,
|
||||
envs: self.processFlags.env
|
||||
))
|
||||
|
||||
if let cwd = self.processFlags.cwd {
|
||||
config.workingDirectory = cwd
|
||||
}
|
||||
|
||||
let defaultUser = config.user
|
||||
let (user, additionalGroups) = Parser.user(
|
||||
user: processFlags.user, uid: processFlags.uid,
|
||||
gid: processFlags.gid, defaultUser: defaultUser)
|
||||
config.user = user
|
||||
config.supplementalGroups.append(contentsOf: additionalGroups)
|
||||
|
||||
do {
|
||||
let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: self.detach)
|
||||
defer {
|
||||
try? io.close()
|
||||
}
|
||||
|
||||
let process = try await client.createProcess(
|
||||
containerId: container.id,
|
||||
processId: UUID().uuidString.lowercased(),
|
||||
configuration: config,
|
||||
stdio: io.stdio
|
||||
)
|
||||
|
||||
if self.detach {
|
||||
try await process.start()
|
||||
try io.closeAfterStart()
|
||||
print(containerId)
|
||||
return
|
||||
}
|
||||
|
||||
if !self.processFlags.tty {
|
||||
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
|
||||
let log = self.log
|
||||
handler.start {
|
||||
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
|
||||
Darwin.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
exitCode = try await io.handleProcess(process: process, log: log)
|
||||
} catch {
|
||||
if error is ContainerizationError {
|
||||
throw error
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: "failed to exec process \(error)")
|
||||
}
|
||||
throw ArgumentParser.ExitCode(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ContainerExport: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static var configuration: CommandConfiguration {
|
||||
CommandConfiguration(
|
||||
commandName: "export",
|
||||
abstract: "Export a container's filesystem as a tar archive",
|
||||
)
|
||||
}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong, help: "Pathname for the saved container filesystem (defaults to stdout)", completion: .file(),
|
||||
transform: { str in
|
||||
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
|
||||
})
|
||||
var output: String?
|
||||
|
||||
@Argument(help: "container ID")
|
||||
var id: String
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
let archive = tempDir.appendingPathComponent("archive.tar")
|
||||
try await client.export(id: id, archive: archive)
|
||||
|
||||
if output == nil {
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: archive) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to open archive for reading")
|
||||
}
|
||||
let bufferSize = 4096
|
||||
while true {
|
||||
let chunk = fileHandle.readData(ofLength: bufferSize)
|
||||
if chunk.isEmpty { break }
|
||||
FileHandle.standardOutput.write(chunk)
|
||||
}
|
||||
try fileHandle.close()
|
||||
} else {
|
||||
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerInspect: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display information about one or more containers")
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container IDs to inspect")
|
||||
var containerIds: [String]
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let uniqueIds = Set(containerIds)
|
||||
let containers = try await client.list().filter {
|
||||
uniqueIds.contains($0.id)
|
||||
}
|
||||
|
||||
if containers.count != uniqueIds.count {
|
||||
let found = Set(containers.map { $0.id })
|
||||
let missing = uniqueIds.subtracting(found).sorted()
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "container not found: \(missing.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(containers.map { ManagedContainer($0) }, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Darwin
|
||||
|
||||
extension Application {
|
||||
public struct ContainerKill: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "kill",
|
||||
abstract: "Kill or signal one or more running containers")
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Kill or signal all running containers")
|
||||
var all = false
|
||||
|
||||
@Option(name: .shortAndLong, help: "Signal to send to the container(s)")
|
||||
var signal: String = "KILL"
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container IDs")
|
||||
var containerIds: [String] = []
|
||||
|
||||
public func validate() throws {
|
||||
if containerIds.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
|
||||
}
|
||||
if containerIds.count > 0 && all {
|
||||
throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let client = ContainerClient()
|
||||
|
||||
let containers: [String]
|
||||
if self.all {
|
||||
let filters = ContainerListFilters(status: .running).withoutMachines()
|
||||
containers = try await client.list(filters: filters).map { $0.id }
|
||||
} else {
|
||||
containers = containerIds
|
||||
}
|
||||
|
||||
var errors: [any Error] = []
|
||||
for container in containers {
|
||||
do {
|
||||
try await client.kill(id: container, signal: signal)
|
||||
print(container)
|
||||
} catch {
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
if !errors.isEmpty {
|
||||
throw AggregateError(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
import SwiftProtobuf
|
||||
|
||||
extension Application {
|
||||
public struct ContainerList: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List running containers",
|
||||
aliases: ["ls"])
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Include containers that are not running")
|
||||
var all = false
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the container ID")
|
||||
var quiet = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
|
||||
let filters = ContainerListFilters(status: self.all ? nil : .running).withoutMachines()
|
||||
let containers = try await client.list(filters: filters)
|
||||
let items = containers.map { ManagedContainer($0) }
|
||||
try Output.render(payload: items, display: items, format: format, quiet: quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Darwin
|
||||
import Dispatch
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerLogs: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "logs",
|
||||
abstract: "Fetch container logs"
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Display the boot log for the container instead of stdio")
|
||||
var boot: Bool = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Follow log output")
|
||||
var follow: Bool = false
|
||||
|
||||
@Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs")
|
||||
var numLines: Int?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container ID")
|
||||
var containerId: String
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let fhs = try await client.logs(id: containerId)
|
||||
let fileHandle = boot ? fhs[1] : fhs[0]
|
||||
|
||||
try await Self.tail(
|
||||
fh: fileHandle,
|
||||
n: numLines,
|
||||
follow: follow
|
||||
)
|
||||
}
|
||||
|
||||
private static func tail(
|
||||
fh: FileHandle,
|
||||
n: Int?,
|
||||
follow: Bool
|
||||
) async throws {
|
||||
if let n {
|
||||
var buffer = Data()
|
||||
let size = try fh.seekToEnd()
|
||||
var offset = size
|
||||
var lines: [String] = []
|
||||
|
||||
while offset > 0, lines.count < n {
|
||||
let readSize = min(1024, offset)
|
||||
offset -= readSize
|
||||
try fh.seek(toOffset: offset)
|
||||
|
||||
let data = fh.readData(ofLength: Int(readSize))
|
||||
buffer.insert(contentsOf: data, at: 0)
|
||||
|
||||
if let chunk = String(data: buffer, encoding: .utf8) {
|
||||
lines = chunk.components(separatedBy: .newlines)
|
||||
lines = lines.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
lines = Array(lines.suffix(n))
|
||||
for line in lines {
|
||||
print(line)
|
||||
}
|
||||
} else {
|
||||
// Fast path if all they want is the full file.
|
||||
guard let data = try fh.readToEnd() else {
|
||||
// Seems you get nil if it's a zero byte read, or you
|
||||
// try and read from dev/null.
|
||||
return
|
||||
}
|
||||
guard let str = String(data: data, encoding: .utf8) else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to convert container logs to utf8"
|
||||
)
|
||||
}
|
||||
print(str.trimmingCharacters(in: .newlines))
|
||||
}
|
||||
|
||||
fflush(stdout)
|
||||
if follow {
|
||||
setbuf(stdout, nil)
|
||||
try await Self.followFile(fh: fh)
|
||||
}
|
||||
}
|
||||
|
||||
private static func followFile(fh: FileHandle) async throws {
|
||||
_ = try fh.seekToEnd()
|
||||
let stream = AsyncStream<String> { cont in
|
||||
fh.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// Triggers on container restart - can exit here as well
|
||||
do {
|
||||
_ = try fh.seekToEnd() // To continue streaming existing truncated log files
|
||||
} catch {
|
||||
fh.readabilityHandler = nil
|
||||
cont.finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
if let str = String(data: data, encoding: .utf8), !str.isEmpty {
|
||||
var lines = str.components(separatedBy: .newlines)
|
||||
lines = lines.filter { !$0.isEmpty }
|
||||
for line in lines {
|
||||
cont.yield(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await line in stream {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerPrune: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "prune",
|
||||
abstract: "Remove all stopped containers"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let client = ContainerClient()
|
||||
let filters = ContainerListFilters(status: .stopped).withoutMachines()
|
||||
let containersToPrune = try await client.list(filters: filters)
|
||||
|
||||
var prunedContainerIds = [String]()
|
||||
var totalSize: UInt64 = 0
|
||||
|
||||
for container in containersToPrune {
|
||||
do {
|
||||
let actualSize = try await client.diskUsage(id: container.id)
|
||||
totalSize += actualSize
|
||||
try await client.delete(id: container.id)
|
||||
prunedContainerIds.append(container.id)
|
||||
} catch {
|
||||
log.error(
|
||||
"failed to prune container",
|
||||
metadata: [
|
||||
"id": "\(container.id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let formatter = ByteCountFormatter()
|
||||
let freed = formatter.string(fromByteCount: Int64(totalSize))
|
||||
|
||||
for name in prunedContainerIds {
|
||||
print(name)
|
||||
}
|
||||
log.info("Reclaimed \(freed) in disk space")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ContainerRun: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "run",
|
||||
abstract: "Run a container")
|
||||
|
||||
@OptionGroup(title: "Process options")
|
||||
var processFlags: Flags.Process
|
||||
|
||||
@OptionGroup(title: "Resource options")
|
||||
var resourceFlags: Flags.Resource
|
||||
|
||||
@OptionGroup(title: "Management options")
|
||||
var managementFlags: Flags.Management
|
||||
|
||||
@OptionGroup(title: "Registry options")
|
||||
var registryFlags: Flags.Registry
|
||||
|
||||
@OptionGroup(title: "Progress options")
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@OptionGroup(title: "Image fetch options")
|
||||
var imageFetchFlags: Flags.ImageFetch
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Image name")
|
||||
var image: String
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
|
||||
var arguments: [String] = []
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
var exitCode: Int32 = 127
|
||||
let id = Utility.createContainerID(name: self.managementFlags.name)
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
ignoreSmallSize: true,
|
||||
totalTasks: 6
|
||||
)
|
||||
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
try Utility.validEntityName(id)
|
||||
|
||||
// Check if container with id already exists.
|
||||
let client = ContainerClient()
|
||||
let existing = try? await client.get(id: id)
|
||||
guard existing == nil else {
|
||||
throw ContainerizationError(
|
||||
.exists,
|
||||
message: "container with id \(id) already exists"
|
||||
)
|
||||
}
|
||||
|
||||
let ck = try await Utility.containerConfigFromFlags(
|
||||
id: id,
|
||||
image: image,
|
||||
arguments: arguments,
|
||||
process: processFlags,
|
||||
management: managementFlags,
|
||||
resource: resourceFlags,
|
||||
registry: registryFlags,
|
||||
imageFetch: imageFetchFlags,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
progressUpdate: progress.handler,
|
||||
log: log
|
||||
)
|
||||
|
||||
progress.set(description: "Starting container")
|
||||
|
||||
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
|
||||
try await client.create(
|
||||
configuration: ck.0,
|
||||
options: options,
|
||||
kernel: ck.1,
|
||||
initImage: ck.2
|
||||
)
|
||||
|
||||
let detach = self.managementFlags.detach
|
||||
do {
|
||||
let io = try ProcessIO.create(
|
||||
tty: self.processFlags.tty,
|
||||
interactive: self.processFlags.interactive,
|
||||
detach: detach
|
||||
)
|
||||
defer {
|
||||
try? io.close()
|
||||
}
|
||||
|
||||
var dynamicEnv: [String: String] = [:]
|
||||
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
|
||||
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
|
||||
}
|
||||
|
||||
let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv)
|
||||
progress.finish()
|
||||
|
||||
if !self.managementFlags.cidfile.isEmpty {
|
||||
let path = self.managementFlags.cidfile
|
||||
let data = id.data(using: .utf8)
|
||||
var attributes = [FileAttributeKey: Any]()
|
||||
attributes[.posixPermissions] = 0o644
|
||||
let success = FileManager.default.createFile(
|
||||
atPath: path,
|
||||
contents: data,
|
||||
attributes: attributes
|
||||
)
|
||||
guard success else {
|
||||
throw ContainerizationError(
|
||||
.internalError, message: "failed to create cidfile at \(path): \(errno)")
|
||||
}
|
||||
}
|
||||
|
||||
if detach {
|
||||
try await process.start()
|
||||
try io.closeAfterStart()
|
||||
print(id)
|
||||
return
|
||||
}
|
||||
|
||||
if !self.processFlags.tty {
|
||||
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
|
||||
let log = self.log
|
||||
handler.start {
|
||||
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
|
||||
Darwin.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
exitCode = try await io.handleProcess(process: process, log: log)
|
||||
} catch {
|
||||
try? await client.delete(id: id)
|
||||
if error is ContainerizationError {
|
||||
throw error
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: "failed to run container: \(error)")
|
||||
}
|
||||
throw ArgumentParser.ExitCode(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ContainerStart: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "start",
|
||||
abstract: "Start a container")
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Attach stdout/stderr")
|
||||
var attach = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Attach stdin")
|
||||
var interactive = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container ID")
|
||||
var containerId: String
|
||||
|
||||
public func run() async throws {
|
||||
var exitCode: Int32 = 127
|
||||
|
||||
let progressConfig = try ProgressConfig(
|
||||
description: "Starting container"
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
let detach = !self.attach && !self.interactive
|
||||
let client = ContainerClient()
|
||||
let container = try await client.get(id: containerId)
|
||||
|
||||
// Bootstrap and process start are both idempotent and don't fail the second time
|
||||
// around, however not doing an rpc is always faster :). The other bit is we don't
|
||||
// support attach currently, so we can't do `start -a` a second time and have it succeed.
|
||||
if container.status == .running {
|
||||
if !detach {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "attach is currently unsupported on already running containers"
|
||||
)
|
||||
}
|
||||
print(containerId)
|
||||
return
|
||||
}
|
||||
|
||||
for mount in container.configuration.mounts where mount.isVirtiofs {
|
||||
if !FileManager.default.fileExists(atPath: mount.source) {
|
||||
throw ContainerizationError(.invalidState, message: "mount source path '\(mount.source)' does not exist")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let io = try ProcessIO.create(
|
||||
tty: container.configuration.initProcess.terminal,
|
||||
interactive: self.interactive,
|
||||
detach: detach
|
||||
)
|
||||
defer {
|
||||
try? io.close()
|
||||
}
|
||||
|
||||
var env: [String: String] = [:]
|
||||
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
|
||||
env["SSH_AUTH_SOCK"] = sshAuthSock
|
||||
}
|
||||
|
||||
let process = try await client.bootstrap(id: container.id, stdio: io.stdio, dynamicEnv: env)
|
||||
progress.finish()
|
||||
|
||||
if detach {
|
||||
try await process.start()
|
||||
try io.closeAfterStart()
|
||||
print(self.containerId)
|
||||
return
|
||||
}
|
||||
|
||||
exitCode = try await io.handleProcess(process: process, log: log)
|
||||
} catch {
|
||||
try? await client.stop(id: container.id)
|
||||
|
||||
if error is ContainerizationError {
|
||||
throw error
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: "failed to start container: \(error)")
|
||||
}
|
||||
throw ArgumentParser.ExitCode(exitCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ContainerStats: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "stats",
|
||||
abstract: "Display resource usage statistics for containers")
|
||||
|
||||
@Argument(help: "Container ID or name (optional, shows all running containers if not specified)")
|
||||
var containers: [String] = []
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .long, help: "Disable streaming stats and only pull the first result")
|
||||
var noStream = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
if format != .table || noStream {
|
||||
// Static mode - get stats once and exit
|
||||
try await runStatic()
|
||||
} else {
|
||||
// Streaming mode - continuously update like top
|
||||
// Enter alternate screen buffer and hide cursor
|
||||
print("\u{001B}[?1049h\u{001B}[?25l", terminator: "")
|
||||
fflush(stdout)
|
||||
|
||||
defer {
|
||||
// Exit alternate screen buffer and show cursor again
|
||||
print("\u{001B}[?25h\u{001B}[?1049l", terminator: "")
|
||||
fflush(stdout)
|
||||
}
|
||||
|
||||
let containerIds = containers
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
defer { group.cancelAll() }
|
||||
group.addTask {
|
||||
let handler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
|
||||
for await _ in handler.signals {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
group.addTask { [containerIds] in
|
||||
try await Self.runStreaming(containerIds: containerIds)
|
||||
}
|
||||
do {
|
||||
try await group.next()
|
||||
} catch is CancellationError {
|
||||
// Normal exit on signal, defer will restore the terminal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runStatic() async throws {
|
||||
let client = ContainerClient()
|
||||
|
||||
let containersToShow: [ContainerSnapshot]
|
||||
if containers.isEmpty {
|
||||
// No containers specified - show all running containers
|
||||
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
|
||||
} else {
|
||||
// Fetch specified containers by ID
|
||||
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
|
||||
// Validate all specified containers were found
|
||||
for containerId in containers {
|
||||
guard containersToShow.contains(where: { $0.id == containerId }) else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "no such container: \(containerId)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let statsData = try await Self.collectStats(client: client, for: containersToShow)
|
||||
|
||||
try Output.render(payload: statsData.map { $0.stats2 }, format: format) {
|
||||
Self.statsTable(statsData)
|
||||
}
|
||||
}
|
||||
|
||||
private static func runStreaming(containerIds: [String]) async throws {
|
||||
let client = ContainerClient()
|
||||
|
||||
// If containers were specified, validate they all exist upfront
|
||||
if !containerIds.isEmpty {
|
||||
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containerIds))
|
||||
for containerId in containerIds {
|
||||
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "no such container: \(containerId)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearScreen()
|
||||
// Show header right away.
|
||||
print(statsTable([]))
|
||||
|
||||
while true {
|
||||
do {
|
||||
let containersToShow: [ContainerSnapshot]
|
||||
if containerIds.isEmpty {
|
||||
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
|
||||
} else {
|
||||
containersToShow = try await client.list(filters: ContainerListFilters(ids: containerIds))
|
||||
}
|
||||
|
||||
let statsData = try await collectStats(client: client, for: containersToShow)
|
||||
|
||||
// Clear screen and reprint
|
||||
clearScreen()
|
||||
print(statsTable(statsData))
|
||||
|
||||
if statsData.isEmpty {
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
} catch {
|
||||
clearScreen()
|
||||
print("error collecting stats: \(error)")
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct StatsSnapshot {
|
||||
let container: ContainerSnapshot
|
||||
let stats1: ContainerResource.ContainerStats
|
||||
let stats2: ContainerResource.ContainerStats
|
||||
}
|
||||
|
||||
private static func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
|
||||
var snapshots: [StatsSnapshot] = []
|
||||
|
||||
// First sample
|
||||
for container in containers {
|
||||
guard container.status == .running else { continue }
|
||||
do {
|
||||
let stats1 = try await client.stats(id: container.id)
|
||||
snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1))
|
||||
} catch {
|
||||
// Skip containers that error out
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Wait 2 seconds for CPU delta calculation
|
||||
if !snapshots.isEmpty {
|
||||
try await Task.sleep(for: .seconds(2))
|
||||
|
||||
// Second sample
|
||||
for i in 0..<snapshots.count {
|
||||
do {
|
||||
let stats2 = try await client.stats(id: snapshots[i].container.id)
|
||||
snapshots[i] = StatsSnapshot(
|
||||
container: snapshots[i].container,
|
||||
stats1: snapshots[i].stats1,
|
||||
stats2: stats2
|
||||
)
|
||||
} catch {
|
||||
// Keep the original stats if second sample fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
/// Calculate CPU percentage from two stat snapshots
|
||||
/// - Parameters:
|
||||
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
|
||||
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
|
||||
/// - timeDeltaUsec: Time delta between samples in microseconds
|
||||
/// - Returns: CPU percentage where 100% = one fully utilized core
|
||||
static func calculateCPUPercent(
|
||||
cpuUsage1: Duration,
|
||||
cpuUsage2: Duration,
|
||||
timeInterval: Duration
|
||||
) -> Double {
|
||||
let cpuDelta =
|
||||
cpuUsage2 > cpuUsage1
|
||||
? cpuUsage2 - cpuUsage1
|
||||
: .seconds(0)
|
||||
return (cpuDelta / timeInterval) * 100.0
|
||||
}
|
||||
|
||||
static func formatBytes(_ bytes: UInt64) -> String {
|
||||
let kib = 1024.0
|
||||
let mib = kib * 1024.0
|
||||
let gib = mib * 1024.0
|
||||
|
||||
let value = Double(bytes)
|
||||
|
||||
if value >= gib {
|
||||
return String(format: "%.2f GiB", value / gib)
|
||||
} else if value >= mib {
|
||||
return String(format: "%.2f MiB", value / mib)
|
||||
} else {
|
||||
return String(format: "%.2f KiB", value / kib)
|
||||
}
|
||||
}
|
||||
|
||||
private static func statsTable(_ statsData: [StatsSnapshot]) -> String {
|
||||
let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]
|
||||
let notAvailable = "--"
|
||||
var rows = [headerRow]
|
||||
|
||||
for snapshot in statsData {
|
||||
var row = [snapshot.container.id]
|
||||
let stats1 = snapshot.stats1
|
||||
let stats2 = snapshot.stats2
|
||||
|
||||
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
|
||||
let cpuPercent = Self.calculateCPUPercent(
|
||||
cpuUsage1: .microseconds(cpuUsageUsec1),
|
||||
cpuUsage2: .microseconds(cpuUsageUsec2),
|
||||
timeInterval: .seconds(2)
|
||||
)
|
||||
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
||||
row.append(cpuStr)
|
||||
} else {
|
||||
row.append(notAvailable)
|
||||
}
|
||||
|
||||
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(memUsageStr) / \(memLimitStr)")
|
||||
|
||||
let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(netRxStr) / \(netTxStr)")
|
||||
|
||||
let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable
|
||||
row.append("\(blkReadStr) / \(blkWriteStr)")
|
||||
|
||||
let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable
|
||||
row.append(pidsStr)
|
||||
|
||||
rows.append(row)
|
||||
}
|
||||
|
||||
// Always print header, even if no containers
|
||||
return TableOutput(rows: rows).format()
|
||||
}
|
||||
|
||||
private static func clearScreen() {
|
||||
// Move cursor to home position and clear from cursor to end of screen
|
||||
print("\u{001B}[H\u{001B}[J", terminator: "")
|
||||
fflush(stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
extension Application {
|
||||
public struct ContainerStop: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "stop",
|
||||
abstract: "Stop one or more running containers")
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Stop all running containers")
|
||||
var all = false
|
||||
|
||||
@Option(name: .shortAndLong, help: "Signal to send to the containers")
|
||||
var signal: String?
|
||||
|
||||
@Option(name: .shortAndLong, help: "Seconds to wait before killing the containers")
|
||||
var time: Int32 = 5
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container IDs")
|
||||
var containerIds: [String] = []
|
||||
|
||||
public func validate() throws {
|
||||
if containerIds.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
|
||||
}
|
||||
if containerIds.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let client = ContainerClient()
|
||||
|
||||
let containers: [String]
|
||||
if self.all {
|
||||
let filters = ContainerListFilters().withoutMachines()
|
||||
containers = try await client.list(filters: filters).map { $0.id }
|
||||
} else {
|
||||
containers = containerIds
|
||||
}
|
||||
|
||||
let opts = ContainerStopOptions(
|
||||
timeoutInSeconds: self.time,
|
||||
signal: self.signal
|
||||
)
|
||||
try await Self.stopContainers(
|
||||
client: client,
|
||||
containers: containers,
|
||||
stopOptions: opts
|
||||
)
|
||||
}
|
||||
|
||||
static func stopContainers(client: ContainerClient, containers: [String], stopOptions: ContainerStopOptions) async throws {
|
||||
var errors: [any Error] = []
|
||||
await withTaskGroup(of: (any Error)?.self) { group in
|
||||
for container in containers {
|
||||
group.addTask {
|
||||
do {
|
||||
try await client.stop(id: container, opts: stopOptions)
|
||||
print(container)
|
||||
return nil
|
||||
} catch {
|
||||
return error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await error in group {
|
||||
if let error {
|
||||
errors.append(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.isEmpty {
|
||||
throw AggregateError(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerResource
|
||||
import Foundation
|
||||
|
||||
extension ManagedContainer: ListDisplayable {
|
||||
public static var tableHeader: [String] {
|
||||
["ID", "IMAGE", "OS", "ARCH", "STATE", "IP", "CPUS", "MEMORY", "STARTED"]
|
||||
}
|
||||
|
||||
public var tableRow: [String] {
|
||||
[
|
||||
configuration.id,
|
||||
configuration.image.reference,
|
||||
configuration.platform.os,
|
||||
configuration.platform.architecture,
|
||||
status.state.rawValue,
|
||||
status.networks.map { $0.ipv4Address.description }.joined(separator: ","),
|
||||
"\(configuration.resources.cpus)",
|
||||
"\(configuration.resources.memoryInBytes / (1024 * 1024)) MB",
|
||||
status.startedDate?.ISO8601Format() ?? "",
|
||||
]
|
||||
}
|
||||
|
||||
public var quietValue: String { configuration.id }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
static func ensureRunning(container: ContainerSnapshot) throws {
|
||||
if container.status != .running {
|
||||
throw ContainerizationError(.invalidState, message: "container \(container.id) is not running")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPlugin
|
||||
import Darwin
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
struct DefaultCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: nil,
|
||||
shouldDisplay: false
|
||||
)
|
||||
|
||||
@OptionGroup(visibility: .hidden)
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(parsing: .captureForPassthrough)
|
||||
var remaining: [String] = []
|
||||
|
||||
func run() async throws {
|
||||
// See if we have a possible plugin command.
|
||||
let pluginLoader = try? await Application.createPluginLoader()
|
||||
guard let command = remaining.first else {
|
||||
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for edge cases and unknown options to match the behavior in the absence of plugins.
|
||||
if command.isEmpty {
|
||||
throw ValidationError("unknown argument '\(command)'")
|
||||
} else if command.starts(with: "-") {
|
||||
throw ValidationError("unknown option '\(command)'")
|
||||
}
|
||||
|
||||
// Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths)
|
||||
let installRoot = CommandLine.executablePath
|
||||
.removingLastComponent()
|
||||
.removingLastComponent()
|
||||
|
||||
// TODO: Remove when we convert PluginLoader to FilePath
|
||||
let installRootURL = URL(fileURLWithPath: installRoot.string)
|
||||
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
|
||||
let installRootPluginsPath =
|
||||
installRoot
|
||||
.appending(FilePath.Component("libexec"))
|
||||
.appending(FilePath.Component("container"))
|
||||
.appending(FilePath.Component("plugins"))
|
||||
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
|
||||
let hintPaths = [userPluginsURL, installRootPluginsURL]
|
||||
.map { $0.appendingPathComponent(command).path(percentEncoded: false) }
|
||||
.joined(separator: "\n - ")
|
||||
|
||||
// If plugin loader couldn't be created, the system/APIServer likely isn't running.
|
||||
if pluginLoader == nil {
|
||||
throw ValidationError(
|
||||
"""
|
||||
Plugins are unavailable. Start the container system services and retry:
|
||||
|
||||
container system start
|
||||
|
||||
Check to see that the plugin exists under:
|
||||
- \(hintPaths)
|
||||
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else {
|
||||
throw ValidationError(
|
||||
"""
|
||||
Plugin 'container-\(command)' not found.
|
||||
|
||||
- If system services are not running, start them with: container system start
|
||||
- If the plugin isn't installed, ensure it exists under:
|
||||
|
||||
Check to see that the plugin exists under:
|
||||
- \(hintPaths)
|
||||
|
||||
"""
|
||||
)
|
||||
}
|
||||
// Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals.
|
||||
Self.resetSignalsForPluginExec()
|
||||
// Exec performs execvp (with no fork).
|
||||
try plugin.exec(args: remaining)
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultCommand {
|
||||
// Exposed for tests to verify signal reset semantics.
|
||||
static func resetSignalsForPluginExec() {
|
||||
signal(SIGINT, SIG_DFL)
|
||||
signal(SIGTERM, SIG_DFL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Flags.Progress {
|
||||
/// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY.
|
||||
private func resolvedProgress() -> ProgressType {
|
||||
switch progress {
|
||||
case .auto:
|
||||
return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain
|
||||
case .none, .ansi, .plain, .color:
|
||||
return progress
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `ProgressConfig` based on the selected progress type.
|
||||
///
|
||||
/// For `.none`, progress updates are disabled. For `.ansi`, the given parameters
|
||||
/// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish)
|
||||
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
|
||||
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
|
||||
/// For `.auto`, the type is resolved by checking whether stderr is a TTY.
|
||||
public func makeConfig(
|
||||
description: String = "",
|
||||
itemsName: String = "it",
|
||||
showTasks: Bool = false,
|
||||
showItems: Bool = false,
|
||||
showSpeed: Bool = true,
|
||||
ignoreSmallSize: Bool = false,
|
||||
totalTasks: Int? = nil
|
||||
) throws -> ProgressConfig {
|
||||
let resolved = resolvedProgress()
|
||||
switch resolved {
|
||||
case .none:
|
||||
return try ProgressConfig(disableProgressUpdates: true)
|
||||
case .ansi, .plain, .color:
|
||||
let isPlain = resolved == .plain
|
||||
let outputMode: ProgressConfig.OutputMode
|
||||
switch resolved {
|
||||
case .plain: outputMode = .plain
|
||||
case .color: outputMode = .color
|
||||
default: outputMode = .ansi
|
||||
}
|
||||
return try ProgressConfig(
|
||||
description: description,
|
||||
itemsName: itemsName,
|
||||
showSpinner: !isPlain,
|
||||
showTasks: showTasks,
|
||||
showItems: showItems,
|
||||
showSpeed: showSpeed,
|
||||
ignoreSmallSize: ignoreSmallSize,
|
||||
totalTasks: totalTasks,
|
||||
clearOnFinish: !isPlain,
|
||||
outputMode: outputMode
|
||||
)
|
||||
case .auto:
|
||||
fatalError("unreachable: .auto should have been resolved")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
struct HelpCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "help",
|
||||
shouldDisplay: false
|
||||
)
|
||||
|
||||
@OptionGroup(visibility: .hidden)
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(parsing: .captureForPassthrough)
|
||||
var subcommandPath: [String] = []
|
||||
|
||||
func run() async throws {
|
||||
if subcommandPath.isEmpty {
|
||||
let pluginLoader = try? await Application.createPluginLoader()
|
||||
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
|
||||
return
|
||||
}
|
||||
guard let target = Self.resolveSubcommand(path: subcommandPath) else {
|
||||
throw ValidationError("unknown command '\(subcommandPath.joined(separator: " "))'")
|
||||
}
|
||||
print(Application.helpMessage(for: target))
|
||||
}
|
||||
|
||||
static func resolveSubcommand(path: [String]) -> ParsableCommand.Type? {
|
||||
var current: ParsableCommand.Type = Application.self
|
||||
for name in path {
|
||||
guard let next = childSubcommands(of: current).first(where: { matches($0, name: name) }) else {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
private static func childSubcommands(of command: ParsableCommand.Type) -> [ParsableCommand.Type] {
|
||||
var all = command.configuration.subcommands
|
||||
for group in command.configuration.groupedSubcommands {
|
||||
all.append(contentsOf: group.subcommands)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
private static func matches(_ command: ParsableCommand.Type, name: String) -> Bool {
|
||||
let cfg = command.configuration
|
||||
if cfg.commandName == name { return true }
|
||||
return cfg.aliases.contains(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct ImageCommand: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "image",
|
||||
abstract: "Manage images",
|
||||
subcommands: [
|
||||
ImageDelete.self,
|
||||
ImageInspect.self,
|
||||
ImageList.self,
|
||||
ImageLoad.self,
|
||||
ImagePrune.self,
|
||||
ImagePull.self,
|
||||
ImagePush.self,
|
||||
ImageSave.self,
|
||||
ImageTag.self,
|
||||
],
|
||||
aliases: ["i"]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
extension Application {
|
||||
public struct RemoveImageOptions: ParsableArguments {
|
||||
public init() {}
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete all images")
|
||||
var all: Bool = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Ignore errors for images that are not found")
|
||||
var force: Bool = false
|
||||
|
||||
@Argument
|
||||
var images: [String] = []
|
||||
}
|
||||
|
||||
struct DeleteImageImplementation {
|
||||
static func validate(options: RemoveImageOptions) throws {
|
||||
if options.images.count == 0 && !options.all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no images specified and --all not supplied")
|
||||
}
|
||||
if options.images.count > 0 && options.all {
|
||||
throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag")
|
||||
}
|
||||
}
|
||||
|
||||
static func removeImage(options: RemoveImageOptions, containerSystemConfig: ContainerSystemConfig, log: Logger) async throws {
|
||||
let (found, notFound) = try await {
|
||||
if options.all {
|
||||
let found = try await ClientImage.list()
|
||||
let notFound: [String] = []
|
||||
return (found, notFound)
|
||||
}
|
||||
return try await ClientImage.get(names: options.images, containerSystemConfig: containerSystemConfig)
|
||||
}()
|
||||
var failures: [String] = options.force ? [] : notFound
|
||||
var didDeleteAnyImage = false
|
||||
for image in found {
|
||||
guard
|
||||
!Utility.isInfraImage(
|
||||
name: image.reference,
|
||||
builderImage: containerSystemConfig.build.image,
|
||||
initImage: containerSystemConfig.vminit.image
|
||||
)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
|
||||
print(image.reference)
|
||||
didDeleteAnyImage = true
|
||||
} catch {
|
||||
log.error("failed to delete \(image.reference): \(error)")
|
||||
failures.append(image.reference)
|
||||
}
|
||||
}
|
||||
let (_, size) = try await ClientImage.cleanUpOrphanedBlobs()
|
||||
let formatter = ByteCountFormatter()
|
||||
let freed = formatter.string(fromByteCount: Int64(size))
|
||||
|
||||
if didDeleteAnyImage {
|
||||
log.info("Reclaimed \(freed) in disk space")
|
||||
}
|
||||
if failures.count > 0 {
|
||||
throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ImageDelete: AsyncLoggableCommand {
|
||||
@OptionGroup
|
||||
var options: RemoveImageOptions
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete one or more images",
|
||||
aliases: ["rm"])
|
||||
|
||||
public init() {}
|
||||
|
||||
public func validate() throws {
|
||||
try DeleteImageImplementation.validate(options: options)
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ImageInspect: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display information about one or more images")
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Images to inspect")
|
||||
var images: [String]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let uniqueNames = Set(images)
|
||||
let result = try await ClientImage.get(
|
||||
names: Array(uniqueNames), containerSystemConfig: containerSystemConfig
|
||||
)
|
||||
|
||||
if !result.error.isEmpty {
|
||||
let missing = result.error.sorted()
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "image not found: \(missing.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
|
||||
var printable: [ImageResource] = []
|
||||
for image in result.images {
|
||||
guard
|
||||
!Utility.isInfraImage(
|
||||
name: image.reference,
|
||||
builderImage: containerSystemConfig.build.image,
|
||||
initImage: containerSystemConfig.vminit.image
|
||||
)
|
||||
else { continue }
|
||||
printable.append(
|
||||
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(printable, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ImageList: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List images",
|
||||
aliases: ["ls"])
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the image name")
|
||||
var quiet = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Verbose output")
|
||||
var verbose = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public mutating func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
try Self.validate(quiet: quiet, verbose: verbose)
|
||||
|
||||
var images = try await ClientImage.list().filter { img in
|
||||
!Utility.isInfraImage(name: img.reference, builderImage: containerSystemConfig.build.image, initImage: containerSystemConfig.vminit.image)
|
||||
}
|
||||
images.sort { $0.reference < $1.reference }
|
||||
|
||||
// Quiet mode prints references directly and skips the more expensive
|
||||
// per-image manifest resolution.
|
||||
if quiet && format == .table {
|
||||
for image in images {
|
||||
let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig)
|
||||
print(processedReferenceString)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let resources = try await Self.buildResources(images: images, containerSystemConfig: containerSystemConfig)
|
||||
|
||||
try Output.render(payload: resources, format: format) {
|
||||
if verbose {
|
||||
return Output.renderTable(resources.flatMap { VerboseImageRow.rows(for: $0) })
|
||||
}
|
||||
return Output.renderTable(resources)
|
||||
}
|
||||
}
|
||||
|
||||
private static func validate(quiet: Bool, verbose: Bool) throws {
|
||||
if quiet && verbose {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together")
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the resource for each image, denormalizing the reference so the
|
||||
/// display name omits the default registry.
|
||||
private static func buildResources(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageResource] {
|
||||
var resources: [ImageResource] = []
|
||||
for image in images {
|
||||
resources.append(
|
||||
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
|
||||
)
|
||||
}
|
||||
return resources
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single row of the verbose image listing — one per platform variant.
|
||||
private struct VerboseImageRow: ListDisplayable {
|
||||
let name: String
|
||||
let tag: String
|
||||
let indexDigest: String
|
||||
let os: String
|
||||
let arch: String
|
||||
let variant: String
|
||||
let fullSize: String
|
||||
let created: String
|
||||
let manifestDigest: String
|
||||
|
||||
static var tableHeader: [String] {
|
||||
["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"]
|
||||
}
|
||||
|
||||
var tableRow: [String] {
|
||||
[name, tag, indexDigest, os, arch, variant, fullSize, created, manifestDigest]
|
||||
}
|
||||
|
||||
var quietValue: String {
|
||||
name
|
||||
}
|
||||
|
||||
/// Flattens an ImageResource into one verbose image row entry per platform variant.
|
||||
static func rows(for resource: ImageResource) -> [VerboseImageRow] {
|
||||
let formatter = ByteCountFormatter()
|
||||
let reference = try? ContainerizationOCI.Reference.parse(resource.displayReference)
|
||||
let name = reference?.name ?? resource.displayReference
|
||||
let tag = reference?.tag ?? "<none>"
|
||||
let indexDigest = Utility.trimDigest(digest: resource.configuration.descriptor.digest)
|
||||
return
|
||||
resource.variants
|
||||
// Skip attestation manifests, which use the `unknown/unknown` platform.
|
||||
.filter { !($0.platform.os == "unknown" && $0.platform.architecture == "unknown") }
|
||||
.map { variant in
|
||||
VerboseImageRow(
|
||||
name: name,
|
||||
tag: tag,
|
||||
indexDigest: indexDigest,
|
||||
os: variant.platform.os,
|
||||
arch: variant.platform.architecture,
|
||||
variant: variant.platform.variant ?? "",
|
||||
fullSize: formatter.string(fromByteCount: variant.size),
|
||||
created: variant.config.created ?? "",
|
||||
manifestDigest: Utility.trimDigest(digest: variant.digest)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ImageLoad: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "load",
|
||||
abstract: "Load images from an OCI compatible tar archive"
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong, help: "Path to the image tar archive", completion: .file(),
|
||||
transform: { str in
|
||||
FilePathOps.absolutePath(FilePath(str))
|
||||
})
|
||||
var input: FilePath?
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")
|
||||
public var force = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempFile)
|
||||
}
|
||||
|
||||
// Read from stdin; otherwise read from the input file
|
||||
let resolvedPath: FilePath
|
||||
if let input {
|
||||
guard FileManager.default.fileExists(atPath: input.string) else {
|
||||
log.error("file does not exist", metadata: ["path": "\(input)"])
|
||||
Application.exit(withError: ArgumentParser.ExitCode(1))
|
||||
}
|
||||
resolvedPath = input
|
||||
} else {
|
||||
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to create temporary file")
|
||||
}
|
||||
|
||||
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
|
||||
}
|
||||
|
||||
let bufferSize = 4096
|
||||
while true {
|
||||
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
|
||||
if chunk.isEmpty { break }
|
||||
fileHandle.write(chunk)
|
||||
}
|
||||
try fileHandle.close()
|
||||
resolvedPath = FilePath(tempFile.path())
|
||||
}
|
||||
|
||||
let progressConfig = try ProgressConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
totalTasks: 2
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
progress.set(description: "Loading tar archive")
|
||||
let result = try await ClientImage.load(
|
||||
from: resolvedPath.string,
|
||||
force: force)
|
||||
if !result.rejectedMembers.isEmpty {
|
||||
log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
|
||||
}
|
||||
|
||||
let taskManager = ProgressTaskCoordinator()
|
||||
let unpackTask = await taskManager.startTask()
|
||||
progress.set(description: "Unpacking image")
|
||||
progress.set(itemsName: "entries")
|
||||
for image in result.images {
|
||||
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
|
||||
}
|
||||
await taskManager.finish()
|
||||
progress.finish()
|
||||
for image in result.images {
|
||||
print(image.reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct ImagePrune: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "prune",
|
||||
abstract: "Remove unused or all images")
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Remove all unused images, not just dangling ones")
|
||||
var all: Bool = false
|
||||
|
||||
public func run() async throws {
|
||||
let allImages = try await ClientImage.list()
|
||||
|
||||
let imagesToPrune: [ClientImage]
|
||||
if all {
|
||||
// Find all images not used by any container
|
||||
let client = ContainerClient()
|
||||
let containers = try await client.list()
|
||||
var imagesInUse = Set<String>()
|
||||
for container in containers {
|
||||
imagesInUse.insert(container.configuration.image.reference)
|
||||
}
|
||||
imagesToPrune = allImages.filter { image in
|
||||
!imagesInUse.contains(image.reference)
|
||||
}
|
||||
} else {
|
||||
// Find dangling images (images with no tag)
|
||||
imagesToPrune = allImages.filter { image in
|
||||
!hasTag(image.reference)
|
||||
}
|
||||
}
|
||||
|
||||
var prunedImages = [String]()
|
||||
|
||||
for image in imagesToPrune {
|
||||
do {
|
||||
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
|
||||
prunedImages.append(image.reference)
|
||||
} catch {
|
||||
log.error(
|
||||
"failed to prune image",
|
||||
metadata: [
|
||||
"ref": "\(image.reference)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let (deletedDigests, size) = try await ClientImage.cleanUpOrphanedBlobs()
|
||||
|
||||
for image in imagesToPrune {
|
||||
print("untagged \(image.reference)")
|
||||
}
|
||||
for digest in deletedDigests {
|
||||
print("deleted \(digest)")
|
||||
}
|
||||
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
let freed = formatter.string(fromByteCount: Int64(size))
|
||||
log.info("Reclaimed \(freed) in disk space")
|
||||
}
|
||||
|
||||
private func hasTag(_ reference: String) -> Bool {
|
||||
do {
|
||||
let ref = try ContainerizationOCI.Reference.parse(reference)
|
||||
return ref.tag != nil && !ref.tag!.isEmpty
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ImagePull: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "pull",
|
||||
abstract: "Pull an image"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
var registry: Flags.Registry
|
||||
|
||||
@OptionGroup
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@OptionGroup
|
||||
var imageFetchFlags: Flags.ImageFetch
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Limit the pull to the specified architecture"
|
||||
)
|
||||
var arch: String?
|
||||
|
||||
@Option(
|
||||
help: "Limit the pull to the specified OS"
|
||||
)
|
||||
var os: String?
|
||||
|
||||
@Option(
|
||||
help: "Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
|
||||
)
|
||||
var platform: String?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument var reference: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public init(platform: String? = nil, scheme: String = "auto", reference: String) {
|
||||
self.logOptions = Flags.Logging()
|
||||
self.registry = Flags.Registry(scheme: scheme)
|
||||
self.platform = platform
|
||||
self.reference = reference
|
||||
}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
|
||||
|
||||
let scheme = try RequestScheme(registry.scheme)
|
||||
|
||||
let processedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig)
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
ignoreSmallSize: true,
|
||||
totalTasks: 2
|
||||
)
|
||||
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
progress.set(description: "Fetching image")
|
||||
progress.set(itemsName: "blobs")
|
||||
let taskManager = ProgressTaskCoordinator()
|
||||
let fetchTask = await taskManager.startTask()
|
||||
let image = try await ClientImage.pull(
|
||||
reference: processedReference, platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig,
|
||||
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler),
|
||||
maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads
|
||||
)
|
||||
|
||||
progress.set(description: "Unpacking image")
|
||||
progress.set(itemsName: "entries")
|
||||
let unpackTask = await taskManager.startTask()
|
||||
try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
|
||||
await taskManager.finish()
|
||||
progress.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ImagePush: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "push",
|
||||
abstract: "Push an image"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
var registry: Flags.Registry
|
||||
|
||||
@OptionGroup
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Limit the push to the specified architecture"
|
||||
)
|
||||
var arch: String?
|
||||
|
||||
@Option(
|
||||
help: "Limit the push to the specified OS"
|
||||
)
|
||||
var os: String?
|
||||
|
||||
@Option(help: "Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]")
|
||||
var platform: String?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument var reference: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
|
||||
|
||||
let scheme = try RequestScheme(registry.scheme)
|
||||
let image = try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig)
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
description: "Pushing image \(image.reference)",
|
||||
itemsName: "blobs",
|
||||
showItems: true,
|
||||
showSpeed: false,
|
||||
ignoreSmallSize: true
|
||||
)
|
||||
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
_ = try await image.push(platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
|
||||
progress.finish()
|
||||
print(image.reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationOCI
|
||||
|
||||
extension ImageResource: ListDisplayable {
|
||||
public static var tableHeader: [String] {
|
||||
["NAME", "TAG", "DIGEST"]
|
||||
}
|
||||
|
||||
public var tableRow: [String] {
|
||||
// `displayReference` is already denormalized by the caller.
|
||||
let reference = try? ContainerizationOCI.Reference.parse(displayReference)
|
||||
return [
|
||||
reference?.name ?? displayReference,
|
||||
reference?.tag ?? "<none>",
|
||||
Utility.trimDigest(digest: configuration.descriptor.digest),
|
||||
]
|
||||
}
|
||||
|
||||
public var quietValue: String {
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct ImageSave: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "save",
|
||||
abstract: "Save one or more images as an OCI compatible tar archive"
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Architecture for the saved image"
|
||||
)
|
||||
var arch: String?
|
||||
|
||||
@Option(
|
||||
help: "OS for the saved image"
|
||||
)
|
||||
var os: String?
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong, help: "Pathname for the saved image", completion: .file(),
|
||||
transform: { str in
|
||||
FilePathOps.absolutePath(FilePath(str))
|
||||
})
|
||||
var output: FilePath?
|
||||
|
||||
@Option(
|
||||
help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
|
||||
)
|
||||
var platform: String?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument var references: [String]
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
|
||||
|
||||
let progressConfig = try ProgressConfig(
|
||||
description: "Saving image(s)"
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
var images: [ImageDescription] = []
|
||||
for reference in references {
|
||||
do {
|
||||
images.append(try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig).description)
|
||||
} catch {
|
||||
log.error("failed to get image for reference \(reference): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
guard images.count == references.count else {
|
||||
throw ContainerizationError(.invalidArgument, message: "failed to save image(s)")
|
||||
}
|
||||
|
||||
if let p {
|
||||
for (reference, description) in zip(references, images) {
|
||||
let image = ClientImage(description: description)
|
||||
do {
|
||||
_ = try await image.manifest(for: p)
|
||||
} catch {
|
||||
var available: [String] = []
|
||||
if let index = try? await image.index() {
|
||||
available = index.manifests
|
||||
.compactMap { $0.platform?.description }
|
||||
.filter { $0 != "unknown/unknown" }
|
||||
}
|
||||
let availableStr = available.isEmpty ? "none" : available.joined(separator: ", ")
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "image \(reference) has no content for platform \(p.description); available platforms: \(availableStr)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write to stdout; otherwise write to the output file
|
||||
if let output {
|
||||
try await ClientImage.save(references: references, out: output.string, platform: p, containerSystemConfig: containerSystemConfig)
|
||||
} else {
|
||||
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: tempFile)
|
||||
}
|
||||
|
||||
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to create temporary file")
|
||||
}
|
||||
|
||||
try await ClientImage.save(references: references, out: tempFile.path(), platform: p, containerSystemConfig: containerSystemConfig)
|
||||
|
||||
guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else {
|
||||
throw ContainerizationError(.internalError, message: "unable to open temporary file for reading")
|
||||
}
|
||||
|
||||
let bufferSize = 4096
|
||||
while true {
|
||||
let chunk = fileHandle.readData(ofLength: bufferSize)
|
||||
if chunk.isEmpty { break }
|
||||
FileHandle.standardOutput.write(chunk)
|
||||
}
|
||||
try fileHandle.close()
|
||||
}
|
||||
|
||||
progress.finish()
|
||||
for reference in references {
|
||||
if output == nil {
|
||||
// stdout is carrying the OCI archive in this branch, so the
|
||||
// saved-reference list goes to stderr via the logger. Printing
|
||||
// it to stdout appends non-archive bytes after the tar EOF and
|
||||
// corrupts the stream for redirection and pipelines (#1801).
|
||||
log.info("\(reference)")
|
||||
} else {
|
||||
print(reference)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
|
||||
extension Application {
|
||||
public struct ImageTag: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "tag",
|
||||
abstract: "Create a new reference for an existing image")
|
||||
|
||||
@Argument(help: "The existing image reference (format: image-name[:tag])")
|
||||
var source: String
|
||||
|
||||
@Argument(help: "The new image reference")
|
||||
var target: String
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig)
|
||||
let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig)
|
||||
try await existing.tag(new: targetReference)
|
||||
print(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// A type that can be rendered as a table row or quiet-mode output.
|
||||
///
|
||||
/// Conformers provide the column headers, row values, and a primary identifier
|
||||
/// for quiet mode. JSON encoding is handled separately by each command using
|
||||
/// its own data model.
|
||||
public protocol ListDisplayable {
|
||||
/// Column headers for table output (e.g., `["ID", "IMAGE", "STATE"]`).
|
||||
static var tableHeader: [String] { get }
|
||||
/// The values for each column, matching the order of ``tableHeader``.
|
||||
var tableRow: [String] { get }
|
||||
/// The primary identifier shown in `--quiet` mode (typically ID or name).
|
||||
var quietValue: String { get }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
public enum ListFormat: String, CaseIterable, ExpressibleByArgument, Sendable {
|
||||
case json
|
||||
case table
|
||||
case yaml
|
||||
case toml
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import Virtualization
|
||||
|
||||
/// Pre-flight checks for container machine settings that depend on host capabilities.
|
||||
enum MachineCapabilities {
|
||||
/// Throws when the host can't run a VM with `isNestedVirtualizationEnabled = true`.
|
||||
/// Apple Silicon M3+ with macOS 15+ is required.
|
||||
static func requireNestedVirtualizationSupported() throws {
|
||||
guard VZGenericPlatformConfiguration.isNestedVirtualizationSupported else {
|
||||
throw ContainerizationError(
|
||||
.unsupported,
|
||||
message: "nested virtualization is not supported on this host (requires Apple Silicon M3+ and macOS 15+)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "machine",
|
||||
abstract: "Manage container machines",
|
||||
discussion: """
|
||||
EXAMPLES:
|
||||
List available images and create a container machine:
|
||||
$ container machine create alpine:3.22 --name my-machine
|
||||
|
||||
Run commands in the container machine:
|
||||
$ container machine run -n my-machine uname
|
||||
$ container machine run -n my-machine -- cat /proc/cpuinfo
|
||||
|
||||
Change the container machine configuration (takes effect after restart):
|
||||
$ container machine set -n my-machine cpus=4 memory=8G home-mount=ro
|
||||
$ container machine stop my-machine
|
||||
$ container machine run -n my-machine -- nproc
|
||||
|
||||
Stop and delete the container machine:
|
||||
$ container machine stop my-machine
|
||||
$ container machine delete my-machine
|
||||
""",
|
||||
subcommands: [
|
||||
MachineCreate.self,
|
||||
MachineDelete.self,
|
||||
MachineInspect.self,
|
||||
MachineList.self,
|
||||
MachineLogs.self,
|
||||
MachineRun.self,
|
||||
MachineSet.self,
|
||||
MachineSetDefault.self,
|
||||
MachineStop.self,
|
||||
],
|
||||
aliases: ["m"]
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
|
||||
extension Application.MachineCommand {
|
||||
public enum ListFormat: String, CaseIterable, ExpressibleByArgument {
|
||||
case json
|
||||
case table
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct MachineCreate: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a new container machine and boot it")
|
||||
|
||||
@OptionGroup(title: "Management options")
|
||||
var managementFlags: Flags.MachineManagement
|
||||
|
||||
@OptionGroup(title: "Registry options")
|
||||
var registryFlags: Flags.Registry
|
||||
|
||||
@OptionGroup(title: "Progress options")
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@OptionGroup(title: "Image fetch options")
|
||||
var imageFetchFlags: Flags.ImageFetch
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Option(name: [.short, .long], help: "Name for the container machine")
|
||||
public var name: String?
|
||||
|
||||
@Flag(name: .long, help: "Set this container machine as the default")
|
||||
public var setDefault: Bool = false
|
||||
|
||||
@Flag(name: .long, help: "Create the container machine without booting it")
|
||||
public var noBoot: Bool = false
|
||||
|
||||
@Option(name: .long, help: "Number of virtual CPUs")
|
||||
public var cpus: Int?
|
||||
|
||||
@Option(name: .long, help: "Memory allocation (e.g., 2G, 8G). Default: half of system memory")
|
||||
public var memory: String?
|
||||
|
||||
@Option(name: .long, help: "User's home directory mount option (ro, rw, none). Default: rw")
|
||||
public var homeMount: String?
|
||||
|
||||
@Flag(name: .long, help: "Enable nested virtualization (requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y)")
|
||||
public var virtualization: Bool = false
|
||||
|
||||
@Option(name: .long, help: "Path to a custom kernel binary (e.g. vmlinux).")
|
||||
public var kernel: String?
|
||||
|
||||
@Argument(help: "Container image reference (e.g., alpine:3.22)")
|
||||
var image: String
|
||||
|
||||
public func run() async throws {
|
||||
if virtualization {
|
||||
try MachineCapabilities.requireNestedVirtualizationSupported()
|
||||
}
|
||||
let resolvedKernel = try kernel.map { try MachineConfig.validateKernelPath($0) }
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
showTasks: true,
|
||||
showItems: true,
|
||||
ignoreSmallSize: true,
|
||||
totalTasks: 3
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
|
||||
let defaultConfig = containerSystemConfig.machine
|
||||
|
||||
let bootConfig = try defaultConfig.with(
|
||||
[
|
||||
"cpus": cpus.map { "\($0)" },
|
||||
"memory": memory,
|
||||
"home-mount": homeMount,
|
||||
"virtualization": virtualization ? "true" : nil,
|
||||
"kernel": resolvedKernel?.string,
|
||||
].compactMapValues { $0 }
|
||||
)
|
||||
|
||||
let id: String
|
||||
if let name {
|
||||
id = name
|
||||
} else {
|
||||
let reference = try Reference.parse(image)
|
||||
reference.normalize()
|
||||
let imageName = reference.name.components(separatedBy: "/").last!
|
||||
let suffix = reference.tag ?? reference.digest ?? "latest"
|
||||
id = "\(imageName)-\(suffix)"
|
||||
}
|
||||
|
||||
try Utility.validEntityName(id)
|
||||
|
||||
let client = MachineClient()
|
||||
let (config, resources) = try await MachineClient.machineConfigFromFlags(
|
||||
id: id,
|
||||
image: image,
|
||||
management: managementFlags,
|
||||
registry: registryFlags,
|
||||
imageFetch: imageFetchFlags,
|
||||
containerSystemConfig: containerSystemConfig,
|
||||
progressUpdate: progress.handler
|
||||
)
|
||||
|
||||
do {
|
||||
try await client.create(configuration: config, resources: resources, bootConfig: bootConfig)
|
||||
progress.finish() // Finish before subsequent output to avoid mangling
|
||||
} catch let error as ContainerizationError {
|
||||
if let cause = error.cause as? ContainerizationError, cause.isCode(.exists) {
|
||||
let append = name == nil ? " (missing '-n/--name' flag)" : ""
|
||||
throw ContainerizationError(.exists, message: cause.message + append)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if setDefault {
|
||||
try await client.setDefault(id: id)
|
||||
}
|
||||
|
||||
if !noBoot {
|
||||
try await bootMachine(id: id, client: client, log: log, interactive: false)
|
||||
}
|
||||
|
||||
print(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import MachineAPIClient
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct MachineDelete: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete a container machine",
|
||||
aliases: ["rm"]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@OptionGroup(visibility: .hidden)
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@Argument(help: "Container machine ID")
|
||||
var id: String
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
|
||||
let wasDefault = try await client.getDefault() == id
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
description: "Deleting container machine"
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
try? await client.stop(id: id)
|
||||
try await client.delete(id: id)
|
||||
|
||||
progress.finish()
|
||||
print(id)
|
||||
|
||||
if wasDefault {
|
||||
log.info("Deleted default container '\(id)'. Set a new default with 'container machine set-default <id>'.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
import MachineAPIClient
|
||||
|
||||
/// Resolves a container machine ID from an optional argument, falling back to the default machine.
|
||||
func resolveMachineId(_ id: String?, client: MachineClient) async throws -> String {
|
||||
if let id {
|
||||
return id
|
||||
}
|
||||
guard let defaultId = try await client.getDefault() else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "no container machine specified and no default set"
|
||||
)
|
||||
}
|
||||
return defaultId
|
||||
}
|
||||
|
||||
/// Boots a container machine and, on first ever boot, runs the in-VM init script
|
||||
/// to set up the host user. Returns the resulting snapshot.
|
||||
///
|
||||
/// When `interactive` is true the init script is wired to the host's terminal
|
||||
/// (used by `machine run`); otherwise it runs detached so non-TTY callers like
|
||||
/// `machine create` don't require a TTY or pollute host stdout.
|
||||
///
|
||||
/// On any failure during user setup the machine is stopped to leave it in a clean state.
|
||||
@discardableResult
|
||||
func bootMachine(
|
||||
id: String?,
|
||||
client: MachineClient,
|
||||
log: Logger,
|
||||
interactive: Bool
|
||||
) async throws -> MachineSnapshot {
|
||||
var dynamicEnv: [String: String] = [:]
|
||||
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
|
||||
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
|
||||
}
|
||||
let snapshot = try await client.boot(id: id, dynamicEnv: dynamicEnv)
|
||||
|
||||
guard !snapshot.initialized else {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
do {
|
||||
guard let containerId = snapshot.containerId else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container machine is running but has no container ID"
|
||||
)
|
||||
}
|
||||
|
||||
let io = try ProcessIO.create(
|
||||
tty: interactive,
|
||||
interactive: interactive,
|
||||
detach: !interactive
|
||||
)
|
||||
defer {
|
||||
try? io.close()
|
||||
}
|
||||
|
||||
let processConfig = ProcessConfiguration(
|
||||
executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)",
|
||||
arguments: ["-u"],
|
||||
environment: snapshot.configuration.processEnvironment,
|
||||
terminal: interactive
|
||||
)
|
||||
|
||||
let process = try await ContainerClient().createProcess(
|
||||
containerId: containerId,
|
||||
processId: UUID().uuidString.lowercased(),
|
||||
configuration: processConfig,
|
||||
stdio: io.stdio)
|
||||
|
||||
let exitCode = try await io.handleProcess(process: process, log: log)
|
||||
guard exitCode == 0 else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container machine failed to create user"
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
try? await client.stop(id: snapshot.id)
|
||||
throw error
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerResource
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineInspect: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display detailed information about a container machine"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container machine ID (uses default if not specified)")
|
||||
var id: String?
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
let machineId = try await resolveMachineId(id, client: client)
|
||||
let snapshot = try await client.inspect(id: machineId)
|
||||
|
||||
let output = InspectOutput(snapshot)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode([output])
|
||||
print(String(decoding: data, as: UTF8.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InspectOutput: Codable {
|
||||
let id: String
|
||||
let image: ImageDescription
|
||||
let platform: ContainerizationOCI.Platform
|
||||
let userSetup: UserSetup
|
||||
let status: RuntimeStatus
|
||||
let startedDate: Date?
|
||||
let createdDate: Date?
|
||||
let containerId: String?
|
||||
let cpus: Int
|
||||
let memory: UInt64
|
||||
let homeMount: MachineConfig.HomeMountOption
|
||||
let diskSize: UInt64?
|
||||
let ipAddress: String?
|
||||
|
||||
init(_ snapshot: MachineSnapshot) {
|
||||
self.id = snapshot.id
|
||||
self.image = snapshot.configuration.image
|
||||
self.platform = snapshot.platform
|
||||
self.userSetup = snapshot.configuration.userSetup
|
||||
self.status = snapshot.status
|
||||
self.startedDate = snapshot.startedDate
|
||||
self.createdDate = snapshot.createdDate
|
||||
self.containerId = snapshot.containerId
|
||||
self.cpus = snapshot.bootConfig.cpus
|
||||
self.memory = snapshot.bootConfig.memory.toUInt64(unit: .bytes)
|
||||
self.homeMount = snapshot.bootConfig.homeMount
|
||||
self.diskSize = snapshot.diskSize
|
||||
self.ipAddress = snapshot.ipAddress
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineList: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List container machines",
|
||||
aliases: ["ls"]
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: MachineCommand.ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the container machine ID")
|
||||
var quiet = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
let machines = try await client.list()
|
||||
|
||||
if self.quiet {
|
||||
machines.forEach { print($0.id) }
|
||||
return
|
||||
}
|
||||
|
||||
let defaultMachine = try await client.getDefault()
|
||||
try printMachines(machines: machines, format: format, defaultMachine: defaultMachine)
|
||||
}
|
||||
|
||||
private func printMachines(
|
||||
machines: [MachineSnapshot],
|
||||
format: MachineCommand.ListFormat,
|
||||
defaultMachine: String?
|
||||
) throws {
|
||||
if format == .json {
|
||||
let printables = machines.map {
|
||||
PrintableMachine($0, isDefault: $0.id == defaultMachine)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let data = try encoder.encode(printables)
|
||||
print(String(decoding: data, as: UTF8.self))
|
||||
return
|
||||
}
|
||||
|
||||
var rows: [[String]] = [["NAME", "CREATED", "IP", "CPUS", "MEMORY", "DISK", "STATE", "DEFAULT"]]
|
||||
for machine in machines {
|
||||
rows.append([
|
||||
machine.id,
|
||||
machine.createdDate.map { formatDate($0) } ?? "-",
|
||||
machine.ipAddress ?? "-",
|
||||
"\(machine.bootConfig.cpus)",
|
||||
formatMemory(machine.bootConfig.memory.toUInt64(unit: .bytes)),
|
||||
machine.diskSize.map { formatMemory($0) } ?? "-",
|
||||
machine.status.rawValue,
|
||||
machine.id == defaultMachine ? "*" : "",
|
||||
])
|
||||
}
|
||||
|
||||
let formatter = TableOutput(rows: rows)
|
||||
print(formatter.format())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatMemory(_ bytes: UInt64) -> String {
|
||||
let gib: UInt64 = 1024 * 1024 * 1024
|
||||
if bytes >= gib {
|
||||
if bytes % gib == 0 {
|
||||
return "\(bytes / gib)G"
|
||||
}
|
||||
let formatted = String(format: "%.1fG", Double(bytes) / Double(gib))
|
||||
if formatted.hasSuffix(".0G") {
|
||||
return "\(bytes / gib)G"
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
return "\(bytes / (1024 * 1024))M"
|
||||
}
|
||||
|
||||
private let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private struct PrintableMachine: Codable {
|
||||
let id: String
|
||||
let status: RuntimeStatus
|
||||
let `default`: Bool
|
||||
let ipAddress: String?
|
||||
let cpus: Int
|
||||
let memory: UInt64
|
||||
let diskSize: UInt64?
|
||||
let createdDate: Date?
|
||||
|
||||
init(_ machine: MachineSnapshot, isDefault: Bool) {
|
||||
self.id = machine.id
|
||||
self.status = machine.status
|
||||
self.default = isDefault
|
||||
self.ipAddress = machine.ipAddress
|
||||
self.cpus = machine.bootConfig.cpus
|
||||
self.memory = machine.bootConfig.memory.toUInt64(unit: .bytes)
|
||||
self.diskSize = machine.diskSize
|
||||
self.createdDate = machine.createdDate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineLogs: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "logs",
|
||||
abstract: "Fetch container machine logs"
|
||||
)
|
||||
|
||||
@Flag(name: .long, help: "Display the boot log for the container machine instead of stdio")
|
||||
var boot: Bool = false
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Follow log output")
|
||||
var follow: Bool = false
|
||||
|
||||
@Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs")
|
||||
var numLines: Int?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Machine VM ID (uses default if not specified)")
|
||||
var id: String?
|
||||
|
||||
public func run() async throws {
|
||||
let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
|
||||
|
||||
Task {
|
||||
for await _ in sigHandler.signals {
|
||||
Darwin.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
let client = MachineClient()
|
||||
let id = try await resolveMachineId(id, client: client)
|
||||
|
||||
let fhs = try await client.logs(id: id)
|
||||
let fileHandle = boot ? fhs[1] : fhs[0]
|
||||
|
||||
try await Self.tail(
|
||||
fh: fileHandle,
|
||||
n: numLines,
|
||||
follow: follow,
|
||||
)
|
||||
}
|
||||
|
||||
private static func tail(
|
||||
fh: FileHandle,
|
||||
n: Int?,
|
||||
follow: Bool
|
||||
) async throws {
|
||||
if let n {
|
||||
var buffer = Data()
|
||||
let size = try fh.seekToEnd()
|
||||
var offset = size
|
||||
var lines: [String] = []
|
||||
|
||||
while offset > 0, lines.count < n {
|
||||
let readSize = min(1024, offset)
|
||||
offset -= readSize
|
||||
try fh.seek(toOffset: offset)
|
||||
|
||||
let data = fh.readData(ofLength: Int(readSize))
|
||||
buffer.insert(contentsOf: data, at: 0)
|
||||
|
||||
if let chunk = String(data: buffer, encoding: .utf8) {
|
||||
lines = chunk.components(separatedBy: .newlines)
|
||||
lines = lines.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
lines = Array(lines.suffix(n))
|
||||
for line in lines {
|
||||
print(line)
|
||||
}
|
||||
} else {
|
||||
// Fast path if all they want is the full file.
|
||||
guard let data = try fh.readToEnd() else {
|
||||
// Seems you get nil if it's a zero byte read, or you
|
||||
// try and read from dev/null.
|
||||
return
|
||||
}
|
||||
guard let str = String(data: data, encoding: .utf8) else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to convert container logs to utf8"
|
||||
)
|
||||
}
|
||||
print(str.trimmingCharacters(in: .newlines))
|
||||
}
|
||||
|
||||
fflush(stdout)
|
||||
if follow {
|
||||
setbuf(stdout, nil)
|
||||
try await Self.followFile(fh: fh)
|
||||
}
|
||||
}
|
||||
|
||||
private static func followFile(fh: FileHandle) async throws {
|
||||
_ = try fh.seekToEnd()
|
||||
let stream = AsyncStream<String> { cont in
|
||||
fh.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
// Triggers on container restart - can exit here as well
|
||||
do {
|
||||
_ = try fh.seekToEnd() // To continue streaming existing truncated log files
|
||||
} catch {
|
||||
fh.readabilityHandler = nil
|
||||
cont.finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
if let str = String(data: data, encoding: .utf8), !str.isEmpty {
|
||||
var lines = str.components(separatedBy: .newlines)
|
||||
lines = lines.filter { !$0.isEmpty }
|
||||
for line in lines {
|
||||
cont.yield(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await line in stream {
|
||||
print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
import SystemPackage
|
||||
|
||||
extension Application {
|
||||
public struct MachineRun: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "run",
|
||||
abstract: "Run a command or interactive shell in a container machine, booting the container machine if necessary"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@OptionGroup(title: "Process options")
|
||||
var processFlags: Flags.Process
|
||||
|
||||
@Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)")
|
||||
var name: String?
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Run a process in a container machine and detach from it")
|
||||
public var detach = false
|
||||
|
||||
@Flag(name: .long, help: "Run as root instead of matching host user")
|
||||
var root: Bool = false
|
||||
|
||||
@Argument(help: "Command to run (default: login shell)")
|
||||
var executable: String?
|
||||
|
||||
@Argument(parsing: .captureForPassthrough, help: "Command arguments")
|
||||
var arguments: [String] = []
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
let containerClient = ContainerClient()
|
||||
|
||||
let snapshot = try await bootMachine(id: name, client: client, log: log, interactive: true)
|
||||
|
||||
guard let containerId = snapshot.containerId else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container machine is running but has no container ID"
|
||||
)
|
||||
}
|
||||
// Default runs `/sbin.machine/init -s` to find the shell for user
|
||||
let executablePath = FilePath("/\(MachineBundle.sbinDirectory)").appending(MachineBundle.initFile).string
|
||||
|
||||
let args: [String]
|
||||
let tty: Bool
|
||||
let interactive: Bool
|
||||
|
||||
if let executable {
|
||||
args = ["-s", executable] + arguments
|
||||
tty = processFlags.tty
|
||||
interactive = processFlags.interactive
|
||||
} else {
|
||||
args = ["-s"]
|
||||
tty = true
|
||||
interactive = true
|
||||
}
|
||||
|
||||
// If not root user, get default user from machine configuration
|
||||
let defaultUser: ProcessConfiguration.User = {
|
||||
if root || getuid() == 0 {
|
||||
return .id(uid: 0, gid: 0)
|
||||
}
|
||||
|
||||
return snapshot.configuration.user
|
||||
}()
|
||||
|
||||
let (user, additionalGroups) = Parser.user(
|
||||
user: processFlags.user, uid: processFlags.uid,
|
||||
gid: processFlags.gid, defaultUser: defaultUser)
|
||||
|
||||
let cwd = getWorkingDirectory(snapshot, user: user)
|
||||
|
||||
// Build environment with HOME set correctly
|
||||
let envVars = try Parser.allEnv(
|
||||
imageEnvs: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
|
||||
envFiles: processFlags.envFile,
|
||||
envs: processFlags.env
|
||||
)
|
||||
|
||||
let processConfig = ProcessConfiguration(
|
||||
executable: executablePath,
|
||||
arguments: args,
|
||||
environment: envVars,
|
||||
workingDirectory: cwd,
|
||||
terminal: tty,
|
||||
user: user,
|
||||
supplementalGroups: additionalGroups
|
||||
)
|
||||
|
||||
let io = try ProcessIO.create(tty: tty, interactive: interactive, detach: detach)
|
||||
defer {
|
||||
try? io.close()
|
||||
}
|
||||
|
||||
let process = try await containerClient.createProcess(
|
||||
containerId: containerId,
|
||||
processId: UUID().uuidString.lowercased(),
|
||||
configuration: processConfig,
|
||||
stdio: io.stdio
|
||||
)
|
||||
|
||||
if !tty {
|
||||
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
|
||||
handler.start {
|
||||
print("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
|
||||
Darwin.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if detach {
|
||||
try await process.start()
|
||||
try io.closeAfterStart()
|
||||
print(snapshot.id)
|
||||
return
|
||||
}
|
||||
|
||||
let exitCode = try await io.handleProcess(process: process, log: log)
|
||||
throw ArgumentParser.ExitCode(exitCode)
|
||||
}
|
||||
|
||||
func getWorkingDirectory(_ snapshot: MachineSnapshot, user: ProcessConfiguration.User) -> String {
|
||||
if let cwd = processFlags.cwd {
|
||||
return cwd
|
||||
}
|
||||
let fallback = user == snapshot.configuration.user ? snapshot.configuration.home : "/"
|
||||
if snapshot.bootConfig.homeMount == .none {
|
||||
return fallback
|
||||
}
|
||||
let home = FilePath(FileManager.default.homeDirectoryForCurrentUser.path)
|
||||
let cwd = FilePath(FileManager.default.currentDirectoryPath)
|
||||
guard cwd.starts(with: home) else {
|
||||
return fallback
|
||||
}
|
||||
return cwd.string
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineSet: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "set",
|
||||
abstract: "Set container machine configuration values"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)")
|
||||
public var name: String?
|
||||
|
||||
@Argument(
|
||||
parsing: .remaining,
|
||||
help: ArgumentHelp(
|
||||
"Configuration values",
|
||||
discussion: MachineConfig.helpText(),
|
||||
valueName: "setting"
|
||||
)
|
||||
)
|
||||
public var rawArgs: [String] = []
|
||||
|
||||
public func run() async throws {
|
||||
guard !rawArgs.isEmpty else {
|
||||
throw ContainerizationError(.invalidArgument, message: "expected at least one configuration value (e.g., machine set cpus=4)")
|
||||
}
|
||||
|
||||
let client = MachineClient()
|
||||
let resolvedName = try await resolveMachineId(name, client: client)
|
||||
let snapshot = try await client.inspect(id: resolvedName)
|
||||
|
||||
let kwargs = Dictionary(
|
||||
try rawArgs.map { arg in
|
||||
let parts = arg.split(separator: "=", maxSplits: 1)
|
||||
guard parts.count == 2 else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid argument format '\(arg)'. Expected 'key=value'")
|
||||
}
|
||||
|
||||
return (String(parts[0]), String(parts[1]))
|
||||
},
|
||||
uniquingKeysWith: { _, last in last }
|
||||
)
|
||||
|
||||
if kwargs["virtualization"] == "true" {
|
||||
try MachineCapabilities.requireNestedVirtualizationSupported()
|
||||
}
|
||||
var validatedKwargs = kwargs
|
||||
if let raw = kwargs["kernel"], !raw.isEmpty {
|
||||
validatedKwargs["kernel"] = try MachineConfig.validateKernelPath(raw).string
|
||||
}
|
||||
|
||||
let newConfig = try snapshot.bootConfig.with(validatedKwargs)
|
||||
|
||||
try await client.setConfig(id: resolvedName, bootConfig: newConfig)
|
||||
|
||||
if snapshot.status == .running {
|
||||
FileHandle.standardError.write(
|
||||
Data("Note: Changes will take effect after stopping and restarting '\(resolvedName)'.\n".utf8))
|
||||
}
|
||||
|
||||
print(resolvedName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import MachineAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct MachineSetDefault: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "set-default",
|
||||
abstract: "Set the default container machine")
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Container machine ID")
|
||||
public var id: String
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
try await client.setDefault(id: id)
|
||||
print(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import MachineAPIClient
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct MachineStop: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "stop",
|
||||
abstract: "Stop a running container machine"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@OptionGroup(visibility: .hidden)
|
||||
var progressFlags: Flags.Progress
|
||||
|
||||
@Argument(help: "container machine ID (uses default if not specified)")
|
||||
var id: String?
|
||||
|
||||
public func run() async throws {
|
||||
let client = MachineClient()
|
||||
|
||||
let machineId = try await resolveMachineId(id, client: client)
|
||||
|
||||
let progressConfig = try self.progressFlags.makeConfig(
|
||||
description: "Stopping container machine"
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
|
||||
try await client.stop(id: machineId)
|
||||
|
||||
progress.finish()
|
||||
print(machineId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct NetworkCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "network",
|
||||
abstract: "Manage container networks",
|
||||
subcommands: [
|
||||
NetworkCreate.self,
|
||||
NetworkDelete.self,
|
||||
NetworkList.self,
|
||||
NetworkInspect.self,
|
||||
NetworkPrune.self,
|
||||
],
|
||||
aliases: ["n"]
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct NetworkCreate: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a new network")
|
||||
|
||||
@Flag(name: .customLong("internal"), help: "Restrict to host-only network")
|
||||
var hostOnly: Bool = false
|
||||
|
||||
@Option(name: .customLong("label"), help: "Set metadata for a network")
|
||||
var labels: [String] = []
|
||||
|
||||
@Option(name: .customLong("option"), help: "Set a plugin-specific option (key=value)")
|
||||
var options: [String] = []
|
||||
|
||||
@Option(name: .long, help: "Set the plugin to use to create this network.")
|
||||
var plugin: String = "container-network-vmnet"
|
||||
|
||||
@Option(
|
||||
name: .customLong("subnet"), help: "Set subnet for a network",
|
||||
transform: {
|
||||
try CIDRv4($0)
|
||||
})
|
||||
var ipv4Subnet: CIDRv4? = nil
|
||||
|
||||
@Option(
|
||||
name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network",
|
||||
transform: {
|
||||
try CIDRv6($0)
|
||||
})
|
||||
var ipv6Subnet: CIDRv6? = nil
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Network name")
|
||||
var name: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels))
|
||||
let parsedOptions = Utility.parseKeyValuePairs(options)
|
||||
let mode: NetworkMode = hostOnly ? .hostOnly : .nat
|
||||
let config = try NetworkConfiguration(
|
||||
name: self.name,
|
||||
mode: mode,
|
||||
ipv4Subnet: ipv4Subnet,
|
||||
ipv6Subnet: ipv6Subnet,
|
||||
labels: parsedLabels,
|
||||
plugin: self.plugin,
|
||||
options: parsedOptions
|
||||
)
|
||||
let networkClient = NetworkClient()
|
||||
let network = try await networkClient.create(configuration: config)
|
||||
print(network.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct NetworkDelete: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete one or more networks",
|
||||
aliases: ["rm"])
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete all networks")
|
||||
var all = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Network names")
|
||||
var networkNames: [String] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func validate() throws {
|
||||
if networkNames.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied")
|
||||
}
|
||||
if networkNames.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "explicitly supplied network name(s) conflict with the --all flag"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public mutating func run() async throws {
|
||||
let networkClient = NetworkClient()
|
||||
let uniqueNetworkNames = Set<String>(networkNames)
|
||||
let networks: [NetworkResource]
|
||||
|
||||
if all {
|
||||
networks = try await networkClient.list()
|
||||
.filter { !$0.isBuiltin }
|
||||
} else {
|
||||
networks = try await networkClient.list()
|
||||
.filter { c in
|
||||
guard uniqueNetworkNames.contains(c.id) else {
|
||||
return false
|
||||
}
|
||||
guard !c.isBuiltin else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "cannot delete a builtin network: \(c.id)"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// If one of the networks requested isn't present lets throw. We don't need to do
|
||||
// this for --all as --all should be perfectly usable with no networks to remove,
|
||||
// otherwise it'd be quite clunky.
|
||||
if networks.count != uniqueNetworkNames.count {
|
||||
let missing = uniqueNetworkNames.filter { id in
|
||||
!networks.contains { n in
|
||||
n.id == id
|
||||
}
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "failed to delete one or more networks: \(missing)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var failed = [String]()
|
||||
let _log = log
|
||||
try await withThrowingTaskGroup(of: NetworkResource?.self) { group in
|
||||
for network in networks {
|
||||
group.addTask {
|
||||
do {
|
||||
// Delete atomically disables the IP allocator, then deletes
|
||||
// the allocator. The disable fails if any IPs are still in use.
|
||||
try await networkClient.delete(id: network.id)
|
||||
print(network.id)
|
||||
return nil
|
||||
} catch {
|
||||
_log.error(
|
||||
"failed to delete network",
|
||||
metadata: [
|
||||
"id": "\(network.id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
return network
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await network in group {
|
||||
guard let network else {
|
||||
continue
|
||||
}
|
||||
failed.append(network.id)
|
||||
}
|
||||
}
|
||||
|
||||
if failed.count > 0 {
|
||||
throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct NetworkInspect: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display information about one or more networks")
|
||||
|
||||
@Argument(help: "Networks to inspect")
|
||||
var networks: [String]
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let networkClient = NetworkClient()
|
||||
let uniqueNames = Set(networks)
|
||||
let items = try await networkClient.list().filter { uniqueNames.contains($0.id) }
|
||||
|
||||
if items.count != uniqueNames.count {
|
||||
let found = Set(items.map { $0.id })
|
||||
let missing = uniqueNames.subtracting(found).sorted()
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "network not found: \(missing.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(items, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct NetworkList: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List networks",
|
||||
aliases: ["ls"])
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the network name")
|
||||
var quiet = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let networkClient = NetworkClient()
|
||||
let networks = try await networkClient.list()
|
||||
try Output.render(payload: networks, display: networks, format: format, quiet: quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
|
||||
extension Application.NetworkCommand {
|
||||
public struct NetworkPrune: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "prune",
|
||||
abstract: "Remove networks with no container connections"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let networkClient = NetworkClient()
|
||||
let client = ContainerClient()
|
||||
let allContainers = try await client.list()
|
||||
let allNetworks = try await networkClient.list()
|
||||
|
||||
var networksInUse = Set<String>()
|
||||
for container in allContainers {
|
||||
for network in container.configuration.networks {
|
||||
networksInUse.insert(network.network)
|
||||
}
|
||||
}
|
||||
|
||||
let networksToPrune = allNetworks.filter { network in
|
||||
!network.isBuiltin && !networksInUse.contains(network.id)
|
||||
}
|
||||
|
||||
var prunedNetworks = [String]()
|
||||
|
||||
for network in networksToPrune {
|
||||
do {
|
||||
try await networkClient.delete(id: network.id)
|
||||
prunedNetworks.append(network.id)
|
||||
} catch {
|
||||
// Note: This failure may occur due to a race condition between the network/
|
||||
// container collection above and a container run command that attaches to a
|
||||
// network listed in the networksToPrune collection.
|
||||
log.error(
|
||||
"failed to prune network",
|
||||
metadata: [
|
||||
"id": "\(network.id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
for name in prunedNetworks {
|
||||
print(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerResource
|
||||
|
||||
extension NetworkResource: ListDisplayable {
|
||||
public static var tableHeader: [String] {
|
||||
["NETWORK", "SUBNET"]
|
||||
}
|
||||
|
||||
public var tableRow: [String] {
|
||||
[id, status.ipv4Subnet.description]
|
||||
}
|
||||
|
||||
public var quietValue: String {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
import TOML
|
||||
import Yams
|
||||
|
||||
/// Options for JSON rendering, wrapping the knobs on `JSONEncoder`.
|
||||
public struct JSONOptions: Sendable {
|
||||
public var outputFormatting: JSONEncoder.OutputFormatting = []
|
||||
public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||
|
||||
public static let compact = JSONOptions(outputFormatting: [.sortedKeys], dateEncodingStrategy: .iso8601)
|
||||
public static let pretty = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys], dateEncodingStrategy: .iso8601)
|
||||
|
||||
public init(
|
||||
outputFormatting: JSONEncoder.OutputFormatting = [],
|
||||
dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate
|
||||
) {
|
||||
self.outputFormatting = outputFormatting
|
||||
self.dateEncodingStrategy = dateEncodingStrategy
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared rendering helpers for CLI output.
|
||||
///
|
||||
/// All list commands route their output through these methods. The machine-readable
|
||||
/// payload is encoded separately from table/quiet output, since the payload model
|
||||
/// often differs from the display model.
|
||||
public enum Output {
|
||||
/// Renders an `Encodable` value as a JSON string.
|
||||
public static func renderJSON<T: Encodable>(_ value: T, options: JSONOptions = .compact) throws -> String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = options.outputFormatting
|
||||
encoder.dateEncodingStrategy = options.dateEncodingStrategy
|
||||
let data = try encoder.encode(value)
|
||||
return String(decoding: data, as: UTF8.self)
|
||||
}
|
||||
|
||||
public static func renderYAML<T: Encodable>(_ value: T) throws -> String {
|
||||
let encoder = YAMLEncoder()
|
||||
let data = try encoder.encode(value)
|
||||
return data
|
||||
}
|
||||
|
||||
/// Renders an `Encodable` value as a TOML string.
|
||||
///
|
||||
/// TOML has no top-level array, so array payloads are wrapped under a stable
|
||||
/// `items` key (JSON and YAML emit a top-level array instead); a non-array
|
||||
/// value encodes as a normal top-level table.
|
||||
public static func renderTOML<T: Encodable>(_ value: T) throws -> String {
|
||||
let encoder = TOMLEncoder()
|
||||
encoder.outputFormatting = .sortedKeys
|
||||
if value is [Any] {
|
||||
return try encoder.encodeToString(["items": value])
|
||||
}
|
||||
return try encoder.encodeToString(value)
|
||||
}
|
||||
|
||||
/// Renders a list of displayable items as a table (with header) or quiet-mode identifiers.
|
||||
public static func renderList<T: ListDisplayable>(_ items: [T], quiet: Bool) -> String {
|
||||
if quiet {
|
||||
return items.map(\.quietValue).joined(separator: "\n")
|
||||
}
|
||||
return renderTable(items)
|
||||
}
|
||||
|
||||
/// Renders a list of displayable items as a column-aligned table with a header row.
|
||||
public static func renderTable<T: ListDisplayable>(_ items: [T]) -> String {
|
||||
var rows: [[String]] = [T.tableHeader]
|
||||
for item in items {
|
||||
rows.append(item.tableRow)
|
||||
}
|
||||
return TableOutput(rows: rows).format()
|
||||
}
|
||||
|
||||
/// Renders `payload` in the requested format, encoding it for the
|
||||
/// machine-readable formats and delegating `.table` to the caller.
|
||||
///
|
||||
/// This is the single place where `ListFormat` is matched exhaustively, so
|
||||
/// adopting commands handle every format by construction: a new `ListFormat`
|
||||
/// case becomes a compile error here until it is given an encoder.
|
||||
public static func render<J: Encodable>(
|
||||
payload: J, format: ListFormat, jsonOptions: JSONOptions = .compact, table: () throws -> String
|
||||
) throws {
|
||||
switch format {
|
||||
case .json: try emit(renderJSON(payload, options: jsonOptions))
|
||||
case .yaml: try emit(renderYAML(payload))
|
||||
case .toml: try emit(renderTOML(payload))
|
||||
case .table: try emit(table())
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders list output in the requested format.
|
||||
///
|
||||
/// The machine-readable payload and the display model may be the same type
|
||||
/// (e.g., `ManagedContainer`) or different types.
|
||||
public static func render<J: Encodable, D: ListDisplayable>(
|
||||
payload: J, display: [D], format: ListFormat, quiet: Bool, jsonOptions: JSONOptions = .compact
|
||||
) throws {
|
||||
try render(payload: payload, format: format, jsonOptions: jsonOptions) {
|
||||
renderList(display, quiet: quiet)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes rendered output to stdout. No-ops on empty strings to avoid blank lines
|
||||
/// (e.g., `container list -q` with zero results should produce no output, not a newline).
|
||||
public static func emit(_ output: String) {
|
||||
if !output.isEmpty {
|
||||
print(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct RegistryCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "registry",
|
||||
abstract: "Manage registry logins",
|
||||
subcommands: [
|
||||
RegistryLogin.self,
|
||||
RegistryLogout.self,
|
||||
RegistryList.self,
|
||||
],
|
||||
aliases: ["r"]
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct RegistryList: AsyncLoggableCommand {
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the registry hostname")
|
||||
var quiet = false
|
||||
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List image registry logins",
|
||||
aliases: ["ls"])
|
||||
|
||||
public func run() async throws {
|
||||
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
|
||||
let registryInfos = try keychain.list()
|
||||
let registries = registryInfos.map { RegistryResource(from: $0) }
|
||||
|
||||
try Output.render(
|
||||
payload: registries,
|
||||
display: registries.map { PrintableRegistry($0) },
|
||||
format: format, quiet: quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrintableRegistry: ListDisplayable {
|
||||
let registry: RegistryResource
|
||||
|
||||
init(_ registry: RegistryResource) {
|
||||
self.registry = registry
|
||||
}
|
||||
|
||||
static var tableHeader: [String] {
|
||||
["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]
|
||||
}
|
||||
|
||||
var tableRow: [String] {
|
||||
[
|
||||
registry.name,
|
||||
registry.username,
|
||||
registry.modificationDate.ISO8601Format(),
|
||||
registry.creationDate.ISO8601Format(),
|
||||
]
|
||||
}
|
||||
|
||||
var quietValue: String {
|
||||
registry.name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct RegistryLogin: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "login",
|
||||
abstract: "Log in to a registry"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
var registry: Flags.Registry
|
||||
|
||||
@Flag(help: "Take the password from stdin")
|
||||
var passwordStdin: Bool = false
|
||||
|
||||
@Option(name: .shortAndLong, help: "Registry user name")
|
||||
var username: String = ""
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Registry server name")
|
||||
var server: String
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
var username = self.username
|
||||
var password = ""
|
||||
if passwordStdin {
|
||||
if username == "" {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument, message: "must provide --username with --password-stdin")
|
||||
}
|
||||
guard let passwordData = try FileHandle.standardInput.readToEnd() else {
|
||||
throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin")
|
||||
}
|
||||
password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
|
||||
if username == "" {
|
||||
username = try keychain.userPrompt(hostname: server)
|
||||
}
|
||||
if password == "" {
|
||||
password = try keychain.passwordPrompt()
|
||||
print()
|
||||
}
|
||||
|
||||
let server = Reference.resolveDomain(domain: server)
|
||||
let scheme = try RequestScheme(registry.scheme).schemeFor(host: server, internalDnsDomain: containerSystemConfig.dns.domain)
|
||||
let _url = "\(scheme)://\(server)"
|
||||
guard let url = URL(string: _url) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL")
|
||||
}
|
||||
guard let host = url.host else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid host \(server)")
|
||||
}
|
||||
|
||||
let client = RegistryClient(
|
||||
host: host,
|
||||
scheme: scheme.rawValue,
|
||||
port: url.port,
|
||||
authentication: BasicAuthentication(username: username, password: password),
|
||||
retryOptions: .init(
|
||||
maxRetries: 10,
|
||||
retryInterval: 300_000_000,
|
||||
shouldRetry: ({ response in
|
||||
response.status.code >= 500
|
||||
})
|
||||
)
|
||||
)
|
||||
try await client.ping()
|
||||
try keychain.save(hostname: server, username: username, password: password)
|
||||
log.info("Login succeeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Containerization
|
||||
import ContainerizationOCI
|
||||
|
||||
extension Application {
|
||||
public struct RegistryLogout: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "logout",
|
||||
abstract: "Log out from a registry"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Registry server name")
|
||||
var registry: String
|
||||
|
||||
public func run() async throws {
|
||||
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
|
||||
let r = Reference.resolveDomain(domain: registry)
|
||||
try keychain.delete(hostname: r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import DNSServer
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct DNSCreate: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a local DNS domain for containers (must run as an administrator)"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Option(name: .long, help: "Set the ip address to be redirected to localhost")
|
||||
var localhost: String?
|
||||
|
||||
@Argument(help: "The local domain name")
|
||||
var domainName: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
var localhostIP: IPAddress? = nil
|
||||
if let localhost {
|
||||
localhostIP = try? IPAddress(localhost)
|
||||
guard let localhostIP, case .v4(_) = localhostIP else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)")
|
||||
}
|
||||
}
|
||||
|
||||
guard let domainName = try? DNSName(domainName) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)")
|
||||
}
|
||||
|
||||
let resolver: HostDNSResolver = HostDNSResolver()
|
||||
do {
|
||||
try resolver.createDomain(name: domainName, localhost: localhostIP)
|
||||
} catch let error as ContainerizationError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)")
|
||||
}
|
||||
|
||||
let pf = PacketFilter()
|
||||
if let from = localhostIP {
|
||||
let to = try! IPAddress("127.0.0.1")
|
||||
do {
|
||||
try pf.createRedirectRule(from: from, to: to, domain: domainName)
|
||||
} catch {
|
||||
_ = try resolver.deleteDomain(name: domainName)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
print(domainName.pqdn)
|
||||
|
||||
if localhostIP != nil {
|
||||
do {
|
||||
try pf.reinitialize()
|
||||
} catch let error as ContainerizationError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try HostDNSResolver.reinitialize()
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import DNSServer
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct DNSDelete: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete a local DNS domain (must run as an administrator)",
|
||||
aliases: ["rm"]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "The local domain name")
|
||||
var domainName: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
guard let domainName = try? DNSName(domainName) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)")
|
||||
}
|
||||
|
||||
let resolver = HostDNSResolver()
|
||||
var localhostIP: IPAddress?
|
||||
do {
|
||||
localhostIP = try resolver.deleteDomain(name: domainName)
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)")
|
||||
}
|
||||
|
||||
do {
|
||||
try HostDNSResolver.reinitialize()
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain")
|
||||
}
|
||||
|
||||
guard let localhostIP else {
|
||||
print(domainName.pqdn)
|
||||
return
|
||||
}
|
||||
|
||||
let pf = PacketFilter()
|
||||
try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName)
|
||||
|
||||
do {
|
||||
try pf.reinitialize()
|
||||
} catch let error as ContainerizationError {
|
||||
throw error
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidState, message: "failed loading pf rules")
|
||||
}
|
||||
print(domainName.pqdn)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import DNSServer
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct DNSList: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List local DNS domains",
|
||||
aliases: ["ls"]
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the domain")
|
||||
var quiet = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let resolver = HostDNSResolver()
|
||||
let domains = resolver.listDomains()
|
||||
|
||||
try Output.render(
|
||||
payload: domains.map { $0.pqdn },
|
||||
display: domains.map { PrintableDomain($0) },
|
||||
format: format, quiet: quiet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrintableDomain: ListDisplayable {
|
||||
let domain: DNSName
|
||||
|
||||
init(_ domain: DNSName) {
|
||||
self.domain = domain
|
||||
}
|
||||
|
||||
static var tableHeader: [String] {
|
||||
["DOMAIN"]
|
||||
}
|
||||
|
||||
var tableRow: [String] {
|
||||
[domain.pqdn]
|
||||
}
|
||||
|
||||
var quietValue: String {
|
||||
domain.pqdn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct KernelSet: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "set",
|
||||
abstract: "Set the default kernel"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "The architecture of the kernel binary (values: amd64, arm64)")
|
||||
var arch: String = ContainerizationOCI.Platform.current.architecture.description
|
||||
|
||||
@Option(name: .customLong("binary"), help: "Path to the kernel file (or archive member, if used with --tar)")
|
||||
var binaryPath: String? = nil
|
||||
|
||||
@Flag(name: .long, help: "Overwrites an existing kernel with the same name")
|
||||
var force: Bool = false
|
||||
|
||||
@Flag(name: .long, help: "Download and install the recommended kernel as the default (takes precedence over all other flags)")
|
||||
var recommended: Bool = false
|
||||
|
||||
@Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file")
|
||||
var tarPath: String? = nil
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
if recommended {
|
||||
let url = containerSystemConfig.kernel.url
|
||||
let path: String = containerSystemConfig.kernel.binaryPath
|
||||
log.info("Installing the recommended kernel from \(url)...")
|
||||
try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force)
|
||||
return
|
||||
}
|
||||
guard tarPath != nil else {
|
||||
return try await self.setKernelFromBinary()
|
||||
}
|
||||
try await self.setKernelFromTar()
|
||||
}
|
||||
|
||||
private func setKernelFromBinary() async throws {
|
||||
guard let binaryPath else {
|
||||
throw ArgumentParser.ValidationError("missing argument '--binary'")
|
||||
}
|
||||
let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString
|
||||
let platform = try getSystemPlatform()
|
||||
try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform, force: force)
|
||||
}
|
||||
|
||||
private func setKernelFromTar() async throws {
|
||||
guard let binaryPath else {
|
||||
throw ArgumentParser.ValidationError("missing argument '--binary'")
|
||||
}
|
||||
guard let tarPath else {
|
||||
throw ArgumentParser.ValidationError("missing argument '--tar")
|
||||
}
|
||||
let platform = try getSystemPlatform()
|
||||
let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path
|
||||
let fm = FileManager.default
|
||||
if fm.fileExists(atPath: localTarPath) {
|
||||
try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force)
|
||||
return
|
||||
}
|
||||
guard let remoteURL = URL(string: tarPath) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?")
|
||||
}
|
||||
try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force)
|
||||
}
|
||||
|
||||
private func getSystemPlatform() throws -> SystemPlatform {
|
||||
switch arch {
|
||||
case "arm64":
|
||||
return .linuxArm
|
||||
case "amd64":
|
||||
return .linuxAmd
|
||||
default:
|
||||
throw ContainerizationError(.unsupported, message: "unsupported architecture \(arch)")
|
||||
}
|
||||
}
|
||||
|
||||
static func downloadAndInstallWithProgressBar(tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws {
|
||||
let progressConfig = try ProgressConfig(
|
||||
showTasks: true,
|
||||
totalTasks: 2
|
||||
)
|
||||
let progress = ProgressBar(config: progressConfig)
|
||||
defer {
|
||||
progress.finish()
|
||||
}
|
||||
progress.start()
|
||||
try await ClientKernel.installKernelFromTar(
|
||||
tarFile: tarRemoteURL.absoluteString, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force)
|
||||
progress.finish()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import Foundation
|
||||
|
||||
enum ListOutputFormat: String, Decodable, ExpressibleByArgument {
|
||||
case json
|
||||
case toml
|
||||
}
|
||||
|
||||
extension Application {
|
||||
public struct PropertyList: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List system properties",
|
||||
aliases: ["ls"]
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListOutputFormat = .toml
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
|
||||
let output =
|
||||
switch format {
|
||||
case .json: try Output.renderJSON(containerSystemConfig)
|
||||
case .toml: try Output.renderTOML(containerSystemConfig)
|
||||
}
|
||||
Output.emit(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct SystemCommand: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "system",
|
||||
abstract: "Manage system components",
|
||||
subcommands: [
|
||||
SystemDF.self,
|
||||
SystemDNS.self,
|
||||
SystemKernel.self,
|
||||
SystemLogs.self,
|
||||
SystemProperty.self,
|
||||
SystemStart.self,
|
||||
SystemStatus.self,
|
||||
SystemStop.self,
|
||||
SystemVersion.self,
|
||||
],
|
||||
aliases: ["s"]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct SystemDF: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "df",
|
||||
abstract: "Show disk usage for images, containers, and volumes"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let stats = try await ClientDiskUsage.get()
|
||||
try Output.render(payload: stats, format: format, jsonOptions: .pretty) {
|
||||
diskUsageTable(stats: stats)
|
||||
}
|
||||
}
|
||||
|
||||
private func diskUsageTable(stats: DiskUsageStats) -> String {
|
||||
var rows: [[String]] = []
|
||||
|
||||
// Header row
|
||||
rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"])
|
||||
|
||||
// Images row
|
||||
rows.append([
|
||||
"Images",
|
||||
"\(stats.images.total)",
|
||||
"\(stats.images.active)",
|
||||
formatSize(stats.images.sizeInBytes),
|
||||
formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes),
|
||||
])
|
||||
|
||||
// Containers row
|
||||
rows.append([
|
||||
"Containers",
|
||||
"\(stats.containers.total)",
|
||||
"\(stats.containers.active)",
|
||||
formatSize(stats.containers.sizeInBytes),
|
||||
formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes),
|
||||
])
|
||||
|
||||
// Volumes row
|
||||
rows.append([
|
||||
"Local Volumes",
|
||||
"\(stats.volumes.total)",
|
||||
"\(stats.volumes.active)",
|
||||
formatSize(stats.volumes.sizeInBytes),
|
||||
formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes),
|
||||
])
|
||||
|
||||
let tableFormatter = TableOutput(rows: rows)
|
||||
return tableFormatter.format()
|
||||
}
|
||||
|
||||
private func formatSize(_ bytes: UInt64) -> String {
|
||||
if bytes == 0 {
|
||||
return "0 B"
|
||||
}
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String {
|
||||
let sizeStr = formatSize(reclaimable)
|
||||
|
||||
if total == 0 {
|
||||
return "\(sizeStr) (0%)"
|
||||
}
|
||||
|
||||
// Cap at 100% in case reclaimable > total (shouldn't happen but be defensive)
|
||||
let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0)))
|
||||
return "\(sizeStr) (\(percentage)%)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct SystemDNS: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "dns",
|
||||
abstract: "Manage local DNS domains",
|
||||
subcommands: [
|
||||
DNSCreate.self,
|
||||
DNSDelete.self,
|
||||
DNSList.self,
|
||||
]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct SystemKernel: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "kernel",
|
||||
abstract: "Manage the default kernel configuration",
|
||||
subcommands: [
|
||||
KernelSet.self
|
||||
]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerizationError
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
extension Application {
|
||||
public struct SystemLogs: AsyncLoggableCommand {
|
||||
public static let subsystem = "com.apple.container"
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "logs",
|
||||
abstract: "Fetch system logs for `container` services"
|
||||
)
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Follow log output")
|
||||
var follow: Bool = false
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Fetch logs starting from the specified time period (minus the current time); supported formats: <number>[m|h|d] (defaults to seconds)"
|
||||
)
|
||||
var last: String = "5m"
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func validate() throws {
|
||||
if follow { return }
|
||||
if let value = Int(last), value > 0 { return }
|
||||
guard let unit = last.last, "mhd".contains(unit),
|
||||
let value = Int(last.dropLast()), value > 0
|
||||
else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid --last value '\(last)': expected a positive integer (seconds) or a value with suffix m, h, or d (e.g. 30, 5m, 1h, 2d)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func run() async throws {
|
||||
let process = Process()
|
||||
let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
|
||||
|
||||
Task {
|
||||
for await _ in sigHandler.signals {
|
||||
process.terminate()
|
||||
Darwin.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
var args = ["log"]
|
||||
args.append(self.follow ? "stream" : "show")
|
||||
args.append(contentsOf: ["--info", logOptions.debug ? "--debug" : nil].compactMap { $0 })
|
||||
if !self.follow {
|
||||
args.append(contentsOf: ["--last", last])
|
||||
}
|
||||
args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"])
|
||||
|
||||
process.launchPath = "/usr/bin/env"
|
||||
process.arguments = args
|
||||
|
||||
process.standardOutput = FileHandle.standardOutput
|
||||
process.standardError = FileHandle.standardError
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "failed to system logs: \(error)"
|
||||
)
|
||||
}
|
||||
throw ArgumentParser.ExitCode(process.terminationStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct SystemProperty: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "property",
|
||||
abstract: "Manage system property values",
|
||||
subcommands: [
|
||||
PropertyList.self
|
||||
]
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPersistence
|
||||
import ContainerPlugin
|
||||
import ContainerXPC
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
import SystemPackage
|
||||
import TerminalProgress
|
||||
|
||||
extension Application {
|
||||
public struct SystemStart: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "start",
|
||||
abstract: "Start `container` services"
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "Path to the root directory for application data",
|
||||
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
|
||||
var appRoot = ApplicationRoot.defaultPath
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Path to the root directory for application executables and plugins",
|
||||
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
|
||||
var installRoot = InstallRoot.defaultPath
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
help: "Path to the root directory for log data, using macOS log facility if not set",
|
||||
transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) })
|
||||
var logRoot: FilePath? = nil
|
||||
|
||||
@Flag(
|
||||
name: .long,
|
||||
inversion: .prefixedEnableDisable,
|
||||
help: "Specify whether the default kernel should be installed or not (default: prompt user)")
|
||||
var kernelInstall: Bool?
|
||||
|
||||
@Option(
|
||||
help: "Number of seconds to wait for API service to become responsive",
|
||||
transform: {
|
||||
guard let timeoutSeconds = Double($0) else {
|
||||
throw ValidationError("Invalid timeout value: \($0)")
|
||||
}
|
||||
return .seconds(timeoutSeconds)
|
||||
}
|
||||
)
|
||||
var timeout: Duration = XPCClient.xpcRegistrationTimeout
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot)
|
||||
// Pass appRoot before installRoot: ConfigurationLoader uses first-match-wins
|
||||
// precedence, so user-provided config in appRoot overrides the defaults
|
||||
// shipped under installRoot. Both layers are passed explicitly because
|
||||
// users can override --app-root and --install-root from the CLI, and the
|
||||
// loader's default search would otherwise ignore those overrides.
|
||||
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load(
|
||||
configurationFiles: [
|
||||
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
|
||||
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
|
||||
])
|
||||
|
||||
// Without the true path to the binary in the plist, `container-apiserver` won't launch properly.
|
||||
// Resolve the symlink to get the true binary path before writing the launchd plist.
|
||||
// Gatekeeper / amfid validates code signatures relative to the enclosing .app bundle
|
||||
// hierarchy; launching via a symlink outside the bundle fails that check.
|
||||
// TODO: Can we use the plugin loader to bootstrap the API server?
|
||||
let executablePath = try CommandLine.executablePath
|
||||
.removingLastComponent()
|
||||
.appending(FilePath.Component("container-apiserver"))
|
||||
.resolvingSymlinks()
|
||||
|
||||
var args = [executablePath.string]
|
||||
|
||||
args.append("start")
|
||||
if logOptions.debug {
|
||||
args.append("--debug")
|
||||
}
|
||||
|
||||
let apiServerDataPath = appRoot.appending(FilePath.Component("apiserver"))
|
||||
let apiServerDataURL = URL(fileURLWithPath: apiServerDataPath.string)
|
||||
try FileManager.default.createDirectory(at: apiServerDataURL, withIntermediateDirectories: true)
|
||||
|
||||
var env = PluginLoader.filterEnvironment()
|
||||
env[ApplicationRoot.environmentName] = appRoot.string
|
||||
env[InstallRoot.environmentName] = installRoot.string
|
||||
if let logRoot {
|
||||
env[LogRoot.environmentName] = logRoot.string
|
||||
}
|
||||
let plist = LaunchPlist(
|
||||
label: "com.apple.container.apiserver",
|
||||
arguments: args,
|
||||
environment: env,
|
||||
limitLoadToSessionType: [.Aqua, .Background, .System],
|
||||
runAtLoad: true,
|
||||
machServices: ["com.apple.container.apiserver"]
|
||||
)
|
||||
|
||||
let plistPath = apiServerDataPath.appending(FilePath.Component("apiserver.plist"))
|
||||
let plistURL = URL(fileURLWithPath: plistPath.string)
|
||||
let data = try plist.encode()
|
||||
try data.write(to: plistURL)
|
||||
|
||||
log.info("Launching container-apiserver...")
|
||||
try ServiceManager.register(plistPath: plistURL.path)
|
||||
|
||||
// Now ping our friendly daemon. Fail if we don't get a response.
|
||||
do {
|
||||
log.info("Testing access to container-apiserver...")
|
||||
_ = try await ClientHealthCheck.ping(timeout: timeout)
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to get a response from apiserver: \(error)"
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
print("Verifying machine API server is running...")
|
||||
_ = try await MachineClient().list()
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to get a response from machine API server: \(error)"
|
||||
)
|
||||
}
|
||||
|
||||
if await !initImageExists(containerSystemConfig: containerSystemConfig) {
|
||||
try? await installInitialFilesystem(initImage: containerSystemConfig.vminit.image)
|
||||
}
|
||||
|
||||
guard await !kernelExists() else {
|
||||
return
|
||||
}
|
||||
try await installDefaultKernel(kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath)
|
||||
}
|
||||
|
||||
private func installInitialFilesystem(initImage: String) async throws {
|
||||
var pullCommand = try ImagePull.parse()
|
||||
pullCommand.reference = initImage
|
||||
log.info("Installing base container filesystem...")
|
||||
do {
|
||||
try await pullCommand.run()
|
||||
} catch {
|
||||
log.error("failed to install base container filesystem", metadata: ["error": "\(error)"])
|
||||
}
|
||||
}
|
||||
|
||||
private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String) async throws {
|
||||
var shouldInstallKernel = false
|
||||
if kernelInstall == nil {
|
||||
print("No default kernel configured.")
|
||||
print("Install the recommended default kernel from [\(kernelURL)]? [Y/n]: ", terminator: "")
|
||||
guard let read = readLine(strippingNewline: true) else {
|
||||
throw ContainerizationError(.internalError, message: "failed to read user input")
|
||||
}
|
||||
guard read.lowercased() == "y" || read.count == 0 else {
|
||||
log.info("Please use the `container system kernel set --recommended` command to configure the default kernel")
|
||||
return
|
||||
}
|
||||
shouldInstallKernel = true
|
||||
} else {
|
||||
shouldInstallKernel = kernelInstall ?? false
|
||||
}
|
||||
guard shouldInstallKernel else {
|
||||
return
|
||||
}
|
||||
log.info("Installing kernel...")
|
||||
try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, force: true)
|
||||
}
|
||||
|
||||
private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool {
|
||||
do {
|
||||
let img = try await ClientImage.get(
|
||||
reference: containerSystemConfig.vminit.image,
|
||||
containerSystemConfig: containerSystemConfig
|
||||
)
|
||||
let _ = try await img.getSnapshot(platform: .current)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func kernelExists() async -> Bool {
|
||||
do {
|
||||
try await ClientKernel.getDefaultKernel(for: .current)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPlugin
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
extension Application {
|
||||
public struct SystemStatus: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "status",
|
||||
abstract: "Show the status of `container` services"
|
||||
)
|
||||
|
||||
@Option(name: .shortAndLong, help: "Launchd prefix for services")
|
||||
var prefix: String = "com.apple.container."
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
struct PrintableStatus: Codable {
|
||||
let status: String
|
||||
let appRoot: String
|
||||
let installRoot: String
|
||||
let logRoot: String?
|
||||
let apiServerVersion: String
|
||||
let apiServerCommit: String
|
||||
let apiServerBuild: String
|
||||
let apiServerAppName: String
|
||||
|
||||
init(
|
||||
status: String,
|
||||
appRoot: String = "",
|
||||
installRoot: String = "",
|
||||
logRoot: String? = nil,
|
||||
apiServerVersion: String = "",
|
||||
apiServerCommit: String = "",
|
||||
apiServerBuild: String = "",
|
||||
apiServerAppName: String = ""
|
||||
) {
|
||||
self.status = status
|
||||
self.appRoot = appRoot
|
||||
self.installRoot = installRoot
|
||||
self.logRoot = logRoot
|
||||
self.apiServerVersion = apiServerVersion
|
||||
self.apiServerCommit = apiServerCommit
|
||||
self.apiServerBuild = apiServerBuild
|
||||
self.apiServerAppName = apiServerAppName
|
||||
}
|
||||
}
|
||||
|
||||
public func run() async throws {
|
||||
let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver")
|
||||
if !isRegistered {
|
||||
try Output.render(payload: PrintableStatus(status: "unregistered"), format: format) {
|
||||
"apiserver is not running and not registered with launchd"
|
||||
}
|
||||
Application.exit(withError: ExitCode(1))
|
||||
}
|
||||
|
||||
// Now ping our friendly daemon. Fail after 10 seconds with no response.
|
||||
do {
|
||||
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
|
||||
let status = PrintableStatus(
|
||||
status: "running",
|
||||
appRoot: systemHealth.appRoot.path(percentEncoded: false),
|
||||
installRoot: systemHealth.installRoot.path(percentEncoded: false),
|
||||
logRoot: systemHealth.logRoot?.string,
|
||||
apiServerVersion: systemHealth.apiServerVersion,
|
||||
apiServerCommit: systemHealth.apiServerCommit,
|
||||
apiServerBuild: systemHealth.apiServerBuild,
|
||||
apiServerAppName: systemHealth.apiServerAppName
|
||||
)
|
||||
try Output.render(payload: status, format: format) {
|
||||
Self.statusTable(status)
|
||||
}
|
||||
} catch {
|
||||
try Output.render(payload: PrintableStatus(status: "not running"), format: format) {
|
||||
"apiserver is not running"
|
||||
}
|
||||
Application.exit(withError: ExitCode(1))
|
||||
}
|
||||
}
|
||||
|
||||
private static func statusTable(_ status: PrintableStatus) -> String {
|
||||
let rows: [[String]] = [
|
||||
["FIELD", "VALUE"],
|
||||
["status", status.status],
|
||||
["appRoot", status.appRoot],
|
||||
["installRoot", status.installRoot],
|
||||
["logRoot", status.logRoot ?? ""],
|
||||
["apiserver.version", status.apiServerVersion],
|
||||
["apiserver.commit", status.apiServerCommit],
|
||||
["apiserver.build", status.apiServerBuild],
|
||||
["apiserver.appName", status.apiServerAppName],
|
||||
]
|
||||
return TableOutput(rows: rows).format()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
extension Application {
|
||||
public struct SystemStop: AsyncLoggableCommand {
|
||||
private static let stopTimeoutSeconds: Int32 = 5
|
||||
private static let shutdownTimeoutSeconds: Int32 = 20
|
||||
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "stop",
|
||||
abstract: "Stop all `container` services"
|
||||
)
|
||||
|
||||
@Option(name: .shortAndLong, help: "Launchd prefix for services")
|
||||
var prefix: String = "com.apple.container."
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let log = Logger(
|
||||
label: "com.apple.container.cli",
|
||||
factory: { label in
|
||||
StreamLogHandler.standardOutput(label: label)
|
||||
}
|
||||
)
|
||||
|
||||
let launchdDomainString = try ServiceManager.getDomainString()
|
||||
let fullLabel = "\(launchdDomainString)/\(prefix)apiserver"
|
||||
|
||||
var running = true
|
||||
do {
|
||||
log.info("checking if APIServer is alive")
|
||||
_ = try await ClientHealthCheck.ping(timeout: .seconds(5))
|
||||
} catch {
|
||||
log.info("APIServer health check failed, skipping bootout")
|
||||
running = false
|
||||
}
|
||||
|
||||
if running {
|
||||
let client = ContainerClient()
|
||||
log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"])
|
||||
do {
|
||||
let containers = try await client.list().map { $0.id }
|
||||
let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: nil)
|
||||
try await ContainerStop.stopContainers(
|
||||
client: client,
|
||||
containers: containers,
|
||||
stopOptions: opts,
|
||||
)
|
||||
} catch {
|
||||
log.warning("failed to stop all containers", metadata: ["error": "\(error)"])
|
||||
}
|
||||
|
||||
log.info("waiting for containers to exit")
|
||||
do {
|
||||
for _ in 0..<Self.shutdownTimeoutSeconds {
|
||||
let runningContainers = try await client.list(filters: ContainerListFilters(status: .running))
|
||||
guard !runningContainers.isEmpty else {
|
||||
break
|
||||
}
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
|
||||
log.info("stopping service", metadata: ["label": "\(fullLabel)"])
|
||||
try ServiceManager.deregister(fullServiceLabel: fullLabel)
|
||||
} catch {
|
||||
log.warning("failed to wait for all containers", metadata: ["error": "\(error)"])
|
||||
}
|
||||
}
|
||||
|
||||
// Note: The assumption here is that we would have registered the launchd services
|
||||
// in the same domain as `launchdDomainString`. This is a fairly sane assumption since
|
||||
// if somehow the launchd domain changed, XPC interactions would not be possible.
|
||||
try ServiceManager.enumerate()
|
||||
.filter { $0.hasPrefix(prefix) }
|
||||
.filter { $0 != fullLabel }
|
||||
.map { "\(launchdDomainString)/\($0)" }
|
||||
.forEach {
|
||||
log.info("stopping service", metadata: ["label": "\($0)"])
|
||||
try? ServiceManager.deregister(fullServiceLabel: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerVersion
|
||||
import Foundation
|
||||
|
||||
extension Application {
|
||||
public struct SystemVersion: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "version",
|
||||
abstract: "Show version information"
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let cliInfo = VersionInfo(
|
||||
version: ReleaseVersion.version(),
|
||||
buildType: ReleaseVersion.buildType(),
|
||||
commit: ReleaseVersion.gitCommit() ?? "unspecified",
|
||||
appName: "container"
|
||||
)
|
||||
|
||||
// Try to get API server version info
|
||||
let serverInfo: VersionInfo?
|
||||
do {
|
||||
let health = try await ClientHealthCheck.ping(timeout: .seconds(2))
|
||||
serverInfo = VersionInfo(
|
||||
version: health.apiServerVersion,
|
||||
buildType: health.apiServerBuild,
|
||||
commit: health.apiServerCommit,
|
||||
appName: health.apiServerAppName
|
||||
)
|
||||
} catch {
|
||||
serverInfo = nil
|
||||
}
|
||||
|
||||
let versions = [cliInfo, serverInfo].compactMap { $0 }
|
||||
|
||||
try Output.render(payload: versions, format: format) {
|
||||
Self.versionTable(versions)
|
||||
}
|
||||
}
|
||||
|
||||
private static func versionTable(_ versions: [VersionInfo]) -> String {
|
||||
let header = ["COMPONENT", "VERSION", "BUILD", "COMMIT"]
|
||||
let rows = [header] + versions.map { [$0.appName, $0.version, $0.buildType, $0.commit] }
|
||||
return TableOutput(rows: rows).format()
|
||||
}
|
||||
}
|
||||
|
||||
public struct VersionInfo: Codable {
|
||||
let version: String
|
||||
let buildType: String
|
||||
let commit: String
|
||||
let appName: String
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct TableOutput: Sendable {
|
||||
private let rows: [[String]]
|
||||
private let spacing: Int
|
||||
|
||||
public init(
|
||||
rows: [[String]],
|
||||
spacing: Int = 2
|
||||
) {
|
||||
self.rows = rows
|
||||
self.spacing = spacing
|
||||
}
|
||||
|
||||
public func format() -> String {
|
||||
var output = ""
|
||||
let maxLengths = self.maxLength()
|
||||
|
||||
for rowIndex in 0..<self.rows.count {
|
||||
let row = self.rows[rowIndex]
|
||||
for columnIndex in 0..<row.count - 1 {
|
||||
let currentLength = (maxLengths[columnIndex] ?? 0) + self.spacing
|
||||
let padded = row[columnIndex].padding(toLength: currentLength, withPad: " ", startingAt: 0)
|
||||
output += padded
|
||||
}
|
||||
// Skip padding for the last column.
|
||||
output += row.last ?? ""
|
||||
output += (rowIndex == self.rows.count - 1) ? "" : "\n"
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/// Returns a mapping of column index and the maximum length of all elements belonging under that column.
|
||||
private func maxLength() -> [Int: Int] {
|
||||
var output: [Int: Int] = [:]
|
||||
for row in self.rows {
|
||||
for (i, column) in row.enumerated() {
|
||||
let currentMax = output[i] ?? 0
|
||||
output[i] = (column.count > currentMax) ? column.count : currentMax
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
|
||||
extension Application {
|
||||
public struct VolumeCommand: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "volume",
|
||||
abstract: "Manage container volumes",
|
||||
subcommands: [
|
||||
VolumeCreate.self,
|
||||
VolumeDelete.self,
|
||||
VolumeList.self,
|
||||
VolumeInspect.self,
|
||||
VolumePrune.self,
|
||||
],
|
||||
aliases: ["v"]
|
||||
)
|
||||
|
||||
public init() {}
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
|
||||
extension Application.VolumeCommand {
|
||||
public struct VolumeCreate: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "create",
|
||||
abstract: "Create a new volume"
|
||||
)
|
||||
|
||||
@Option(name: .customLong("label"), help: "Set metadata for a volume")
|
||||
var labels: [String] = []
|
||||
|
||||
@Option(name: .customLong("opt"), help: "Set driver specific options")
|
||||
var driverOpts: [String] = []
|
||||
|
||||
@Option(name: .short, help: "Size of the volume in bytes, with optional K, M, G, T, or P suffix")
|
||||
var size: String?
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Volume name")
|
||||
var name: String
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts)
|
||||
let parsedLabels = Utility.parseKeyValuePairs(labels)
|
||||
|
||||
// If --size is specified, add it to driver options
|
||||
if let size = size {
|
||||
parsedDriverOpts["size"] = size
|
||||
}
|
||||
|
||||
let volume = try await ClientVolume.create(
|
||||
name: name,
|
||||
driver: "local",
|
||||
driverOpts: parsedDriverOpts,
|
||||
labels: parsedLabels
|
||||
)
|
||||
print(volume.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application.VolumeCommand {
|
||||
public struct VolumeDelete: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "delete",
|
||||
abstract: "Delete one or more volumes",
|
||||
aliases: ["rm"]
|
||||
)
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Delete all volumes")
|
||||
var all = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Volume names")
|
||||
var names: [String] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
public func validate() throws {
|
||||
if names.count == 0 && !all {
|
||||
throw ContainerizationError(.invalidArgument, message: "no volumes specified and --all not supplied")
|
||||
}
|
||||
if names.count > 0 && all {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "explicitly supplied volume name(s) conflict with the --all flag"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func run() async throws {
|
||||
let uniqueVolumeNames = Set<String>(names)
|
||||
let volumes: [VolumeConfiguration]
|
||||
|
||||
if all {
|
||||
volumes = try await ClientVolume.list()
|
||||
} else {
|
||||
volumes = try await ClientVolume.list()
|
||||
.filter { v in
|
||||
uniqueVolumeNames.contains(v.id)
|
||||
}
|
||||
|
||||
// If one of the volumes requested isn't present lets throw. We don't need to do
|
||||
// this for --all as --all should be perfectly usable with no volumes to remove,
|
||||
// otherwise it'd be quite clunky.
|
||||
if volumes.count != uniqueVolumeNames.count {
|
||||
let missing = uniqueVolumeNames.filter { id in
|
||||
!volumes.contains { v in
|
||||
v.id == id
|
||||
}
|
||||
}
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "failed to delete one or more volumes: \(missing)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var failed = [String]()
|
||||
let _log = log
|
||||
try await withThrowingTaskGroup(of: VolumeConfiguration?.self) { group in
|
||||
for volume in volumes {
|
||||
group.addTask {
|
||||
do {
|
||||
try await ClientVolume.delete(name: volume.id)
|
||||
print(volume.id)
|
||||
return nil
|
||||
} catch {
|
||||
_log.error(
|
||||
"failed to delete volume",
|
||||
metadata: [
|
||||
"id": "\(volume.id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
return volume
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for try await volume in group {
|
||||
guard let volume else {
|
||||
continue
|
||||
}
|
||||
failed.append(volume.id)
|
||||
}
|
||||
}
|
||||
|
||||
if failed.count > 0 {
|
||||
throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
extension Application.VolumeCommand {
|
||||
public struct VolumeInspect: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "inspect",
|
||||
abstract: "Display information about one or more volumes"
|
||||
)
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
@Argument(help: "Volumes to inspect")
|
||||
var names: [String]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let uniqueNames = Set(names)
|
||||
let volumes = try await ClientVolume.list().filter { uniqueNames.contains($0.id) }
|
||||
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
|
||||
|
||||
if volumes.count != uniqueNames.count {
|
||||
let found = Set(volumes.map { $0.id })
|
||||
let missing = uniqueNames.subtracting(found).sorted()
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "volume not found: \(missing.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
|
||||
try Output.emit(Output.renderJSON(volumeResources, options: .pretty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerResource
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
|
||||
extension Application.VolumeCommand {
|
||||
public struct VolumeList: AsyncLoggableCommand {
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "list",
|
||||
abstract: "List volumes",
|
||||
aliases: ["ls"]
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Format of the output")
|
||||
var format: ListFormat = .table
|
||||
|
||||
@Flag(name: .shortAndLong, help: "Only output the volume name")
|
||||
var quiet: Bool = false
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public init() {}
|
||||
|
||||
public func run() async throws {
|
||||
let volumes = try await ClientVolume.list()
|
||||
let volumeResources = volumes.map { VolumeResource(configuration: $0) }
|
||||
try Output.render(payload: volumeResources, display: volumeResources, format: format, quiet: quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import Foundation
|
||||
|
||||
extension Application.VolumeCommand {
|
||||
public struct VolumePrune: AsyncLoggableCommand {
|
||||
public init() {}
|
||||
public static let configuration = CommandConfiguration(
|
||||
commandName: "prune",
|
||||
abstract: "Remove volumes with no container references")
|
||||
|
||||
@OptionGroup
|
||||
public var logOptions: Flags.Logging
|
||||
|
||||
public func run() async throws {
|
||||
let allVolumes = try await ClientVolume.list()
|
||||
|
||||
// Find all volumes not used by any container
|
||||
let client = ContainerClient()
|
||||
let containers = try await client.list()
|
||||
var volumesInUse = Set<String>()
|
||||
for container in containers {
|
||||
for mount in container.configuration.mounts {
|
||||
if mount.isVolume, let volumeName = mount.volumeName {
|
||||
volumesInUse.insert(volumeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let volumesToPrune = allVolumes.filter { volume in
|
||||
!volumesInUse.contains(volume.name)
|
||||
}
|
||||
|
||||
var prunedVolumes = [String]()
|
||||
var totalSize: UInt64 = 0
|
||||
|
||||
for volume in volumesToPrune {
|
||||
do {
|
||||
let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name)
|
||||
totalSize += actualSize
|
||||
try await ClientVolume.delete(name: volume.name)
|
||||
prunedVolumes.append(volume.name)
|
||||
} catch {
|
||||
log.error(
|
||||
"failed to prune volume",
|
||||
metadata: [
|
||||
"id": "\(volume.name)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
for name in prunedVolumes {
|
||||
print(name)
|
||||
}
|
||||
|
||||
let formatter = ByteCountFormatter()
|
||||
let freed = formatter.string(fromByteCount: Int64(totalSize))
|
||||
log.info("Reclaimed \(freed) in disk space")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerResource
|
||||
|
||||
extension VolumeResource: ListDisplayable {
|
||||
public static var tableHeader: [String] {
|
||||
["NAME", "TYPE", "DRIVER", "OPTIONS"]
|
||||
}
|
||||
|
||||
public var tableRow: [String] {
|
||||
[
|
||||
name,
|
||||
isAnonymous ? "anonymous" : "named",
|
||||
configuration.driver,
|
||||
configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","),
|
||||
]
|
||||
}
|
||||
|
||||
public var quietValue: String {
|
||||
name
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user