mirror of
https://github.com/apple/container.git
synced 2026-07-13 04:57:08 +00:00
f7d00aad48
This is not intended to be used to support `--filter` or similar on the CLIs list yet, it's solely to clean up our rather awkward use of `ContainerClient.list()` today in the CLI. The list RPC simply returns all of the containers we have created. Because of this, for a LOT of our commands we filter to what we need client side, which feels like a waste.. This change introduces a filter struct that we can provide an array of container IDs, labels, and the status of the containers to filter the `list()` output from. This additionally, because it was killing (pun not intended) me and I was already having to change this area for the `list()` additions, changes container kill slightly to return an error if you try and kill a container that doesn't exist.
273 lines
11 KiB
Swift
273 lines
11 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 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 == .json || 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)
|
|
}
|
|
|
|
try await runStreaming()
|
|
}
|
|
}
|
|
|
|
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 collectStats(client: client, for: containersToShow)
|
|
|
|
if format == .json {
|
|
let jsonStats = statsData.map { $0.stats2 }
|
|
let data = try JSONEncoder().encode(jsonStats)
|
|
print(String(decoding: data, as: UTF8.self))
|
|
return
|
|
}
|
|
|
|
printStatsTable(statsData)
|
|
}
|
|
|
|
private func runStreaming() async throws {
|
|
let client = ContainerClient()
|
|
|
|
// If containers were specified, validate they all exist upfront
|
|
if !containers.isEmpty {
|
|
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containers))
|
|
for containerId in containers {
|
|
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "no such container: \(containerId)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
clearScreen()
|
|
// Show header right away.
|
|
printStatsTable([])
|
|
|
|
while true {
|
|
do {
|
|
let containersToShow: [ContainerSnapshot]
|
|
if containers.isEmpty {
|
|
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
|
|
} else {
|
|
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
|
|
}
|
|
|
|
let statsData = try await collectStats(client: client, for: containersToShow)
|
|
|
|
// Clear screen and reprint
|
|
clearScreen()
|
|
printStatsTable(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 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 func printStatsTable(_ statsData: [StatsSnapshot]) {
|
|
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
|
|
let formatter = TableOutput(rows: rows)
|
|
print(formatter.format())
|
|
}
|
|
|
|
private 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)
|
|
}
|
|
}
|
|
}
|