mirror of
https://github.com/apple/container.git
synced 2026-07-14 05:27:02 +00:00
d6f052d206
## Motivation and Context
Now that we're in 2026, we need to update the license headers on all the
files. Unfortunately, Hawkeye doesn't have an attribute for the current
year to help us avoid this in the future. Instead, I had to work around
this by doing the following:
1. Update licenserc.toml with:
```
[properties]
... (other properties)
currentYear = "2026"
```
2. Update scripts/license-header.txt with
```
Copyright ©{{ " " }}{%- set created = attrs.git_file_created_year or
attrs.disk_file_created_year -%}{%- set modified = props["currentYear"]
-%}{%- if created != modified -%} {{created}}-{{modified}}{%- else
-%}{{created}}{%- endif -%}{{ " " }}{{ props["copyrightOwner"] }}.
```
Then I removed these two changes before committing. After this PR is
merged, all files will have recently had git updates, so the existing
code for setting the modified year should work as intended.
Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
263 lines
10 KiB
Swift
263 lines
10 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 ContainerClient
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import Foundation
|
|
|
|
extension Application {
|
|
public struct ContainerStats: AsyncParsableCommand {
|
|
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
|
|
var global: Flags.Global
|
|
|
|
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 allContainers = try await ClientContainer.list()
|
|
|
|
let containersToShow: [ClientContainer]
|
|
if containers.isEmpty {
|
|
// No containers specified - show all running containers
|
|
containersToShow = allContainers.filter { $0.status == .running }
|
|
} else {
|
|
// Validate all specified containers exist before proceeding
|
|
var found: [ClientContainer] = []
|
|
for containerId in containers {
|
|
guard let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "no such container: \(containerId)"
|
|
)
|
|
}
|
|
found.append(container)
|
|
}
|
|
containersToShow = found
|
|
}
|
|
|
|
let statsData = try await collectStats(for: containersToShow)
|
|
|
|
if format == .json {
|
|
let jsonStats = statsData.map { $0.stats2 }
|
|
let data = try JSONEncoder().encode(jsonStats)
|
|
print(String(data: data, encoding: .utf8)!)
|
|
return
|
|
}
|
|
|
|
printStatsTable(statsData)
|
|
}
|
|
|
|
private func runStreaming() async throws {
|
|
// If containers were specified, validate they all exist upfront
|
|
if !containers.isEmpty {
|
|
let allContainers = try await ClientContainer.list()
|
|
for containerId in containers {
|
|
guard allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) != nil else {
|
|
throw ContainerizationError(
|
|
.notFound,
|
|
message: "no such container: \(containerId)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
clearScreen()
|
|
// Show header right away.
|
|
printStatsTable([])
|
|
|
|
while true {
|
|
do {
|
|
let allContainers = try await ClientContainer.list()
|
|
|
|
let containersToShow: [ClientContainer]
|
|
if containers.isEmpty {
|
|
containersToShow = allContainers.filter { $0.status == .running }
|
|
} else {
|
|
var found: [ClientContainer] = []
|
|
for containerId in containers {
|
|
if let container = allContainers.first(where: { $0.id == containerId || $0.id.starts(with: containerId) }) {
|
|
found.append(container)
|
|
}
|
|
}
|
|
containersToShow = found
|
|
}
|
|
|
|
let statsData = try await collectStats(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: ClientContainer
|
|
let stats1: ContainerClient.ContainerStats
|
|
let stats2: ContainerClient.ContainerStats
|
|
}
|
|
|
|
private func collectStats(for containers: [ClientContainer]) async throws -> [StatsSnapshot] {
|
|
var snapshots: [StatsSnapshot] = []
|
|
|
|
// First sample
|
|
for container in containers {
|
|
guard container.status == .running else { continue }
|
|
do {
|
|
let stats1 = try await container.stats()
|
|
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 snapshots[i].container.stats()
|
|
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(
|
|
cpuUsageUsec1: UInt64,
|
|
cpuUsageUsec2: UInt64,
|
|
timeDeltaUsec: UInt64
|
|
) -> Double {
|
|
let cpuDelta =
|
|
cpuUsageUsec2 > cpuUsageUsec1
|
|
? cpuUsageUsec2 - cpuUsageUsec1
|
|
: 0
|
|
return (Double(cpuDelta) / Double(timeDeltaUsec)) * 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 header = [["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]]
|
|
var rows = header
|
|
|
|
for snapshot in statsData {
|
|
let stats1 = snapshot.stats1
|
|
let stats2 = snapshot.stats2
|
|
|
|
let cpuPercent = Self.calculateCPUPercent(
|
|
cpuUsageUsec1: stats1.cpuUsageUsec,
|
|
cpuUsageUsec2: stats2.cpuUsageUsec,
|
|
timeDeltaUsec: 2_000_000 // 2 seconds in microseconds
|
|
)
|
|
let cpuStr = String(format: "%.2f%%", cpuPercent)
|
|
|
|
let memUsageStr = "\(Self.formatBytes(stats2.memoryUsageBytes)) / \(Self.formatBytes(stats2.memoryLimitBytes))"
|
|
let netStr = "\(Self.formatBytes(stats2.networkRxBytes)) / \(Self.formatBytes(stats2.networkTxBytes))"
|
|
let blockStr = "\(Self.formatBytes(stats2.blockReadBytes)) / \(Self.formatBytes(stats2.blockWriteBytes))"
|
|
|
|
let pidsStr = "\(stats2.numProcesses)"
|
|
|
|
rows.append([snapshot.container.id, cpuStr, memUsageStr, netStr, blockStr, pidsStr])
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|