Skip to content

Commit 78d6682

Browse files
fateleifatelei
authored andcommitted
feat: implement version sub command (apple#911)
- closes apple#383 - implement version sub command, give more info --------- Co-authored-by: fatelei <fatelei@fateleis-MacBook-Pro.local>
1 parent f22aeb6 commit 78d6682

File tree

11 files changed

+294
-18
lines changed

11 files changed

+294
-18
lines changed

Package.resolved

Lines changed: 12 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/ContainerClient/Core/ClientHealthCheck.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ extension ClientHealthCheck {
4343
guard let apiServerCommit = reply.string(key: .apiServerCommit) else {
4444
throw ContainerizationError(.internalError, message: "failed to decode apiServerCommit in health check")
4545
}
46-
return .init(appRoot: appRoot, installRoot: installRoot, apiServerVersion: apiServerVersion, apiServerCommit: apiServerCommit)
46+
guard let apiServerBuild = reply.string(key: .apiServerBuild) else {
47+
throw ContainerizationError(.internalError, message: "failed to decode apiServerBuild in health check")
48+
}
49+
guard let apiServerAppName = reply.string(key: .apiServerAppName) else {
50+
throw ContainerizationError(.internalError, message: "failed to decode apiServerAppName in health check")
51+
}
52+
return .init(
53+
appRoot: appRoot,
54+
installRoot: installRoot,
55+
apiServerVersion: apiServerVersion,
56+
apiServerCommit: apiServerCommit,
57+
apiServerBuild: apiServerBuild,
58+
apiServerAppName: apiServerAppName
59+
)
4760
}
4861
}

Sources/ContainerClient/Core/SystemHealth.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,10 @@ public struct SystemHealth: Sendable, Codable {
2929

3030
/// The Git commit ID for the container services.
3131
public let apiServerCommit: String
32+
33+
/// The build type of the API server (debug|release).
34+
public let apiServerBuild: String
35+
36+
/// The app name label returned by the server.
37+
public let apiServerAppName: String
3238
}

Sources/ContainerClient/Core/XPC+.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ public enum XPCKeys: String {
6363
case installRoot
6464
case apiServerVersion
6565
case apiServerCommit
66+
case apiServerBuild
67+
case apiServerAppName
6668

6769
/// Process request keys.
6870
case signal

Sources/ContainerCommands/System/SystemCommand.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ extension Application {
3131
SystemStart.self,
3232
SystemStatus.self,
3333
SystemStop.self,
34+
SystemVersion.self,
3435
],
3536
aliases: ["s"]
3637
)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ArgumentParser
18+
import ContainerClient
19+
import ContainerVersion
20+
import Foundation
21+
22+
extension Application {
23+
public struct SystemVersion: AsyncParsableCommand {
24+
public static let configuration = CommandConfiguration(
25+
commandName: "version",
26+
abstract: "Show version information"
27+
)
28+
29+
@Option(name: .long, help: "Format of the output")
30+
var format: ListFormat = .table
31+
32+
@OptionGroup
33+
var global: Flags.Global
34+
35+
public init() {}
36+
37+
public func run() async throws {
38+
let cliInfo = VersionInfo(
39+
version: ReleaseVersion.version(),
40+
buildType: ReleaseVersion.buildType(),
41+
commit: ReleaseVersion.gitCommit() ?? "unspecified",
42+
appName: "container"
43+
)
44+
45+
// Try to get API server version info
46+
let serverInfo: VersionInfo?
47+
do {
48+
let health = try await ClientHealthCheck.ping(timeout: .seconds(2))
49+
serverInfo = VersionInfo(
50+
version: health.apiServerVersion,
51+
buildType: health.apiServerBuild,
52+
commit: health.apiServerCommit,
53+
appName: health.apiServerAppName
54+
)
55+
} catch {
56+
serverInfo = nil
57+
}
58+
59+
let versions = [cliInfo, serverInfo].compactMap { $0 }
60+
61+
switch format {
62+
case .table:
63+
printVersionTable(versions: versions)
64+
case .json:
65+
try printVersionJSON(versions: versions)
66+
}
67+
}
68+
69+
private func printVersionTable(versions: [VersionInfo]) {
70+
let header = ["COMPONENT", "VERSION", "BUILD", "COMMIT"]
71+
let rows = [header] + versions.map { [$0.appName, $0.version, $0.buildType, $0.commit] }
72+
73+
let table = TableOutput(rows: rows)
74+
print(table.format())
75+
}
76+
77+
private func printVersionJSON(versions: [VersionInfo]) throws {
78+
let data = try JSONEncoder().encode(versions)
79+
print(String(data: data, encoding: .utf8) ?? "[]")
80+
}
81+
}
82+
83+
public struct VersionInfo: Codable {
84+
let version: String
85+
let buildType: String
86+
let commit: String
87+
let appName: String
88+
}
89+
}

Sources/ContainerVersion/ReleaseVersion.swift

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,21 @@ import Foundation
1919

