Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion Sources/ContainerClient/Core/ClientHealthCheck.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
6 changes: 6 additions & 0 deletions Sources/ContainerClient/Core/SystemHealth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
2 changes: 2 additions & 0 deletions Sources/ContainerClient/Core/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ public enum XPCKeys: String {
case installRoot
case apiServerVersion
case apiServerCommit
case apiServerBuild
case apiServerAppName

/// Process request keys.
case signal
Expand Down
6 changes: 0 additions & 6 deletions Sources/ContainerCommands/Application.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,6 @@ public struct Application: AsyncParsableCommand {
RegistryCommand.self,
]
),
CommandGroup(
name: "Volume",
subcommands: [
VolumeCommand.self
]
),
CommandGroup(
name: "Other",
subcommands: Self.otherCommands()
Expand Down
3 changes: 2 additions & 1 deletion Sources/ContainerCommands/System/SystemCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ extension Application {
SystemStart.self,
SystemStatus.self,
SystemStop.self,
VersionCommand.self,
],
aliases: ["s"]
)
}
}
}
109 changes: 109 additions & 0 deletions Sources/ContainerCommands/System/Version.swift
Original file line number Diff line number Diff line change
@@ -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?
}
}
13 changes: 9 additions & 4 deletions Sources/ContainerVersion/ReleaseVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
102 changes: 102 additions & 0 deletions Tests/CLITests/Subcommands/System/TestCLIVersion.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
Loading