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,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user