From 22baee24df6a16899f681850be8d4bbfa049399f Mon Sep 17 00:00:00 2001 From: muk2 Date: Sat, 13 Jun 2026 04:16:28 +0000 Subject: [PATCH 1/4] Add `container info` command Implements issue #815: a top-level `container info` command (like `docker info`) that reports system-wide information so tools can use `container` as a drop-in via `ln -s container docker`. The command gathers, from real APIs only: - CLI version/build/commit (ReleaseVersion) - Host architecture, OS, CPU count (Arch / ProcessInfo) - API server status/version/build/commit and app/install/log roots (ClientHealthCheck) - Container counts (total/running) and image count when the daemon is up (ContainerClient.list / ClientImage.list) - Default registry domain, kernel binary path, container CPU/memory, builder image, init image, and DNS domain (ContainerSystemConfig) Supports the standard `--format table|json|yaml|toml` output flag used by other commands. Daemon-dependent sections are omitted gracefully when the API server is not running. Fixes #815 --- Sources/ContainerCommands/Application.swift | 2 + .../ContainerCommands/System/SystemInfo.swift | 217 ++++++++++++++++++ .../SystemInfoTests.swift | 123 ++++++++++ 3 files changed, 342 insertions(+) create mode 100644 Sources/ContainerCommands/System/SystemInfo.swift create mode 100644 Tests/ContainerCommandsTests/SystemInfoTests.swift diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index c77f51a59..2490addba 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -224,6 +224,7 @@ public struct Application: AsyncLoggableCommand { guard #available(macOS 26, *) else { return [ BuilderCommand.self, + SystemInfo.self, SystemCommand.self, ] } @@ -231,6 +232,7 @@ public struct Application: AsyncLoggableCommand { return [ BuilderCommand.self, NetworkCommand.self, + SystemInfo.self, SystemCommand.self, ] } diff --git a/Sources/ContainerCommands/System/SystemInfo.swift b/Sources/ContainerCommands/System/SystemInfo.swift new file mode 100644 index 000000000..54773de9e --- /dev/null +++ b/Sources/ContainerCommands/System/SystemInfo.swift @@ -0,0 +1,217 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerVersion +import Foundation + +extension Application { + public struct SystemInfo: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "info", + abstract: "Display system-wide information" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let info = await Self.gather() + try Output.render(payload: info, format: format) { + Self.infoTable(info) + } + } + + /// Collects system information from the CLI, the daemon (if running), and + /// the loaded system configuration. Daemon-dependent fields are populated + /// only when the API server responds. + static func gather() async -> InfoPayload { + let client = ClientInfo( + version: ReleaseVersion.version(), + build: ReleaseVersion.buildType(), + commit: ReleaseVersion.gitCommit() ?? "unspecified", + appName: "container" + ) + + let host = HostInfo( + architecture: Arch.hostArchitecture().rawValue, + operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString, + cpus: ProcessInfo.processInfo.processorCount + ) + + var server: ServerInfo? = nil + var paths: PathInfo? = nil + if let health = try? await ClientHealthCheck.ping(timeout: .seconds(2)) { + server = ServerInfo( + version: health.apiServerVersion, + build: health.apiServerBuild, + commit: health.apiServerCommit, + appName: health.apiServerAppName + ) + paths = PathInfo( + appRoot: health.appRoot.path(percentEncoded: false), + installRoot: health.installRoot.path(percentEncoded: false), + logRoot: health.logRoot?.string + ) + } + + var resources: ResourceCounts? = nil + if server != nil { + let containerClient = ContainerClient() + if let all = try? await containerClient.list(filters: ContainerListFilters.all.withoutMachines()) { + let running = all.filter { $0.status == .running }.count + resources = ResourceCounts( + containersTotal: all.count, + containersRunning: running + ) + } + if var resources, let images = try? await ClientImage.list() { + resources.images = images.count + } + } + + var defaults: DefaultsInfo? = nil + if let config = try? await Application.loadContainerSystemConfig() { + defaults = DefaultsInfo( + registryDomain: config.registry.domain, + kernelBinaryPath: config.kernel.binaryPath, + containerCPUs: config.container.cpus, + containerMemory: config.container.memory.description, + builderImage: config.build.image, + initImage: config.vminit.image, + dnsDomain: config.dns.domain + ) + } + + return InfoPayload( + client: client, + server: server, + host: host, + paths: paths, + resources: resources, + defaults: defaults + ) + } + + static func infoTable(_ info: InfoPayload) -> String { + var rows: [[String]] = [["FIELD", "VALUE"]] + + rows.append(["client.version", info.client.version]) + rows.append(["client.build", info.client.build]) + rows.append(["client.commit", info.client.commit]) + + rows.append(["host.os", info.host.operatingSystem]) + rows.append(["host.architecture", info.host.architecture]) + rows.append(["host.cpus", String(info.host.cpus)]) + + if let server = info.server { + rows.append(["server.status", "running"]) + rows.append(["server.version", server.version]) + rows.append(["server.build", server.build]) + rows.append(["server.commit", server.commit]) + } else { + rows.append(["server.status", "not running"]) + } + + if let paths = info.paths { + rows.append(["paths.appRoot", paths.appRoot]) + rows.append(["paths.installRoot", paths.installRoot]) + rows.append(["paths.logRoot", paths.logRoot ?? ""]) + } + + if let resources = info.resources { + rows.append(["containers.total", String(resources.containersTotal)]) + rows.append(["containers.running", String(resources.containersRunning)]) + if let images = resources.images { + rows.append(["images.total", String(images)]) + } + } + + if let defaults = info.defaults { + rows.append(["defaults.registryDomain", defaults.registryDomain]) + rows.append(["defaults.kernelBinaryPath", defaults.kernelBinaryPath]) + rows.append(["defaults.containerCPUs", String(defaults.containerCPUs)]) + rows.append(["defaults.containerMemory", defaults.containerMemory]) + rows.append(["defaults.builderImage", defaults.builderImage]) + rows.append(["defaults.initImage", defaults.initImage]) + if let dnsDomain = defaults.dnsDomain { + rows.append(["defaults.dnsDomain", dnsDomain]) + } + } + + return TableOutput(rows: rows).format() + } + } + + struct InfoPayload: Codable { + let client: ClientInfo + let server: ServerInfo? + let host: HostInfo + let paths: PathInfo? + let resources: ResourceCounts? + let defaults: DefaultsInfo? + } + + struct ClientInfo: Codable { + let version: String + let build: String + let commit: String + let appName: String + } + + struct ServerInfo: Codable { + let version: String + let build: String + let commit: String + let appName: String + } + + struct HostInfo: Codable { + let architecture: String + let operatingSystem: String + let cpus: Int + } + + struct PathInfo: Codable { + let appRoot: String + let installRoot: String + let logRoot: String? + } + + struct ResourceCounts: Codable { + let containersTotal: Int + let containersRunning: Int + var images: Int? + } + + struct DefaultsInfo: Codable { + let registryDomain: String + let kernelBinaryPath: String + let containerCPUs: Int + let containerMemory: String + let builderImage: String + let initImage: String + let dnsDomain: String? + } +} diff --git a/Tests/ContainerCommandsTests/SystemInfoTests.swift b/Tests/ContainerCommandsTests/SystemInfoTests.swift new file mode 100644 index 000000000..bd1b06dcc --- /dev/null +++ b/Tests/ContainerCommandsTests/SystemInfoTests.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerCommands + +struct SystemInfoTests { + private func makePayload( + server: Application.ServerInfo? = nil, + paths: Application.PathInfo? = nil, + resources: Application.ResourceCounts? = nil, + defaults: Application.DefaultsInfo? = nil + ) -> Application.InfoPayload { + Application.InfoPayload( + client: Application.ClientInfo(version: "1.2.3", build: "release", commit: "abcdef", appName: "container"), + server: server, + host: Application.HostInfo(architecture: "arm64", operatingSystem: "macOS 26.0", cpus: 8), + paths: paths, + resources: resources, + defaults: defaults + ) + } + + @Test + func tableAlwaysIncludesClientAndHost() { + let table = Application.SystemInfo.infoTable(makePayload()) + #expect(table.contains("FIELD")) + #expect(table.contains("client.version")) + #expect(table.contains("1.2.3")) + #expect(table.contains("host.architecture")) + #expect(table.contains("arm64")) + #expect(table.contains("host.cpus")) + } + + @Test + func tableShowsServerNotRunningWhenAbsent() { + let table = Application.SystemInfo.infoTable(makePayload()) + #expect(table.contains("server.status")) + #expect(table.contains("not running")) + #expect(!table.contains("server.version")) + } + + @Test + func tableShowsServerAndDaemonFieldsWhenRunning() { + let payload = makePayload( + server: Application.ServerInfo(version: "9.9.9", build: "release", commit: "deadbeef", appName: "container-apiserver"), + paths: Application.PathInfo(appRoot: "/app/root", installRoot: "/install/root", logRoot: "/log/root"), + resources: { + var r = Application.ResourceCounts(containersTotal: 5, containersRunning: 2) + r.images = 7 + return r + }(), + defaults: Application.DefaultsInfo( + registryDomain: "docker.io", + kernelBinaryPath: "opt/kata/vmlinux", + containerCPUs: 4, + containerMemory: "1024MB", + builderImage: "ghcr.io/apple/builder:latest", + initImage: "vminit:latest", + dnsDomain: "test.local" + ) + ) + let table = Application.SystemInfo.infoTable(payload) + #expect(table.contains("running")) + #expect(table.contains("server.version")) + #expect(table.contains("9.9.9")) + #expect(table.contains("paths.appRoot")) + #expect(table.contains("/app/root")) + #expect(table.contains("containers.total")) + #expect(table.contains("containers.running")) + #expect(table.contains("images.total")) + #expect(table.contains("defaults.registryDomain")) + #expect(table.contains("docker.io")) + #expect(table.contains("defaults.dnsDomain")) + #expect(table.contains("test.local")) + } + + @Test + func payloadRoundTripsThroughJSON() throws { + let payload = makePayload( + server: Application.ServerInfo(version: "9.9.9", build: "release", commit: "deadbeef", appName: "container-apiserver") + ) + let json = try Output.renderJSON(payload) + let decoded = try JSONDecoder().decode(Application.InfoPayload.self, from: Data(json.utf8)) + #expect(decoded.client.version == "1.2.3") + #expect(decoded.server?.commit == "deadbeef") + #expect(decoded.host.cpus == 8) + #expect(decoded.paths == nil) + } + + @Test + func optionalDNSDomainOmittedWhenNil() { + let payload = makePayload( + defaults: Application.DefaultsInfo( + registryDomain: "docker.io", + kernelBinaryPath: "opt/kata/vmlinux", + containerCPUs: 4, + containerMemory: "1024MB", + builderImage: "img", + initImage: "vminit", + dnsDomain: nil + ) + ) + let table = Application.SystemInfo.infoTable(payload) + #expect(table.contains("defaults.registryDomain")) + #expect(!table.contains("defaults.dnsDomain")) + } +} From 6c13fb0d7f55f67ed0e50ab3053567a9d8d2d87d Mon Sep 17 00:00:00 2001 From: muk2 Date: Sat, 20 Jun 2026 12:54:04 -0400 Subject: [PATCH 2/4] Fix image count dropped from container info output The image count was assigned to a shadowed copy of the resource counts created by an `if var resources` binding, so it never reached the `InfoPayload` returned by `gather()`. As a result `images.total` was omitted from table and JSON output even when `ClientImage.list()` succeeded. Apply the count to the outer value via a small, pure `withImageCount` helper and add regression tests covering the count path. --- .../ContainerCommands/System/SystemInfo.swift | 14 +++++++++-- .../SystemInfoTests.swift | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/Sources/ContainerCommands/System/SystemInfo.swift b/Sources/ContainerCommands/System/SystemInfo.swift index 54773de9e..c8bad9787 100644 --- a/Sources/ContainerCommands/System/SystemInfo.swift +++ b/Sources/ContainerCommands/System/SystemInfo.swift @@ -86,8 +86,8 @@ extension Application { containersRunning: running ) } - if var resources, let images = try? await ClientImage.list() { - resources.images = images.count + if let images = try? await ClientImage.list() { + resources = Self.withImageCount(resources, imageCount: images.count) } } @@ -114,6 +114,16 @@ extension Application { ) } + /// Records the image count on the resource counts when both are + /// available. Returns the counts unchanged (including `nil`) when there + /// are no resource counts yet, i.e. the daemon's container list was + /// unavailable. + static func withImageCount(_ resources: ResourceCounts?, imageCount: Int?) -> ResourceCounts? { + guard var resources, let imageCount else { return resources } + resources.images = imageCount + return resources + } + static func infoTable(_ info: InfoPayload) -> String { var rows: [[String]] = [["FIELD", "VALUE"]] diff --git a/Tests/ContainerCommandsTests/SystemInfoTests.swift b/Tests/ContainerCommandsTests/SystemInfoTests.swift index bd1b06dcc..16cad202b 100644 --- a/Tests/ContainerCommandsTests/SystemInfoTests.swift +++ b/Tests/ContainerCommandsTests/SystemInfoTests.swift @@ -120,4 +120,27 @@ struct SystemInfoTests { #expect(table.contains("defaults.registryDomain")) #expect(!table.contains("defaults.dnsDomain")) } + + @Test + func imageCountIsRecordedOnResourceCounts() { + let resources = Application.ResourceCounts(containersTotal: 3, containersRunning: 1) + let updated = Application.SystemInfo.withImageCount(resources, imageCount: 7) + #expect(updated?.images == 7) + // And it surfaces in the rendered table. + let table = Application.SystemInfo.infoTable(makePayload(resources: updated)) + #expect(table.contains("images.total")) + #expect(table.contains("7")) + } + + @Test + func imageCountWithoutResourceCountsStaysNil() { + #expect(Application.SystemInfo.withImageCount(nil, imageCount: 7) == nil) + } + + @Test + func imageCountOmittedWhenUnavailable() { + let resources = Application.ResourceCounts(containersTotal: 2, containersRunning: 0) + let updated = Application.SystemInfo.withImageCount(resources, imageCount: nil) + #expect(updated?.images == nil) + } } From 8e2d08b906851baebe7b561808cbb5e44bf6de9d Mon Sep 17 00:00:00 2001 From: muk2 Date: Thu, 25 Jun 2026 21:26:47 -0400 Subject: [PATCH 3/4] Integrate `info` fields into `system status` Drop the separate top-level `container info` command and fold its richer payload (client, host, resource counts, and system defaults) into `container system status`. `system status` keeps its existing role and exit-code contract: it still reports `unregistered`/`not running` and exits non-zero in those cases, but when the daemon is up it now emits the full client/host/server/paths/resources/defaults view in table or JSON. This avoids a Docker-shaped command name and gives downstream tooling a single Apple-native machine-readable surface, per PR review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ContainerCommands/Application.swift | 2 - .../ContainerCommands/System/SystemInfo.swift | 227 --------------- .../System/SystemStatus.swift | 262 ++++++++++++++---- ...nfoTests.swift => SystemStatusTests.swift} | 54 ++-- 4 files changed, 232 insertions(+), 313 deletions(-) delete mode 100644 Sources/ContainerCommands/System/SystemInfo.swift rename Tests/ContainerCommandsTests/{SystemInfoTests.swift => SystemStatusTests.swift} (75%) diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 2490addba..c77f51a59 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -224,7 +224,6 @@ public struct Application: AsyncLoggableCommand { guard #available(macOS 26, *) else { return [ BuilderCommand.self, - SystemInfo.self, SystemCommand.self, ] } @@ -232,7 +231,6 @@ public struct Application: AsyncLoggableCommand { return [ BuilderCommand.self, NetworkCommand.self, - SystemInfo.self, SystemCommand.self, ] } diff --git a/Sources/ContainerCommands/System/SystemInfo.swift b/Sources/ContainerCommands/System/SystemInfo.swift deleted file mode 100644 index c8bad9787..000000000 --- a/Sources/ContainerCommands/System/SystemInfo.swift +++ /dev/null @@ -1,227 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ContainerPersistence -import ContainerResource -import ContainerVersion -import Foundation - -extension Application { - public struct SystemInfo: AsyncLoggableCommand { - public static let configuration = CommandConfiguration( - commandName: "info", - abstract: "Display system-wide information" - ) - - @Option(name: .long, help: "Format of the output") - var format: ListFormat = .table - - @OptionGroup - public var logOptions: Flags.Logging - - public init() {} - - public func run() async throws { - let info = await Self.gather() - try Output.render(payload: info, format: format) { - Self.infoTable(info) - } - } - - /// Collects system information from the CLI, the daemon (if running), and - /// the loaded system configuration. Daemon-dependent fields are populated - /// only when the API server responds. - static func gather() async -> InfoPayload { - let client = ClientInfo( - version: ReleaseVersion.version(), - build: ReleaseVersion.buildType(), - commit: ReleaseVersion.gitCommit() ?? "unspecified", - appName: "container" - ) - - let host = HostInfo( - architecture: Arch.hostArchitecture().rawValue, - operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString, - cpus: ProcessInfo.processInfo.processorCount - ) - - var server: ServerInfo? = nil - var paths: PathInfo? = nil - if let health = try? await ClientHealthCheck.ping(timeout: .seconds(2)) { - server = ServerInfo( - version: health.apiServerVersion, - build: health.apiServerBuild, - commit: health.apiServerCommit, - appName: health.apiServerAppName - ) - paths = PathInfo( - appRoot: health.appRoot.path(percentEncoded: false), - installRoot: health.installRoot.path(percentEncoded: false), - logRoot: health.logRoot?.string - ) - } - - var resources: ResourceCounts? = nil - if server != nil { - let containerClient = ContainerClient() - if let all = try? await containerClient.list(filters: ContainerListFilters.all.withoutMachines()) { - let running = all.filter { $0.status == .running }.count - resources = ResourceCounts( - containersTotal: all.count, - containersRunning: running - ) - } - if let images = try? await ClientImage.list() { - resources = Self.withImageCount(resources, imageCount: images.count) - } - } - - var defaults: DefaultsInfo? = nil - if let config = try? await Application.loadContainerSystemConfig() { - defaults = DefaultsInfo( - registryDomain: config.registry.domain, - kernelBinaryPath: config.kernel.binaryPath, - containerCPUs: config.container.cpus, - containerMemory: config.container.memory.description, - builderImage: config.build.image, - initImage: config.vminit.image, - dnsDomain: config.dns.domain - ) - } - - return InfoPayload( - client: client, - server: server, - host: host, - paths: paths, - resources: resources, - defaults: defaults - ) - } - - /// Records the image count on the resource counts when both are - /// available. Returns the counts unchanged (including `nil`) when there - /// are no resource counts yet, i.e. the daemon's container list was - /// unavailable. - static func withImageCount(_ resources: ResourceCounts?, imageCount: Int?) -> ResourceCounts? { - guard var resources, let imageCount else { return resources } - resources.images = imageCount - return resources - } - - static func infoTable(_ info: InfoPayload) -> String { - var rows: [[String]] = [["FIELD", "VALUE"]] - - rows.append(["client.version", info.client.version]) - rows.append(["client.build", info.client.build]) - rows.append(["client.commit", info.client.commit]) - - rows.append(["host.os", info.host.operatingSystem]) - rows.append(["host.architecture", info.host.architecture]) - rows.append(["host.cpus", String(info.host.cpus)]) - - if let server = info.server { - rows.append(["server.status", "running"]) - rows.append(["server.version", server.version]) - rows.append(["server.build", server.build]) - rows.append(["server.commit", server.commit]) - } else { - rows.append(["server.status", "not running"]) - } - - if let paths = info.paths { - rows.append(["paths.appRoot", paths.appRoot]) - rows.append(["paths.installRoot", paths.installRoot]) - rows.append(["paths.logRoot", paths.logRoot ?? ""]) - } - - if let resources = info.resources { - rows.append(["containers.total", String(resources.containersTotal)]) - rows.append(["containers.running", String(resources.containersRunning)]) - if let images = resources.images { - rows.append(["images.total", String(images)]) - } - } - - if let defaults = info.defaults { - rows.append(["defaults.registryDomain", defaults.registryDomain]) - rows.append(["defaults.kernelBinaryPath", defaults.kernelBinaryPath]) - rows.append(["defaults.containerCPUs", String(defaults.containerCPUs)]) - rows.append(["defaults.containerMemory", defaults.containerMemory]) - rows.append(["defaults.builderImage", defaults.builderImage]) - rows.append(["defaults.initImage", defaults.initImage]) - if let dnsDomain = defaults.dnsDomain { - rows.append(["defaults.dnsDomain", dnsDomain]) - } - } - - return TableOutput(rows: rows).format() - } - } - - struct InfoPayload: Codable { - let client: ClientInfo - let server: ServerInfo? - let host: HostInfo - let paths: PathInfo? - let resources: ResourceCounts? - let defaults: DefaultsInfo? - } - - struct ClientInfo: Codable { - let version: String - let build: String - let commit: String - let appName: String - } - - struct ServerInfo: Codable { - let version: String - let build: String - let commit: String - let appName: String - } - - struct HostInfo: Codable { - let architecture: String - let operatingSystem: String - let cpus: Int - } - - struct PathInfo: Codable { - let appRoot: String - let installRoot: String - let logRoot: String? - } - - struct ResourceCounts: Codable { - let containersTotal: Int - let containersRunning: Int - var images: Int? - } - - struct DefaultsInfo: Codable { - let registryDomain: String - let kernelBinaryPath: String - let containerCPUs: Int - let containerMemory: String - let builderImage: String - let initImage: String - let dnsDomain: String? - } -} diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift index bfdd76527..aed2caf90 100644 --- a/Sources/ContainerCommands/System/SystemStatus.swift +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -16,7 +16,10 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence import ContainerPlugin +import ContainerResource +import ContainerVersion import ContainerizationError import Foundation import Logging @@ -25,7 +28,7 @@ extension Application { public struct SystemStatus: AsyncLoggableCommand { public static let configuration = CommandConfiguration( commandName: "status", - abstract: "Show the status of `container` services" + abstract: "Show the status of `container` services and system-wide information" ) @Option(name: .shortAndLong, help: "Launchd prefix for services") @@ -39,41 +42,10 @@ extension Application { public init() {} - struct PrintableStatus: Codable { - let status: String - let appRoot: String - let installRoot: String - let logRoot: String? - let apiServerVersion: String - let apiServerCommit: String - let apiServerBuild: String - let apiServerAppName: String - - init( - status: String, - appRoot: String = "", - installRoot: String = "", - logRoot: String? = nil, - apiServerVersion: String = "", - apiServerCommit: String = "", - apiServerBuild: String = "", - apiServerAppName: String = "" - ) { - self.status = status - self.appRoot = appRoot - self.installRoot = installRoot - self.logRoot = logRoot - self.apiServerVersion = apiServerVersion - self.apiServerCommit = apiServerCommit - self.apiServerBuild = apiServerBuild - self.apiServerAppName = apiServerAppName - } - } - public func run() async throws { let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") if !isRegistered { - try Output.render(payload: PrintableStatus(status: "unregistered"), format: format) { + try Output.render(payload: StatusPayload(status: "unregistered"), format: format) { "apiserver is not running and not registered with launchd" } Application.exit(withError: ExitCode(1)) @@ -81,41 +53,217 @@ extension Application { // Now ping our friendly daemon. Fail after 10 seconds with no response. do { - let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) - let status = PrintableStatus( - status: "running", - appRoot: systemHealth.appRoot.path(percentEncoded: false), - installRoot: systemHealth.installRoot.path(percentEncoded: false), - logRoot: systemHealth.logRoot?.string, - apiServerVersion: systemHealth.apiServerVersion, - apiServerCommit: systemHealth.apiServerCommit, - apiServerBuild: systemHealth.apiServerBuild, - apiServerAppName: systemHealth.apiServerAppName - ) + let health = try await ClientHealthCheck.ping(timeout: .seconds(10)) + let status = await Self.gather(health: health) try Output.render(payload: status, format: format) { Self.statusTable(status) } } catch { - try Output.render(payload: PrintableStatus(status: "not running"), format: format) { + try Output.render(payload: StatusPayload(status: "not running"), format: format) { "apiserver is not running" } Application.exit(withError: ExitCode(1)) } } - private static func statusTable(_ status: PrintableStatus) -> String { - let rows: [[String]] = [ - ["FIELD", "VALUE"], - ["status", status.status], - ["appRoot", status.appRoot], - ["installRoot", status.installRoot], - ["logRoot", status.logRoot ?? ""], - ["apiserver.version", status.apiServerVersion], - ["apiserver.commit", status.apiServerCommit], - ["apiserver.build", status.apiServerBuild], - ["apiserver.appName", status.apiServerAppName], - ] + /// Collects system-wide status from the CLI, the running daemon, and the + /// loaded system configuration. Resource and defaults fields are populated + /// best-effort and omitted when their source is unavailable. + static func gather(health: SystemHealth) async -> StatusPayload { + let client = ClientInfo( + version: ReleaseVersion.version(), + build: ReleaseVersion.buildType(), + commit: ReleaseVersion.gitCommit() ?? "unspecified", + appName: "container" + ) + + let host = HostInfo( + architecture: Arch.hostArchitecture().rawValue, + operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString, + cpus: ProcessInfo.processInfo.processorCount + ) + + let server = ServerInfo( + version: health.apiServerVersion, + build: health.apiServerBuild, + commit: health.apiServerCommit, + appName: health.apiServerAppName + ) + + let paths = PathInfo( + appRoot: health.appRoot.path(percentEncoded: false), + installRoot: health.installRoot.path(percentEncoded: false), + logRoot: health.logRoot?.string + ) + + var resources: ResourceCounts? = nil + let containerClient = ContainerClient() + if let all = try? await containerClient.list(filters: ContainerListFilters.all.withoutMachines()) { + let running = all.filter { $0.status == .running }.count + resources = ResourceCounts( + containersTotal: all.count, + containersRunning: running + ) + } + if let images = try? await ClientImage.list() { + resources = Self.withImageCount(resources, imageCount: images.count) + } + + var defaults: DefaultsInfo? = nil + if let config = try? await Application.loadContainerSystemConfig() { + defaults = DefaultsInfo( + registryDomain: config.registry.domain, + kernelBinaryPath: config.kernel.binaryPath, + containerCPUs: config.container.cpus, + containerMemory: config.container.memory.description, + builderImage: config.build.image, + initImage: config.vminit.image, + dnsDomain: config.dns.domain + ) + } + + return StatusPayload( + status: "running", + client: client, + server: server, + host: host, + paths: paths, + resources: resources, + defaults: defaults + ) + } + + /// Records the image count on the resource counts when both are + /// available. Returns the counts unchanged (including `nil`) when there + /// are no resource counts yet, i.e. the daemon's container list was + /// unavailable. + static func withImageCount(_ resources: ResourceCounts?, imageCount: Int?) -> ResourceCounts? { + guard var resources, let imageCount else { return resources } + resources.images = imageCount + return resources + } + + static func statusTable(_ status: StatusPayload) -> String { + var rows: [[String]] = [["FIELD", "VALUE"]] + + rows.append(["status", status.status]) + + if let client = status.client { + rows.append(["client.version", client.version]) + rows.append(["client.build", client.build]) + rows.append(["client.commit", client.commit]) + } + + if let host = status.host { + rows.append(["host.os", host.operatingSystem]) + rows.append(["host.architecture", host.architecture]) + rows.append(["host.cpus", String(host.cpus)]) + } + + if let server = status.server { + rows.append(["server.version", server.version]) + rows.append(["server.build", server.build]) + rows.append(["server.commit", server.commit]) + rows.append(["server.appName", server.appName]) + } + + if let paths = status.paths { + rows.append(["paths.appRoot", paths.appRoot]) + rows.append(["paths.installRoot", paths.installRoot]) + rows.append(["paths.logRoot", paths.logRoot ?? ""]) + } + + if let resources = status.resources { + rows.append(["containers.total", String(resources.containersTotal)]) + rows.append(["containers.running", String(resources.containersRunning)]) + if let images = resources.images { + rows.append(["images.total", String(images)]) + } + } + + if let defaults = status.defaults { + rows.append(["defaults.registryDomain", defaults.registryDomain]) + rows.append(["defaults.kernelBinaryPath", defaults.kernelBinaryPath]) + rows.append(["defaults.containerCPUs", String(defaults.containerCPUs)]) + rows.append(["defaults.containerMemory", defaults.containerMemory]) + rows.append(["defaults.builderImage", defaults.builderImage]) + rows.append(["defaults.initImage", defaults.initImage]) + if let dnsDomain = defaults.dnsDomain { + rows.append(["defaults.dnsDomain", dnsDomain]) + } + } + return TableOutput(rows: rows).format() } } + + struct StatusPayload: Codable { + let status: String + let client: ClientInfo? + let server: ServerInfo? + let host: HostInfo? + let paths: PathInfo? + let resources: ResourceCounts? + let defaults: DefaultsInfo? + + init( + status: String, + client: ClientInfo? = nil, + server: ServerInfo? = nil, + host: HostInfo? = nil, + paths: PathInfo? = nil, + resources: ResourceCounts? = nil, + defaults: DefaultsInfo? = nil + ) { + self.status = status + self.client = client + self.server = server + self.host = host + self.paths = paths + self.resources = resources + self.defaults = defaults + } + } + + struct ClientInfo: Codable { + let version: String + let build: String + let commit: String + let appName: String + } + + struct ServerInfo: Codable { + let version: String + let build: String + let commit: String + let appName: String + } + + struct HostInfo: Codable { + let architecture: String + let operatingSystem: String + let cpus: Int + } + + struct PathInfo: Codable { + let appRoot: String + let installRoot: String + let logRoot: String? + } + + struct ResourceCounts: Codable { + let containersTotal: Int + let containersRunning: Int + var images: Int? + } + + struct DefaultsInfo: Codable { + let registryDomain: String + let kernelBinaryPath: String + let containerCPUs: Int + let containerMemory: String + let builderImage: String + let initImage: String + let dnsDomain: String? + } } diff --git a/Tests/ContainerCommandsTests/SystemInfoTests.swift b/Tests/ContainerCommandsTests/SystemStatusTests.swift similarity index 75% rename from Tests/ContainerCommandsTests/SystemInfoTests.swift rename to Tests/ContainerCommandsTests/SystemStatusTests.swift index 16cad202b..253089761 100644 --- a/Tests/ContainerCommandsTests/SystemInfoTests.swift +++ b/Tests/ContainerCommandsTests/SystemStatusTests.swift @@ -19,16 +19,16 @@ import Testing @testable import ContainerCommands -struct SystemInfoTests { - private func makePayload( - server: Application.ServerInfo? = nil, +struct SystemStatusTests { + private func makeRunningPayload( paths: Application.PathInfo? = nil, resources: Application.ResourceCounts? = nil, defaults: Application.DefaultsInfo? = nil - ) -> Application.InfoPayload { - Application.InfoPayload( + ) -> Application.StatusPayload { + Application.StatusPayload( + status: "running", client: Application.ClientInfo(version: "1.2.3", build: "release", commit: "abcdef", appName: "container"), - server: server, + server: Application.ServerInfo(version: "9.9.9", build: "release", commit: "deadbeef", appName: "container-apiserver"), host: Application.HostInfo(architecture: "arm64", operatingSystem: "macOS 26.0", cpus: 8), paths: paths, resources: resources, @@ -37,9 +37,11 @@ struct SystemInfoTests { } @Test - func tableAlwaysIncludesClientAndHost() { - let table = Application.SystemInfo.infoTable(makePayload()) + func tableIncludesStatusClientAndHostWhenRunning() { + let table = Application.SystemStatus.statusTable(makeRunningPayload()) #expect(table.contains("FIELD")) + #expect(table.contains("status")) + #expect(table.contains("running")) #expect(table.contains("client.version")) #expect(table.contains("1.2.3")) #expect(table.contains("host.architecture")) @@ -48,17 +50,17 @@ struct SystemInfoTests { } @Test - func tableShowsServerNotRunningWhenAbsent() { - let table = Application.SystemInfo.infoTable(makePayload()) - #expect(table.contains("server.status")) + func tableShowsOnlyStatusWhenNotRunning() { + let table = Application.SystemStatus.statusTable(Application.StatusPayload(status: "not running")) + #expect(table.contains("status")) #expect(table.contains("not running")) + #expect(!table.contains("client.version")) #expect(!table.contains("server.version")) } @Test func tableShowsServerAndDaemonFieldsWhenRunning() { - let payload = makePayload( - server: Application.ServerInfo(version: "9.9.9", build: "release", commit: "deadbeef", appName: "container-apiserver"), + let payload = makeRunningPayload( paths: Application.PathInfo(appRoot: "/app/root", installRoot: "/install/root", logRoot: "/log/root"), resources: { var r = Application.ResourceCounts(containersTotal: 5, containersRunning: 2) @@ -75,8 +77,7 @@ struct SystemInfoTests { dnsDomain: "test.local" ) ) - let table = Application.SystemInfo.infoTable(payload) - #expect(table.contains("running")) + let table = Application.SystemStatus.statusTable(payload) #expect(table.contains("server.version")) #expect(table.contains("9.9.9")) #expect(table.contains("paths.appRoot")) @@ -92,20 +93,19 @@ struct SystemInfoTests { @Test func payloadRoundTripsThroughJSON() throws { - let payload = makePayload( - server: Application.ServerInfo(version: "9.9.9", build: "release", commit: "deadbeef", appName: "container-apiserver") - ) + let payload = makeRunningPayload() let json = try Output.renderJSON(payload) - let decoded = try JSONDecoder().decode(Application.InfoPayload.self, from: Data(json.utf8)) - #expect(decoded.client.version == "1.2.3") + let decoded = try JSONDecoder().decode(Application.StatusPayload.self, from: Data(json.utf8)) + #expect(decoded.status == "running") + #expect(decoded.client?.version == "1.2.3") #expect(decoded.server?.commit == "deadbeef") - #expect(decoded.host.cpus == 8) + #expect(decoded.host?.cpus == 8) #expect(decoded.paths == nil) } @Test func optionalDNSDomainOmittedWhenNil() { - let payload = makePayload( + let payload = makeRunningPayload( defaults: Application.DefaultsInfo( registryDomain: "docker.io", kernelBinaryPath: "opt/kata/vmlinux", @@ -116,7 +116,7 @@ struct SystemInfoTests { dnsDomain: nil ) ) - let table = Application.SystemInfo.infoTable(payload) + let table = Application.SystemStatus.statusTable(payload) #expect(table.contains("defaults.registryDomain")) #expect(!table.contains("defaults.dnsDomain")) } @@ -124,23 +124,23 @@ struct SystemInfoTests { @Test func imageCountIsRecordedOnResourceCounts() { let resources = Application.ResourceCounts(containersTotal: 3, containersRunning: 1) - let updated = Application.SystemInfo.withImageCount(resources, imageCount: 7) + let updated = Application.SystemStatus.withImageCount(resources, imageCount: 7) #expect(updated?.images == 7) // And it surfaces in the rendered table. - let table = Application.SystemInfo.infoTable(makePayload(resources: updated)) + let table = Application.SystemStatus.statusTable(makeRunningPayload(resources: updated)) #expect(table.contains("images.total")) #expect(table.contains("7")) } @Test func imageCountWithoutResourceCountsStaysNil() { - #expect(Application.SystemInfo.withImageCount(nil, imageCount: 7) == nil) + #expect(Application.SystemStatus.withImageCount(nil, imageCount: 7) == nil) } @Test func imageCountOmittedWhenUnavailable() { let resources = Application.ResourceCounts(containersTotal: 2, containersRunning: 0) - let updated = Application.SystemInfo.withImageCount(resources, imageCount: nil) + let updated = Application.SystemStatus.withImageCount(resources, imageCount: nil) #expect(updated?.images == nil) } } From 3f468273267f861e662b67555a3aee0e309627c6 Mon Sep 17 00:00:00 2001 From: muk2 Date: Tue, 30 Jun 2026 17:23:17 -0400 Subject: [PATCH 4/4] Fix system status JSON schema in CLI tests and trim apiserver version Update TestCLIStatus to the nested `system status` payload (paths.*/server.*) so it decodes the running and not-running cases, fixing the `appRoot` keyNotFound failures and the `apiserver.version`/`apiserver.commit` table assertions. Report the bare release version for `apiServerVersion` in the health check reply instead of the composed `singleLine` string, so `server.version` (and `system version`) show e.g. `1.0.0-23-g7b94575` rather than a composition of the build/commit fields that are already reported separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../HealthCheck/HealthCheckHarness.swift | 2 +- .../Subcommands/System/TestCLIStatus.swift | 66 +++++++++++-------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift index 563482d1a..82907c6b9 100644 --- a/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift @@ -44,7 +44,7 @@ public actor HealthCheckHarness { if let logRoot { reply.set(key: .logRoot, value: logRoot.string) } - reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) + reply.set(key: .apiServerVersion, value: ReleaseVersion.version()) reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified") // Extra optional fields for richer client display reply.set(key: .apiServerBuild, value: ReleaseVersion.buildType()) diff --git a/Tests/CLITests/Subcommands/System/TestCLIStatus.swift b/Tests/CLITests/Subcommands/System/TestCLIStatus.swift index b6ac115a8..7e5793810 100644 --- a/Tests/CLITests/Subcommands/System/TestCLIStatus.swift +++ b/Tests/CLITests/Subcommands/System/TestCLIStatus.swift @@ -21,14 +21,24 @@ import Testing @Suite(.serialSuites) final class TestCLIStatus: CLITest { struct StatusJSON: Codable { + struct Server: Codable { + let version: String + let build: String + let commit: String + let appName: String + } + + struct Paths: Codable { + let appRoot: String + let installRoot: String + let logRoot: String? + } + let status: String - let appRoot: String - let installRoot: String - let logRoot: String? - let apiServerVersion: String - let apiServerCommit: String - let apiServerBuild: String - let apiServerAppName: String + // Daemon-sourced fields are only present when the apiserver is running, + // so they are optional to allow decoding the "not running" payload too. + let server: Server? + let paths: Paths? } @Test func defaultDisplaysTable() throws { @@ -53,10 +63,10 @@ final class TestCLIStatus: CLITest { let fullOutput = lines.joined(separator: "\n") #expect(fullOutput.contains("status")) #expect(fullOutput.contains("running")) - #expect(fullOutput.contains("appRoot")) - #expect(fullOutput.contains("installRoot")) - #expect(fullOutput.contains("apiserver.version")) - #expect(fullOutput.contains("apiserver.commit")) + #expect(fullOutput.contains("paths.appRoot")) + #expect(fullOutput.contains("paths.installRoot")) + #expect(fullOutput.contains("server.version")) + #expect(fullOutput.contains("server.commit")) _ = data // silence unused warning if assertions short-circuit } @@ -76,12 +86,12 @@ final class TestCLIStatus: CLITest { let decoded = try JSONDecoder().decode(StatusJSON.self, from: data) #expect(decoded.status == "running") - #expect(!decoded.appRoot.isEmpty) - #expect(!decoded.installRoot.isEmpty) - #expect(!decoded.apiServerVersion.isEmpty) - #expect(!decoded.apiServerCommit.isEmpty) - #expect(!decoded.apiServerBuild.isEmpty) - #expect(!decoded.apiServerAppName.isEmpty) + #expect(!(decoded.paths?.appRoot.isEmpty ?? true)) + #expect(!(decoded.paths?.installRoot.isEmpty ?? true)) + #expect(!(decoded.server?.version.isEmpty ?? true)) + #expect(!(decoded.server?.commit.isEmpty ?? true)) + #expect(!(decoded.server?.build.isEmpty ?? true)) + #expect(!(decoded.server?.appName.isEmpty ?? true)) } @Test func explicitTableFormat() throws { @@ -120,12 +130,16 @@ final class TestCLIStatus: CLITest { // Verify table output contains key values from JSON #expect(tableOut.contains(decoded.status)) - #expect(tableOut.contains(decoded.appRoot)) - #expect(tableOut.contains(decoded.installRoot)) - #expect(tableOut.contains(decoded.apiServerVersion)) - #expect(tableOut.contains(decoded.apiServerCommit)) - #expect(tableOut.contains(decoded.apiServerBuild)) - #expect(tableOut.contains(decoded.apiServerAppName)) + if let paths = decoded.paths { + #expect(tableOut.contains(paths.appRoot)) + #expect(tableOut.contains(paths.installRoot)) + } + if let server = decoded.server { + #expect(tableOut.contains(server.version)) + #expect(tableOut.contains(server.commit)) + #expect(tableOut.contains(server.build)) + #expect(tableOut.contains(server.appName)) + } } @Test func jsonOutputValidStructure() throws { @@ -141,9 +155,9 @@ final class TestCLIStatus: CLITest { if status == 0 { // When running, all fields should be populated #expect(decoded.status == "running") - #expect(!decoded.appRoot.isEmpty) - #expect(!decoded.installRoot.isEmpty) - #expect(!decoded.apiServerVersion.isEmpty) + #expect(!(decoded.paths?.appRoot.isEmpty ?? true)) + #expect(!(decoded.paths?.installRoot.isEmpty ?? true)) + #expect(!(decoded.server?.version.isEmpty ?? true)) } else { // When not running, status should indicate the issue #expect(decoded.status == "not running" || decoded.status == "unregistered")