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/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/ContainerCommandsTests/SystemStatusTests.swift b/Tests/ContainerCommandsTests/SystemStatusTests.swift new file mode 100644 index 000000000..253089761 --- /dev/null +++ b/Tests/ContainerCommandsTests/SystemStatusTests.swift @@ -0,0 +1,146 @@ +//===----------------------------------------------------------------------===// +// 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 SystemStatusTests { + private func makeRunningPayload( + paths: Application.PathInfo? = nil, + resources: Application.ResourceCounts? = nil, + defaults: Application.DefaultsInfo? = nil + ) -> Application.StatusPayload { + Application.StatusPayload( + status: "running", + client: Application.ClientInfo(version: "1.2.3", build: "release", commit: "abcdef", appName: "container"), + 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, + defaults: defaults + ) + } + + @Test + 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")) + #expect(table.contains("arm64")) + #expect(table.contains("host.cpus")) + } + + @Test + 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 = makeRunningPayload( + 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.SystemStatus.statusTable(payload) + #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 = makeRunningPayload() + let json = try Output.renderJSON(payload) + 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.paths == nil) + } + + @Test + func optionalDNSDomainOmittedWhenNil() { + let payload = makeRunningPayload( + defaults: Application.DefaultsInfo( + registryDomain: "docker.io", + kernelBinaryPath: "opt/kata/vmlinux", + containerCPUs: 4, + containerMemory: "1024MB", + builderImage: "img", + initImage: "vminit", + dnsDomain: nil + ) + ) + let table = Application.SystemStatus.statusTable(payload) + #expect(table.contains("defaults.registryDomain")) + #expect(!table.contains("defaults.dnsDomain")) + } + + @Test + func imageCountIsRecordedOnResourceCounts() { + let resources = Application.ResourceCounts(containersTotal: 3, containersRunning: 1) + let updated = Application.SystemStatus.withImageCount(resources, imageCount: 7) + #expect(updated?.images == 7) + // And it surfaces in the rendered table. + let table = Application.SystemStatus.statusTable(makeRunningPayload(resources: updated)) + #expect(table.contains("images.total")) + #expect(table.contains("7")) + } + + @Test + func imageCountWithoutResourceCountsStaysNil() { + #expect(Application.SystemStatus.withImageCount(nil, imageCount: 7) == nil) + } + + @Test + func imageCountOmittedWhenUnavailable() { + let resources = Application.ResourceCounts(containersTotal: 2, containersRunning: 0) + let updated = Application.SystemStatus.withImageCount(resources, imageCount: nil) + #expect(updated?.images == nil) + } +} diff --git a/Tests/IntegrationTests/System/TestCLIStatus.swift b/Tests/IntegrationTests/System/TestCLIStatus.swift index 36041810b..d748627ca 100644 --- a/Tests/IntegrationTests/System/TestCLIStatus.swift +++ b/Tests/IntegrationTests/System/TestCLIStatus.swift @@ -21,14 +21,24 @@ import Testing @Suite struct TestCLIStatus { 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() async throws { @@ -46,10 +56,10 @@ struct TestCLIStatus { 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")) } } @@ -67,12 +77,12 @@ struct TestCLIStatus { #expect(!result.output.isEmpty) let decoded = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) #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)) } } @@ -101,12 +111,16 @@ struct TestCLIStatus { let decoded = try JSONDecoder().decode(StatusJSON.self, from: jsonResult.outputData) #expect(tableResult.output.contains(decoded.status)) - #expect(tableResult.output.contains(decoded.appRoot)) - #expect(tableResult.output.contains(decoded.installRoot)) - #expect(tableResult.output.contains(decoded.apiServerVersion)) - #expect(tableResult.output.contains(decoded.apiServerCommit)) - #expect(tableResult.output.contains(decoded.apiServerBuild)) - #expect(tableResult.output.contains(decoded.apiServerAppName)) + if let paths = decoded.paths { + #expect(tableResult.output.contains(paths.appRoot)) + #expect(tableResult.output.contains(paths.installRoot)) + } + if let server = decoded.server { + #expect(tableResult.output.contains(server.version)) + #expect(tableResult.output.contains(server.commit)) + #expect(tableResult.output.contains(server.build)) + #expect(tableResult.output.contains(server.appName)) + } } } @@ -116,9 +130,9 @@ struct TestCLIStatus { let decoded = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) if result.status == 0 { #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 { #expect(decoded.status == "not running" || decoded.status == "unregistered") }