Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 77 additions & 15 deletions Sources/ContainerCommands/Container/ContainerStats.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ extension Application {

let statsData = try await Self.collectStats(client: client, for: containersToShow)

try Output.render(payload: statsData.map { $0.stats2 }, format: format) {
try Output.render(payload: statsData.map { StatsReport(snapshot: $0) }, format: format) {
Self.statsTable(statsData)
}
}
Expand Down Expand Up @@ -153,10 +153,79 @@ extension Application {
}
}

private struct StatsSnapshot {
fileprivate struct StatsSnapshot {
let container: ContainerSnapshot
let stats1: ContainerResource.ContainerStats
let stats2: ContainerResource.ContainerStats

/// Percent of one core, matching `top` and `docker stats`: 400% is four cores
/// saturated. Deliberately not divided by the container's CPU allocation.
var cpuPercent: Double? {
guard let first = stats1.cpuUsageUsec, let second = stats2.cpuUsageUsec else {
return nil
}
return ContainerStats.calculateCPUPercent(
cpuUsage1: .microseconds(first),
cpuUsage2: .microseconds(second),
timeInterval: ContainerStats.sampleInterval
)
}
}

/// `collectStats` sleeps for this between samples and the percentage divides by it,
/// so the two must not drift apart.
static let sampleInterval: Duration = .seconds(2)

/// The payload for the machine-readable formats: the second sample plus the CPU
/// percentage derived from both.
///
/// `ContainerResource.ContainerStats` models a single sample, and a rate is not a
/// property of one sample, so the derived value lives here rather than widening that
/// type. The sample is wrapped and flattened on encode so the stored shape stays
/// additive (`cpuPercent` only) without copying every sample field onto this type.
///
/// Encoding goes through one keyed container. TOMLEncoder replaces `encoder.value`
/// on each `container(keyedBy:)` call, so `stats.encode(to:)` followed by a second
/// container would drop the sample fields.
struct StatsReport: Encodable {
let stats: ContainerResource.ContainerStats
let cpuPercent: Double?

fileprivate init(snapshot: StatsSnapshot) {
self.init(stats: snapshot.stats2, cpuPercent: snapshot.cpuPercent)
}

init(stats: ContainerResource.ContainerStats, cpuPercent: Double?) {
self.stats = stats
self.cpuPercent = cpuPercent
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(stats.id, forKey: .id)
try container.encodeIfPresent(stats.cpuUsageUsec, forKey: .cpuUsageUsec)
try container.encodeIfPresent(stats.memoryUsageBytes, forKey: .memoryUsageBytes)
try container.encodeIfPresent(stats.memoryLimitBytes, forKey: .memoryLimitBytes)
try container.encodeIfPresent(stats.networkRxBytes, forKey: .networkRxBytes)
try container.encodeIfPresent(stats.networkTxBytes, forKey: .networkTxBytes)
try container.encodeIfPresent(stats.blockReadBytes, forKey: .blockReadBytes)
try container.encodeIfPresent(stats.blockWriteBytes, forKey: .blockWriteBytes)
try container.encodeIfPresent(stats.numProcesses, forKey: .numProcesses)
try container.encodeIfPresent(cpuPercent, forKey: .cpuPercent)
}

private enum CodingKeys: String, CodingKey {
case id
case cpuPercent
case cpuUsageUsec
case memoryUsageBytes
case memoryLimitBytes
case networkRxBytes
case networkTxBytes
case blockReadBytes
case blockWriteBytes
case numProcesses
}
}

private static func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
Expand All @@ -176,7 +245,7 @@ extension Application {

// Wait 2 seconds for CPU delta calculation
if !snapshots.isEmpty {
try await Task.sleep(for: .seconds(2))
try await Task.sleep(for: Self.sampleInterval)

// Second sample
for i in 0..<snapshots.count {
Expand All @@ -199,9 +268,9 @@ extension Application {

/// 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
/// - cpuUsage1: CPU usage from the first sample
/// - cpuUsage2: CPU usage from the second sample
/// - timeInterval: Wall-clock interval between samples
/// - Returns: CPU percentage where 100% = one fully utilized core
static func calculateCPUPercent(
cpuUsage1: Duration,
Expand Down Expand Up @@ -238,17 +307,10 @@ extension Application {

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)
if let cpuPercent = snapshot.cpuPercent {
row.append(String(format: "%.2f%%", cpuPercent))
} else {
row.append(notAvailable)
}
Expand Down
154 changes: 154 additions & 0 deletions Tests/ContainerCommandsTests/StatsReportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
//===----------------------------------------------------------------------===//
// 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
import Testing

@testable import ContainerCommands

private func makeSampleStats(
id: String = "busy",
memoryUsageBytes: UInt64? = 1024,
memoryLimitBytes: UInt64? = 256 * 1024 * 1024,
cpuUsageUsec: UInt64? = 2_000_000,
networkRxBytes: UInt64? = 10,
networkTxBytes: UInt64? = 20,
blockReadBytes: UInt64? = 30,
blockWriteBytes: UInt64? = 40,
numProcesses: UInt64? = 1
) -> ContainerResource.ContainerStats {
ContainerResource.ContainerStats(
id: id,
memoryUsageBytes: memoryUsageBytes,
memoryLimitBytes: memoryLimitBytes,
cpuUsageUsec: cpuUsageUsec,
networkRxBytes: networkRxBytes,
networkTxBytes: networkTxBytes,
blockReadBytes: blockReadBytes,
blockWriteBytes: blockWriteBytes,
numProcesses: numProcesses
)
}

private func jsonObject(from report: Application.ContainerStats.StatsReport) throws -> [String: Any] {
let data = try JSONEncoder().encode(report)
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
}

struct CalculateCPUPercentTests {
@Test
func oneCoreFullyUtilizedIs100Percent() {
let percent = Application.ContainerStats.calculateCPUPercent(
cpuUsage1: .seconds(0),
cpuUsage2: .seconds(2),
timeInterval: Application.ContainerStats.sampleInterval
)
#expect(percent == 100.0)
}

@Test
func fourCoresSaturatedIs400Percent() {
let percent = Application.ContainerStats.calculateCPUPercent(
cpuUsage1: .seconds(0),
cpuUsage2: .seconds(8),
timeInterval: Application.ContainerStats.sampleInterval
)
#expect(percent == 400.0)
}

@Test
func unchangedUsageIsZero() {
let percent = Application.ContainerStats.calculateCPUPercent(
cpuUsage1: .milliseconds(500),
cpuUsage2: .milliseconds(500),
timeInterval: Application.ContainerStats.sampleInterval
)
#expect(percent == 0.0)
}

@Test
func usageDecreaseIsTreatedAsZero() {
let percent = Application.ContainerStats.calculateCPUPercent(
cpuUsage1: .seconds(4),
cpuUsage2: .seconds(1),
timeInterval: Application.ContainerStats.sampleInterval
)
#expect(percent == 0.0)
}
}

struct StatsReportEncodingTests {
@Test
func jsonIncludesCpuPercentAndForwardsEverySampleField() throws {
let stats = makeSampleStats()
let report = Application.ContainerStats.StatsReport(stats: stats, cpuPercent: 42.5)
let sample = try #require(
JSONSerialization.jsonObject(with: JSONEncoder().encode(stats)) as? [String: Any]
)
let encoded = try jsonObject(from: report)

for key in sample.keys {
#expect(encoded[key] != nil, "StatsReport dropped sample field \(key)")
}
#expect(encoded["stats"] == nil, "sample should be flattened, not nested under stats")
#expect(encoded["cpuPercent"] as? Double == 42.5)
#expect(encoded["id"] as? String == "busy")

let decoded = try JSONDecoder().decode(
ContainerResource.ContainerStats.self,
from: JSONEncoder().encode(report)
)
#expect(decoded.id == stats.id)
#expect(decoded.memoryUsageBytes == stats.memoryUsageBytes)
#expect(decoded.cpuUsageUsec == stats.cpuUsageUsec)
}

@Test
func jsonOmitsNilCpuPercent() throws {
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: nil)
let encoded = try jsonObject(from: report)
#expect(encoded["cpuPercent"] == nil)
#expect(encoded["id"] as? String == "busy")
}

@Test
func renderJSONIncludesCpuPercent() throws {
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
let json = try Output.renderJSON([report])
#expect(json.contains("\"cpuPercent\":12.25"))
#expect(json.contains("\"id\":\"busy\""))
}

@Test
func renderYAMLIncludesCpuPercent() throws {
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
let yaml = try Output.renderYAML([report])
#expect(yaml.contains("cpuPercent"))
#expect(yaml.contains("busy"))
#expect(yaml.contains("memoryUsageBytes"))
}

@Test
func renderTOMLIncludesCpuPercent() throws {
let report = Application.ContainerStats.StatsReport(stats: makeSampleStats(), cpuPercent: 12.25)
let toml = try Output.renderTOML([report])
#expect(toml.contains("cpuPercent"))
#expect(toml.contains("12.25"))
#expect(toml.contains("busy"))
#expect(toml.contains("memoryUsageBytes"))
}
}
20 changes: 17 additions & 3 deletions Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,35 @@
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerResource
import ContainerTestSupport
import Foundation
import Testing

/// Machine-readable stats payload, including the derived `cpuPercent` that
/// `ContainerResource.ContainerStats` does not carry.
private struct StatsJSON: Decodable {
let id: String
let cpuPercent: Double?
let memoryUsageBytes: UInt64?
let numProcesses: UInt64?
}

@Suite
struct TestCLIStatsCommand {
@Test func testStatsNoStreamJSONFormat() async throws {
try await ContainerFixture.with { f in
let image = WarmupImage.alpine320.rawValue
try await f.withContainer(image: image) { name in
let result = try f.run(["stats", "--format", "json", "--no-stream", name]).check()
let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData)
let stats = try JSONDecoder().decode([StatsJSON].self, from: result.outputData)
#expect(stats.count == 1, "expected stats for one container")
#expect(stats[0].id == name, "container ID should match")
let memoryUsageBytes = try #require(stats[0].memoryUsageBytes)
let numProcesses = try #require(stats[0].numProcesses)
let cpuPercent = try #require(stats[0].cpuPercent, "json should include cpuPercent")
#expect(memoryUsageBytes > 0, "memory usage should be non-zero")
#expect(numProcesses >= 1, "should have at least one process")
#expect(cpuPercent >= 0, "cpuPercent should be non-negative, got \(cpuPercent)")
}
}
}
Expand Down Expand Up @@ -94,11 +104,15 @@ struct TestCLIStatsCommand {
try await f.withContainer(image: image, tag: "c1") { name1 in
try await f.withContainer(image: image, tag: "c2") { name2 in
let result = try f.run(["stats", "--format", "json", "--no-stream"]).check()
let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData)
let stats = try JSONDecoder().decode([StatsJSON].self, from: result.outputData)
try #require(stats.count >= 2, "should have stats for at least 2 containers")
let ids = stats.map { $0.id }
#expect(ids.contains(name1), "should include first container")
#expect(ids.contains(name2), "should include second container")
for row in stats where ids.contains(row.id) {
let cpuPercent = try #require(row.cpuPercent, "json should include cpuPercent for \(row.id)")
#expect(cpuPercent >= 0, "cpuPercent should be non-negative, got \(cpuPercent)")
}
}
}
}
Expand Down