From 06b8cf83dea671ac01733642d7148309e5bef6d9 Mon Sep 17 00:00:00 2001 From: fatelei Date: Fri, 21 Nov 2025 15:55:02 +0800 Subject: [PATCH] feat: implement version sub command --- Package.resolved | 26 ++--- .../Core/ClientHealthCheck.swift | 12 +- .../ContainerClient/Core/SystemHealth.swift | 6 + Sources/ContainerClient/Core/XPC+.swift | 2 + Sources/ContainerCommands/Application.swift | 6 - .../System/SystemCommand.swift | 3 +- .../ContainerCommands/System/Version.swift | 109 ++++++++++++++++++ Sources/ContainerVersion/ReleaseVersion.swift | 13 ++- .../HealthCheck/HealthCheckHarness.swift | 3 + .../Subcommands/System/TestCLIVersion.swift | 102 ++++++++++++++++ docs/command-reference.md | 49 ++++++++ docs/tutorial.md | 8 +- 12 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 Sources/ContainerCommands/System/Version.swift create mode 100644 Tests/CLITests/Subcommands/System/TestCLIVersion.swift diff --git a/Package.resolved b/Package.resolved index d1e71d7f..d199cbc6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6b3046dae5a56f593367112450130e1d5e5ce015afbad2a2299c865c1e5d10f7", + "originHash" : "d91cbd102465c50b5e642feab2dddc0c414a23ca40d898dbd38d8758c9faab99", "pins" : [ { "identity" : "async-http-client", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", + "version" : "1.1.1" } }, { @@ -96,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c059d9c9d08d6654b9a92dda93d9049a278964c6", - "version" : "1.12.0" + "revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130", + "version" : "1.17.0" } }, { @@ -141,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", - "version" : "1.4.0" + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" } }, { @@ -150,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" } }, { @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "385f5bd783ffbfff46b246a7db7be8e4f04c53bd", - "version" : "2.33.0" + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" } }, { @@ -213,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { diff --git a/Sources/ContainerClient/Core/ClientHealthCheck.swift b/Sources/ContainerClient/Core/ClientHealthCheck.swift index 90f006aa..fdd12c8d 100644 --- a/Sources/ContainerClient/Core/ClientHealthCheck.swift +++ b/Sources/ContainerClient/Core/ClientHealthCheck.swift @@ -43,6 +43,16 @@ extension ClientHealthCheck { guard let apiServerCommit = reply.string(key: .apiServerCommit) else { throw ContainerizationError(.internalError, message: "failed to decode apiServerCommit in health check") } - return .init(appRoot: appRoot, installRoot: installRoot, apiServerVersion: apiServerVersion, apiServerCommit: apiServerCommit) + // Optional fields for newer servers + let apiServerBuild = reply.string(key: .apiServerBuild) + let apiServerAppName = reply.string(key: .apiServerAppName) + return .init( + appRoot: appRoot, + installRoot: installRoot, + apiServerVersion: apiServerVersion, + apiServerCommit: apiServerCommit, + apiServerBuild: apiServerBuild, + apiServerAppName: apiServerAppName + ) } } diff --git a/Sources/ContainerClient/Core/SystemHealth.swift b/Sources/ContainerClient/Core/SystemHealth.swift index 48fd1f79..f966346c 100644 --- a/Sources/ContainerClient/Core/SystemHealth.swift +++ b/Sources/ContainerClient/Core/SystemHealth.swift @@ -29,4 +29,10 @@ public struct SystemHealth: Sendable, Codable { /// The Git commit ID for the container services. public let apiServerCommit: String + + /// Optional build type of the API server (debug|release). Present on newer servers. + public let apiServerBuild: String? + + /// Optional app name label returned by the server. + public let apiServerAppName: String? } diff --git a/Sources/ContainerClient/Core/XPC+.swift b/Sources/ContainerClient/Core/XPC+.swift index dade31ca..8bf7528f 100644 --- a/Sources/ContainerClient/Core/XPC+.swift +++ b/Sources/ContainerClient/Core/XPC+.swift @@ -63,6 +63,8 @@ public enum XPCKeys: String { case installRoot case apiServerVersion case apiServerCommit + case apiServerBuild + case apiServerAppName /// Process request keys. case signal diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 1b8b4981..ce14cae1 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -71,12 +71,6 @@ public struct Application: AsyncParsableCommand { RegistryCommand.self, ] ), - CommandGroup( - name: "Volume", - subcommands: [ - VolumeCommand.self - ] - ), CommandGroup( name: "Other", subcommands: Self.otherCommands() diff --git a/Sources/ContainerCommands/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift index 807e4d5c..f10cef67 100644 --- a/Sources/ContainerCommands/System/SystemCommand.swift +++ b/Sources/ContainerCommands/System/SystemCommand.swift @@ -31,8 +31,9 @@ extension Application { SystemStart.self, SystemStatus.self, SystemStop.self, + VersionCommand.self, ], aliases: ["s"] ) - } +} } diff --git a/Sources/ContainerCommands/System/Version.swift b/Sources/ContainerCommands/System/Version.swift new file mode 100644 index 00000000..17d31433 --- /dev/null +++ b/Sources/ContainerCommands/System/Version.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 ContainerClient +import ContainerVersion +import Foundation + +extension Application { + public struct VersionCommand: AsyncParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "version", + abstract: "Show version information" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + + public init() {} + + public func run() async throws { + let cliInfo = VersionInfo( + version: ReleaseVersion.version(), + buildType: ReleaseVersion.buildType(), + commit: ReleaseVersion.gitCommit() ?? "unspecified", + appName: "container CLI" + ) + + // Try to get API server version info + let serverInfo: VersionInfo? + do { + let health = try await ClientHealthCheck.ping(timeout: .seconds(2)) + serverInfo = VersionInfo( + version: health.apiServerVersion, + buildType: health.apiServerBuild ?? "unknown", + commit: health.apiServerCommit, + appName: "container API Server" + ) + } catch { + serverInfo = nil + } + + switch format { + case .table: + printVersionTable(cli: cliInfo, server: serverInfo) + case .json: + try printVersionJSON(cli: cliInfo, server: serverInfo) + } + } + + + private func printVersionTable(cli: VersionInfo, server: VersionInfo?) { + var rows: [[String]] = [ + ["COMPONENT", "VERSION", "BUILD", "COMMIT"], + ["CLI", cli.version, cli.buildType, cli.commit] + ] + + if let server = server { + rows.append(["API Server", server.version, server.buildType, server.commit]) + } + + let table = TableOutput(rows: rows) + print(table.format()) + } + + private func printVersionJSON(cli: VersionInfo, server: VersionInfo?) throws { + let output = VersionJSON( + version: cli.version, + buildType: cli.buildType, + commit: cli.commit, + appName: cli.appName, + server: server + ) + let data = try JSONEncoder().encode(output) + print(String(data: data, encoding: .utf8) ?? "{}") + } + } + + public struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } + + struct VersionJSON: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + let server: VersionInfo? + } +} diff --git a/Sources/ContainerVersion/ReleaseVersion.swift b/Sources/ContainerVersion/ReleaseVersion.swift index c5a07cf8..d42b55f5 100644 --- a/Sources/ContainerVersion/ReleaseVersion.swift +++ b/Sources/ContainerVersion/ReleaseVersion.swift @@ -19,16 +19,21 @@ import Foundation public struct ReleaseVersion { public static func singleLine(appName: String) -> String { - var versionDetails: [String: String] = ["build": "release"] - #if DEBUG - versionDetails["build"] = "debug" - #endif + var versionDetails: [String: String] = ["build": buildType()] versionDetails["commit"] = gitCommit().map { String($0.prefix(7)) } ?? "unspecified" let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") return "\(appName) version \(version()) (\(extras))" } + public static func buildType() -> String { + #if DEBUG + return "debug" + #else + return "release" + #endif + } + public static func version() -> String { let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String diff --git a/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift b/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift index ca85c876..370391cf 100644 --- a/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift +++ b/Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift @@ -40,6 +40,9 @@ public actor HealthCheckHarness { reply.set(key: .installRoot, value: installRoot.absoluteString) reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) 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()) + reply.set(key: .apiServerAppName, value: "container API Server") return reply } } diff --git a/Tests/CLITests/Subcommands/System/TestCLIVersion.swift b/Tests/CLITests/Subcommands/System/TestCLIVersion.swift new file mode 100644 index 00000000..58e56270 --- /dev/null +++ b/Tests/CLITests/Subcommands/System/TestCLIVersion.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 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 + +/// Tests for `container system version` output formats and build type detection. +final class TestCLIVersion: CLITest { + struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } + + struct VersionJSON: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + let server: VersionInfo? + } + + private func expectedBuildType() throws -> String { + let path = try executablePath + if path.path.contains("/debug/") { + return "debug" + } else if path.path.contains("/release/") { + return "release" + } + // Fallback: prefer debug when ambiguous (matches SwiftPM default for tests) + return "debug" + } + + @Test func defaultDisplaysTable() throws { + let (data, out, err, status) = try run(arguments: ["system", "version"]) // default is table + #expect(status == 0, "system version should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + // Validate table structure + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) // header + at least CLI row + #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + #expect(lines[1].hasPrefix("CLI ")) + + // Build should reflect the binary we are running (debug/release) + let expected = try expectedBuildType() + #expect(lines.joined(separator: "\n").contains(" CLI ")) + #expect(lines.joined(separator: "\n").contains(" \(expected) ")) + _ = data // silence unused warning if assertions short-circuit + } + + @Test func jsonFormat() throws { + let (data, out, err, status) = try run(arguments: ["system", "version", "--format", "json"]) + #expect(status == 0, "system version --format json should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) + #expect(decoded.appName == "container CLI") + #expect(!decoded.version.isEmpty) + #expect(!decoded.commit.isEmpty) + + let expected = try expectedBuildType() + #expect(decoded.buildType == expected) + } + + @Test func explicitTableFormat() throws { + let (_, out, err, status) = try run(arguments: ["system", "version", "--format", "table"]) + #expect(status == 0, "system version --format table should succeed, stderr: \(err)") + #expect(!out.isEmpty) + + let lines = out.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + #expect(lines[1].hasPrefix("CLI ")) + } + + @Test func buildTypeMatchesBinary() throws { + // Validate build type via JSON to avoid parsing table text loosely + let (data, _, err, status) = try run(arguments: ["system", "version", "--format", "json"]) + #expect(status == 0, "version --format json should succeed, stderr: \(err)") + let decoded = try JSONDecoder().decode(VersionJSON.self, from: data) + + let expected = try expectedBuildType() + #expect(decoded.buildType == expected, "Expected build type \(expected) but got \(decoded.buildType)") + } +} \ No newline at end of file diff --git a/docs/command-reference.md b/docs/command-reference.md index 8430842d..4bb9c16c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -925,6 +925,55 @@ container system status [--prefix ] [--debug] * `-p, --prefix `: Launchd prefix for services (default: com.apple.container.) +### `container system version` + +Shows version information for the CLI and, if available, the API server. The table format is consistent with other list outputs and includes a header. If the API server responds to a health check, a second row for the server is added. + +**Usage** + +```bash +container system version [--format ] +``` + +**Options** + +* `--format `: Output format (values: json, table; default: table) + +**Table Output** + +Columns: `COMPONENT`, `VERSION`, `BUILD`, `COMMIT`. + +Example: + +```bash +container system version +``` + +``` +COMPONENT VERSION BUILD COMMIT +CLI 1.2.3 debug abcdef1 +API Server container-apiserver 1.2.3 release 1234abc +``` + +**JSON Output** + +Backward-compatible with previous CLI-only output. Top-level fields describe the CLI. When available, a `server` object is included with the same fields. + +```json +{ + "version": "1.2.3", + "buildType": "debug", + "commit": "abcdef1", + "appName": "container CLI", + "server": { + "version": "container-apiserver 1.2.3", + "buildType": "release", + "commit": "1234abc", + "appName": "container API Server" + } +} +``` + ### `container system logs` Displays logs from the container services. You can specify a time interval or follow new logs in real time. diff --git a/docs/tutorial.md b/docs/tutorial.md index 20e9db76..347a540b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -58,9 +58,15 @@ USAGE: container [--debug] OPTIONS: --debug Enable debug output [environment: CONTAINER_DEBUG] - --version Show the version. + --version Show the CLI version (single line). -h, --help Show help information. +Detailed version information is available under the system command: + +``` +container system version [--format json|table] +``` + CONTAINER SUBCOMMANDS: create Create a new container delete, rm Delete one or more containers