diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index 769a28809..6b41664de 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -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) } } @@ -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] { @@ -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.. 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")) + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift index 9e02b68c4..56f0b87b4 100644 --- a/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift @@ -14,11 +14,19 @@ // 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 { @@ -26,13 +34,15 @@ struct TestCLIStatsCommand { 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)") } } } @@ -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)") + } } } }