mirror of
https://github.com/apple/container.git
synced 2026-09-24 15:19:38 -05:00
Enhance container system status command output (#1769)
- Fixes #815. - Relocates the API server info received from the health check XPC to a `server` property. - Fixes the server `version` property in the health check result to contain only the version; previously it included text that was already in sibling properties. - Adds properties for host, client, paths, and resources. - Adds these properties to the output of `container system status`, in a table or as JSON (`--format json`). Daemon-sourced fields populate only when the API server responds, so the command degrades gracefully to a "server not running" view when the daemon is stopped. System configuration values are not repeated here — `container system property ls` already reports them. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,8 @@
|
||||
import ArgumentParser
|
||||
import ContainerAPIClient
|
||||
import ContainerPlugin
|
||||
import ContainerResource
|
||||
import ContainerVersion
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
@@ -25,7 +27,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 +41,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 +52,178 @@ 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 and the running daemon.
|
||||
/// Resource counts 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)
|
||||
}
|
||||
|
||||
return StatusPayload(
|
||||
status: "running",
|
||||
client: client,
|
||||
server: server,
|
||||
host: host,
|
||||
paths: paths,
|
||||
resources: resources
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)])
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
|
||||
init(
|
||||
status: String,
|
||||
client: ClientInfo? = nil,
|
||||
server: ServerInfo? = nil,
|
||||
host: HostInfo? = nil,
|
||||
paths: PathInfo? = nil,
|
||||
resources: ResourceCounts? = nil
|
||||
) {
|
||||
self.status = status
|
||||
self.client = client
|
||||
self.server = server
|
||||
self.host = host
|
||||
self.paths = paths
|
||||
self.resources = resources
|
||||
}
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
) -> 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
|
||||
)
|
||||
}
|
||||
|
||||
@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
|
||||
}()
|
||||
)
|
||||
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"))
|
||||
}
|
||||
|
||||
@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 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)
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,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 {
|
||||
@@ -47,10 +57,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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +78,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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,12 +112,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,9 +131,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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user