2020
public struct ReleaseVersion {
2121
public static func singleLine(appName: String) -> String {
22-
var versionDetails: [String: String] = ["build": "release"]
23-
#if DEBUG
24-
versionDetails["build"] = "debug"
25-
#endif
22+
var versionDetails: [String: String] = ["build": buildType()]
2623
versionDetails["commit"] = gitCommit().map { String($0.prefix(7)) } ?? "unspecified"
2724
let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ")
2825

2926
return "\(appName) version \(version()) (\(extras))"
3027
}
3128

29+
public static func buildType() -> String {
30+
#if DEBUG
31+
return "debug"
32+
#else
33+
return "release"
34+
#endif
35+
}
36+
3237
public static func version() -> String {
3338
let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl)
3439
let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String

Sources/Services/ContainerAPIService/HealthCheck/HealthCheckHarness.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ public actor HealthCheckHarness {
4040
reply.set(key: .installRoot, value: installRoot.absoluteString)
4141
reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver"))
4242
reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified")
43+
// Extra optional fields for richer client display
44+
reply.set(key: .apiServerBuild, value: ReleaseVersion.buildType())
45+
reply.set(key: .apiServerAppName, value: "container API Server")
4346
return reply
4447
}
4548
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Testing
19+
20+
/// Tests for `container system version` output formats and build type detection.
21+
final class TestCLIVersion: CLITest {
22+
struct VersionInfo: Codable {
23+
let version: String
24+
let buildType: String
25+
let commit: String
26+
let appName: String
27+
}
28+
29+
struct VersionJSON: Codable {
30+
let version: String
31+
let buildType: String
32+
let commit: String
33+
let appName: String
34+
let server: VersionInfo?
35+
}
36+
37+
private func expectedBuildType() throws -> String {
38+
let path = try executablePath
39+
if path.path.contains("/debug/") {
40+
return "debug"
41+
} else if path.path.contains("/release/") {
42+
return "release"
43+
}
44+
// Fallback: prefer debug when ambiguous (matches SwiftPM default for tests)
45+
return "debug"
46+
}
47+
48+
@Test func defaultDisplaysTable() throws {
49+
let (data, out, err, status) = try run(arguments: ["system", "version"]) // default is table
50+
#expect(status == 0, "system version should succeed, stderr: \(err)")
51+
#expect(!out.isEmpty)
52+
53+
// Validate table structure
54+
let lines = out.trimmingCharacters(in: .whitespacesAndNewlines)
55+
.components(separatedBy: .newlines)
56+
#expect(lines.count >= 2) // header + at least CLI row
57+
#expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT"))
58+
#expect(lines[1].hasPrefix("CLI "))
59+
60+
// Build should reflect the binary we are running (debug/release)
61+
let expected = try expectedBuildType()
62+
#expect(lines.joined(separator: "\n").contains(" CLI "))
63+
#expect(lines.joined(separator: "\n").contains(" \(expected) "))
64+
_ = data // silence unused warning if assertions short-circuit
65+
}
66+
67+
@Test func jsonFormat() throws {
68+
let (data, out, err, status) = try run(arguments: ["system", "version", "--format", "json"])
69+
#expect(status == 0, "system version --format json should succeed, stderr: \(err)")
70+
#expect(!out.isEmpty)
71+
72+
let decoded = try JSONDecoder().decode(VersionJSON.self, from: data)
73+
#expect(decoded.appName == "container CLI")
74+
#expect(!decoded.version.isEmpty)
75+
#expect(!decoded.commit.isEmpty)
76+
77+
let expected = try expectedBuildType()
78+
#expect(decoded.buildType == expected)
79+
}
80+
81+
@Test func explicitTableFormat() throws {
82+
let (_, out, err, status) = try run(arguments: ["system", "version", "--format", "table"])
83+
#expect(status == 0, "system version --format table should succeed, stderr: \(err)")
84+
#expect(!out.isEmpty)
85+
86+
let lines = out.trimmingCharacters(in: .whitespacesAndNewlines)
87+
.components(separatedBy: .newlines)
88+
#expect(lines.count >= 2)
89+
#expect(lines[0].contains("COMPONENT") && lines[0].contains("VERSION") && lines[0].contains("BUILD") && lines[0].contains("COMMIT"))
90+
#expect(lines[1].hasPrefix("CLI "))
91+
}
92+
93+
@Test func buildTypeMatchesBinary() throws {
94+
// Validate build type via JSON to avoid parsing table text loosely
95+
let (data, _, err, status) = try run(arguments: ["system", "version", "--format", "json"])
96+
#expect(status == 0, "version --format json should succeed, stderr: \(err)")
97+
let decoded = try JSONDecoder().decode(VersionJSON.self, from: data)
98+
99+
let expected = try expectedBuildType()
100+
#expect(decoded.buildType == expected, "Expected build type \(expected) but got \(decoded.buildType)")
101+
}
102+
}

docs/command-reference.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -925,6 +925,55 @@ container system status [--prefix <prefix>] [--debug]
925925

926926
* `-p, --prefix <prefix>`: Launchd prefix for services (default: com.apple.container.)
927927

928+
### `container system version`
929+
930+
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.
931+
932+
**Usage**
933+
934+
```bash
935+
container system version [--format <format>]
936+
```
937+
938+
**Options**
939+
940+
* `--format <format>`: Output format (values: json, table; default: table)
941+
942+
**Table Output**
943+
944+
Columns: `COMPONENT`, `VERSION`, `BUILD`, `COMMIT`.
945+
946+
Example:
947+
948+
```bash
949+
container system version
950+
```
951+
952+
```
953+
COMPONENT VERSION BUILD COMMIT
954+
CLI 1.2.3 debug abcdef1
955+
API Server container-apiserver 1.2.3 release 1234abc
956+
```
957+
958+
**JSON Output**
959+
960+
Backward-compatible with previous CLI-only output. Top-level fields describe the CLI. When available, a `server` object is included with the same fields.
961+
962+
```json
963+
{
964+
"version": "1.2.3",
965+
"buildType": "debug",
966+
"commit": "abcdef1",
967+
"appName": "container CLI",
968+
"server": {
969+
"version": "container-apiserver 1.2.3",
970+
"buildType": "release",
971+
"commit": "1234abc",
972+
"appName": "container API Server"
973+
}
974+
}
975+
```
976+
928977
### `container system logs`
929978

930979
Displays logs from the container services. You can specify a time interval or follow new logs in real time.

0 commit comments

Comments
 (0)