From 7c59b827b1fcdf9193e82284226e81b88585d1d8 Mon Sep 17 00:00:00 2001 From: Nico Emanuelli Date: Wed, 8 Jul 2026 10:13:12 +0200 Subject: [PATCH] Add container machine stats command A running container machine is backed by a container, so its resource usage is already available through the existing container stats path. Expose it with a new `container machine stats []` subcommand. - Extract the sampling, CPU%/byte formatting, table rendering, and streaming loop out of the private internals of `container stats` into a shared StatsRendering helper, parametrized by the id-column header, and refactor ContainerStats onto it with no behavior change. - Add MachineStats, mirroring `machine inspect` for target resolution and reusing StatsRendering. It resolves the machine's backing container id, supports table/json/yaml/toml and live streaming with --no-stream, and labels output with the machine id. - Add unit tests for the formatting/CPU helpers (previously untested) and an integration test for the new command. - Document the command in command-reference.md and container-machine.md. --- .../Container/ContainerStats.swift | 243 +++--------------- .../Machine/MachineCommand.swift | 4 + .../Machine/MachineStats.swift | 91 +++++++ .../ContainerCommands/StatsRendering.swift | 243 ++++++++++++++++++ .../StatsRenderingTests.swift | 143 +++++++++++ .../Machine/TestCLIMachineStats.swift | 78 ++++++ docs/command-reference.md | 32 +++ docs/container-machine.md | 10 + 8 files changed, 631 insertions(+), 213 deletions(-) create mode 100644 Sources/ContainerCommands/Machine/MachineStats.swift create mode 100644 Sources/ContainerCommands/StatsRendering.swift create mode 100644 Tests/ContainerCommandsTests/StatsRenderingTests.swift create mode 100644 Tests/IntegrationTests/Machine/TestCLIMachineStats.swift diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift index 769a28809..988a79838 100644 --- a/Sources/ContainerCommands/Container/ContainerStats.swift +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -18,9 +18,6 @@ import ArgumentParser import ContainerAPIClient import ContainerResource import ContainerizationError -import ContainerizationExtras -import ContainerizationOS -import Foundation extension Application { public struct ContainerStats: AsyncLoggableCommand { @@ -44,37 +41,29 @@ extension Application { public func run() async throws { if format != .table || noStream { - // Static mode - get stats once and exit + // 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) + // Streaming mode - continuously update like top. + let client = ContainerClient() + let containerIds = containers - defer { - // Exit alternate screen buffer and show cursor again - print("\u{001B}[?25h\u{001B}[?1049l", terminator: "") - fflush(stdout) + // Validate specified containers exist before entering the + // alternate screen buffer, so the error is visible. + if !containerIds.isEmpty { + let specified = try await client.list(filters: ContainerListFilters(ids: containerIds)) + try Self.validate(requested: containerIds, found: specified) } - 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 + try await StatsRendering.stream(idHeader: "Container ID") { [client, containerIds] in + 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 runningIds = containersToShow.filter { $0.status == .running }.map { $0.id } + return try await StatsRendering.collect(client: client, ids: runningIds) } } } @@ -84,201 +73,29 @@ extension Application { let containersToShow: [ContainerSnapshot] if containers.isEmpty { - // No containers specified - show all running containers + // No containers specified - show all running containers. containersToShow = try await client.list(filters: ContainerListFilters(status: .running)) } else { - // Fetch specified containers by ID + // Fetch specified containers by ID and validate they were all found. 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)" - ) - } - } + try Self.validate(requested: containers, found: containersToShow) } - let statsData = try await Self.collectStats(client: client, for: containersToShow) - - try Output.render(payload: statsData.map { $0.stats2 }, format: format) { - Self.statsTable(statsData) - } + let runningIds = containersToShow.filter { $0.status == .running }.map { $0.id } + let rows = try await StatsRendering.collect(client: client, ids: runningIds) + try StatsRendering.renderStatic(rows: rows, format: format, idHeader: "Container ID") } - 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.. 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) + /// Throws `.notFound` if any requested container id is missing from `found`. + private static func validate(requested: [String], found: [ContainerSnapshot]) throws { + for containerId in requested { + guard found.contains(where: { $0.id == containerId }) else { + throw ContainerizationError( + .notFound, + message: "no such container: \(containerId)" ) - 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) } } } diff --git a/Sources/ContainerCommands/Machine/MachineCommand.swift b/Sources/ContainerCommands/Machine/MachineCommand.swift index fe09bceed..9a15b4d62 100644 --- a/Sources/ContainerCommands/Machine/MachineCommand.swift +++ b/Sources/ContainerCommands/Machine/MachineCommand.swift @@ -31,6 +31,9 @@ extension Application { $ container machine run -n my-machine uname $ container machine run -n my-machine -- cat /proc/cpuinfo + Show resource usage statistics for the container machine: + $ container machine stats my-machine + Change the container machine configuration (takes effect after restart): $ container machine set -n my-machine cpus=4 memory=8G home-mount=ro $ container machine stop my-machine @@ -49,6 +52,7 @@ extension Application { MachineRun.self, MachineSet.self, MachineSetDefault.self, + MachineStats.self, MachineStop.self, ], aliases: ["m"] diff --git a/Sources/ContainerCommands/Machine/MachineStats.swift b/Sources/ContainerCommands/Machine/MachineStats.swift new file mode 100644 index 000000000..8f2fc1fd3 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineStats.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 MachineAPIClient + +extension Application { + public struct MachineStats: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "stats", + abstract: "Display resource usage statistics for a container machine") + + @Argument(help: "Container machine ID (uses default if not specified)") + var id: 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 { + let machineClient = MachineClient() + let machineId = try await resolveMachineId(id, client: machineClient) + + // A running container machine is itself backed by a container, so its + // resource usage is that container's stats. Resolve the backing + // container id up front, which also errors on a stopped or missing + // machine before any streaming UI is shown. + let snapshot = try await machineClient.inspect(id: machineId) + guard snapshot.status == .running else { + throw ContainerizationError( + .invalidState, + message: "container machine \(machineId) is not running" + ) + } + guard let containerId = snapshot.containerId else { + throw ContainerizationError( + .invalidState, + message: "container machine \(machineId) is running but has no container ID" + ) + } + + let containerClient = ContainerClient() + let idHeader = "Machine ID" + + if format != .table || noStream { + // Static mode - get stats once and exit. + let rows = try await Self.collect( + client: containerClient, containerId: containerId, machineId: machineId) + try StatsRendering.renderStatic(rows: rows, format: format, idHeader: idHeader) + } else { + // Streaming mode - continuously update like top. + try await StatsRendering.stream(idHeader: idHeader) { [containerClient, containerId, machineId] in + try await Self.collect( + client: containerClient, containerId: containerId, machineId: machineId) + } + } + } + + /// Samples the machine's backing container, labeling the resulting row + /// with the machine id rather than the internal container id. + private static func collect( + client: ContainerClient, containerId: String, machineId: String + ) async throws -> [StatsRendering.Row] { + try await StatsRendering.collect(client: client, ids: [containerId]) + .map { StatsRendering.Row(id: machineId, stats1: $0.stats1, stats2: $0.stats2) } + } + } +} diff --git a/Sources/ContainerCommands/StatsRendering.swift b/Sources/ContainerCommands/StatsRendering.swift new file mode 100644 index 000000000..7c803e835 --- /dev/null +++ b/Sources/ContainerCommands/StatsRendering.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerAPIClient +import ContainerResource +import ContainerizationOS +import Foundation + +/// Shared sampling and rendering for the `stats` commands. +/// +/// `container stats` (all running containers) and `container machine stats` (a +/// machine's backing container) display the same resource-usage columns, +/// derived from two samples taken `sampleInterval` apart. This type holds the +/// logic they share so the two commands stay in lockstep. +enum StatsRendering { + /// A container's two stat samples plus the identifier to display for it. + /// + /// `id` is the label shown in the first table column — the container id for + /// `container stats`, the machine id for `container machine stats` — which + /// need not be the id the samples were collected under. + struct Row: Sendable { + let id: String + let stats1: ContainerResource.ContainerStats + let stats2: ContainerResource.ContainerStats + } + + /// Time between the two samples used to derive the CPU percentage. + static let sampleInterval: Duration = .seconds(2) + + /// Collects two samples for each container id, `sampleInterval` apart. + /// + /// Every id is sampled once, then — after a single wait — sampled again, so + /// the interval is shared across ids rather than paid per id. Ids whose + /// first sample fails are dropped; ids whose second sample fails keep the + /// first sample for both (their CPU shows 0%). + static func collect(client: ContainerClient, ids: [String]) async throws -> [Row] { + var rows: [Row] = [] + + // First sample. + for id in ids { + do { + let stats1 = try await client.stats(id: id) + rows.append(Row(id: id, stats1: stats1, stats2: stats1)) + } catch { + // Skip ids that error out. + continue + } + } + + // Wait once for the CPU delta, then take the second sample. + if !rows.isEmpty { + try await Task.sleep(for: sampleInterval) + + for i in 0.. 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) + } + } + + /// Renders the stats rows as a column-aligned table. + /// + /// `idHeader` names the first column (e.g. `"Container ID"` or + /// `"Machine ID"`). The header row is always emitted, even with no rows. + static func table(_ rows: [Row], idHeader: String) -> String { + let headerRow = [idHeader, "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"] + let notAvailable = "--" + var tableRows = [headerRow] + + for row in rows { + var cells = [row.id] + let stats1 = row.stats1 + let stats2 = row.stats2 + + if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec { + let cpuPercent = calculateCPUPercent( + cpuUsage1: .microseconds(cpuUsageUsec1), + cpuUsage2: .microseconds(cpuUsageUsec2), + timeInterval: sampleInterval + ) + cells.append(String(format: "%.2f%%", cpuPercent)) + } else { + cells.append(notAvailable) + } + + let memUsageStr = stats2.memoryUsageBytes.map { formatBytes($0) } ?? notAvailable + let memLimitStr = stats2.memoryLimitBytes.map { formatBytes($0) } ?? notAvailable + cells.append("\(memUsageStr) / \(memLimitStr)") + + let netRxStr = stats2.networkRxBytes.map { formatBytes($0) } ?? notAvailable + let netTxStr = stats2.networkTxBytes.map { formatBytes($0) } ?? notAvailable + cells.append("\(netRxStr) / \(netTxStr)") + + let blkReadStr = stats2.blockReadBytes.map { formatBytes($0) } ?? notAvailable + let blkWriteStr = stats2.blockWriteBytes.map { formatBytes($0) } ?? notAvailable + cells.append("\(blkReadStr) / \(blkWriteStr)") + + let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable + cells.append(pidsStr) + + tableRows.append(cells) + } + + // Always print header, even if there are no rows. + return TableOutput(rows: tableRows).format() + } + + /// Renders one snapshot of stats in the requested format and returns. + /// + /// The machine-readable payload is each row's second sample (matching the + /// table's metric columns); the derived CPU percentage appears only in the + /// table. + static func renderStatic(rows: [Row], format: ListFormat, idHeader: String) throws { + // Encode each row's second sample, labeled with the row's display id so + // the machine-readable output identifies rows the same way the table + // does (the machine id for `container machine stats`). For + // `container stats` the display id already equals the stats id, so this + // is a no-op there. + let payload = rows.map { row -> ContainerResource.ContainerStats in + var stats = row.stats2 + stats.id = row.id + return stats + } + try Output.render(payload: payload, format: format) { + table(rows, idHeader: idHeader) + } + } + + /// Continuously renders stats like `top`, refreshing on each `fetch()`. + /// + /// Switches the terminal to the alternate screen buffer and hides the + /// cursor, restoring both on exit — including on `SIGINT`/`SIGTERM`. `fetch` + /// produces the rows for each frame. + static func stream(idHeader: String, fetch: @Sendable @escaping () async throws -> [Row]) async throws { + // 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 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 { + clearScreen() + // Show header right away. + print(table([], idHeader: idHeader)) + + while true { + do { + let rows = try await fetch() + + // Clear screen and reprint. + clearScreen() + print(table(rows, idHeader: idHeader)) + + if rows.isEmpty { + try await Task.sleep(for: sampleInterval) + } + } catch { + clearScreen() + print("error collecting stats: \(error)") + try await Task.sleep(for: sampleInterval) + } + } + } + do { + try await group.next() + } catch is CancellationError { + // Normal exit on signal; defer restores the terminal. + } + } + } + + 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) + } +} diff --git a/Tests/ContainerCommandsTests/StatsRenderingTests.swift b/Tests/ContainerCommandsTests/StatsRenderingTests.swift new file mode 100644 index 000000000..ef482d48d --- /dev/null +++ b/Tests/ContainerCommandsTests/StatsRenderingTests.swift @@ -0,0 +1,143 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerCommands + +struct StatsRenderingFormatBytesTests { + @Test + func bytesBelowOneKiBStillRenderAsKiB() { + #expect(StatsRendering.formatBytes(0) == "0.00 KiB") + #expect(StatsRendering.formatBytes(512) == "0.50 KiB") + } + + @Test + func unitBoundaries() { + #expect(StatsRendering.formatBytes(1024) == "1.00 KiB") + #expect(StatsRendering.formatBytes(1024 * 1024) == "1.00 MiB") + #expect(StatsRendering.formatBytes(1024 * 1024 * 1024) == "1.00 GiB") + } + + @Test + func fractionalUnits() { + #expect(StatsRendering.formatBytes(1536) == "1.50 KiB") + #expect(StatsRendering.formatBytes(1024 * 1024 * 3 / 2) == "1.50 MiB") + } +} + +struct StatsRenderingCPUPercentTests { + @Test + func zeroWhenNoDelta() { + #expect( + StatsRendering.calculateCPUPercent( + cpuUsage1: .seconds(1), cpuUsage2: .seconds(1), timeInterval: .seconds(2)) == 0.0) + } + + @Test + func clampsNegativeDeltaToZero() { + #expect( + StatsRendering.calculateCPUPercent( + cpuUsage1: .seconds(2), cpuUsage2: .seconds(1), timeInterval: .seconds(2)) == 0.0) + } + + @Test + func oneFullyUsedCoreIsOneHundredPercent() { + #expect( + StatsRendering.calculateCPUPercent( + cpuUsage1: .seconds(0), cpuUsage2: .seconds(2), timeInterval: .seconds(2)) == 100.0) + } + + @Test + func halfUsedCoreIsFiftyPercent() { + #expect( + StatsRendering.calculateCPUPercent( + cpuUsage1: .seconds(0), cpuUsage2: .seconds(1), timeInterval: .seconds(2)) == 50.0) + } + + @Test + func multipleCoresExceedOneHundredPercent() { + #expect( + StatsRendering.calculateCPUPercent( + cpuUsage1: .seconds(0), cpuUsage2: .seconds(4), timeInterval: .seconds(2)) == 200.0) + } +} + +struct StatsRenderingTableTests { + private func makeStats( + cpuUsec: UInt64?, + memUsage: UInt64? = nil, + memLimit: UInt64? = nil, + rx: UInt64? = nil, + tx: UInt64? = nil, + blockRead: UInt64? = nil, + blockWrite: UInt64? = nil, + pids: UInt64? = nil + ) -> ContainerStats { + ContainerStats( + id: "backing-container", + memoryUsageBytes: memUsage, + memoryLimitBytes: memLimit, + cpuUsageUsec: cpuUsec, + networkRxBytes: rx, + networkTxBytes: tx, + blockReadBytes: blockRead, + blockWriteBytes: blockWrite, + numProcesses: pids) + } + + @Test + func headerUsesProvidedIdColumnLabelAndPrintsWithNoRows() { + let out = StatsRendering.table([], idHeader: "Machine ID") + #expect(out.contains("Machine ID")) + #expect(out.contains("Cpu %")) + #expect(out.contains("Memory Usage")) + #expect(out.contains("Net Rx/Tx")) + #expect(out.contains("Block I/O")) + #expect(out.contains("Pids")) + // Header only: a single line, no data rows. + #expect(out.split(separator: "\n").count == 1) + } + + @Test + func rowRendersDisplayIdAndFormattedMetrics() { + // 2s of CPU time over the 2s sample interval == one saturated core. + let sample1 = makeStats(cpuUsec: 0) + let sample2 = makeStats( + cpuUsec: 2_000_000, + memUsage: 1024 * 1024, + memLimit: 2 * 1024 * 1024, + rx: 0, tx: 0, blockRead: 0, blockWrite: 0, + pids: 5) + let out = StatsRendering.table( + [.init(id: "my-machine", stats1: sample1, stats2: sample2)], + idHeader: "Machine ID") + #expect(out.contains("my-machine")) // display id, not the container id + #expect(out.contains("100.00%")) + #expect(out.contains("1.00 MiB / 2.00 MiB")) + #expect(out.contains("5")) // pids + } + + @Test + func missingMetricsRenderAsDashes() { + let sample = makeStats(cpuUsec: nil) + let out = StatsRendering.table( + [.init(id: "m", stats1: sample, stats2: sample)], + idHeader: "Machine ID") + #expect(out.contains("--")) + } +} diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineStats.swift b/Tests/IntegrationTests/Machine/TestCLIMachineStats.swift new file mode 100644 index 000000000..618781f97 --- /dev/null +++ b/Tests/IntegrationTests/Machine/TestCLIMachineStats.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// 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 + +@Suite +struct TestCLIMachineStats { + private let machineImage = "ghcr.io/linuxcontainers/alpine:3.20" + + @Test func testMachineStatsNoStreamJSONFormat() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + + let result = try f.runMachine(["stats", "--no-stream", "--format", "json", name]).check() + let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) + #expect(stats.count == 1, "expected stats for one machine") + // The machine-readable payload identifies the row by the machine id, + // not the internal backing-container id. + #expect(stats[0].id == name, "stats id should be the machine id") + let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) + #expect(memoryUsageBytes > 0, "memory usage should be non-zero") + let numProcesses = try #require(stats[0].numProcesses) + #expect(numProcesses >= 1, "should have at least one process") + } + } + + @Test func testMachineStatsTableFormat() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + + let result = try f.runMachine(["stats", "--no-stream", name]).check() + #expect(result.output.contains("Machine ID"), "output should contain the machine table header") + #expect(result.output.contains("Cpu %"), "output should contain the CPU column") + #expect(result.output.contains("Memory Usage"), "output should contain the Memory column") + #expect(result.output.contains(name), "output should contain the machine name") + } + } + + @Test func testMachineStatsNotRunning() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + // Created but never booted, so it is not running. + try f.doMachineCreate(name: name, image: machineImage) + + let result = try f.runMachine(["stats", "--no-stream", name]) + #expect(result.status != 0, "stats should fail for a machine that is not running") + } + } + + @Test func testMachineStatsNonExistent() async throws { + try await ContainerFixture.with { f in + let result = try f.runMachine(["stats", "--no-stream", "nonexistent-machine-xyz"]) + #expect(result.status != 0, "stats should fail for a non-existent machine") + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 4066e3a38..71a966c8c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1199,6 +1199,38 @@ container machine inspect [--debug] [] No options. +### `container machine stats` + +Displays real-time resource usage statistics for a container machine. A running container machine is itself backed by a container, so this reports that container's CPU percentage, memory usage, network I/O, block I/O, and process count. By default it continuously updates in an interactive display (like `top`); use `--no-stream` for a single snapshot. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine stats [--format ] [--no-stream] [--debug] [] +``` + +**Arguments** + +* ``: Container machine ID (uses default if not specified) + +**Options** + +* `--format `: Format of the output (values: json, table, yaml, toml; default: table) +* `--no-stream`: Disable streaming stats and only pull the first result + +**Examples** + +```bash +# show stats for the default container machine (interactive) +container machine stats + +# show stats for a named container machine +container machine stats my-machine + +# get a single snapshot as JSON +container machine stats --format json --no-stream my-machine +``` + ### `container machine set` Sets configuration values on a container machine. Changes take effect after the container machine is stopped and restarted. Uses the default container machine if no ID is given. diff --git a/docs/container-machine.md b/docs/container-machine.md index 81e36e562..8aa6dd003 100644 --- a/docs/container-machine.md +++ b/docs/container-machine.md @@ -60,6 +60,16 @@ container machine rm dev # delete, including its persistent storage `container machine` has the alias `m`, so `m ls`, `m run`, etc. all work. +### Monitor resource usage + +`container machine stats` shows live CPU, memory, network, and block I/O for a running container machine, like `top`. Use `--no-stream` for a single snapshot: + +```bash +container machine stats dev # interactive, updates live +container machine stats --no-stream dev # one snapshot +container machine stats --no-stream --format json dev +``` + ### Resize CPUs, memory, or change the home-mount `container machine set` updates configuration on disk. Changes take effect after the next stop and start: