diff --git a/Makefile b/Makefile index 31816886c..5b66e3fa7 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand2 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand3 || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIPruneCommand || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRegistry || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \ diff --git a/Sources/ContainerCommands/Registry/RegistryCommand.swift b/Sources/ContainerCommands/Registry/RegistryCommand.swift index 0cf219d25..5aab382a4 100644 --- a/Sources/ContainerCommands/Registry/RegistryCommand.swift +++ b/Sources/ContainerCommands/Registry/RegistryCommand.swift @@ -23,8 +23,9 @@ extension Application { commandName: "registry", abstract: "Manage registry logins", subcommands: [ - Login.self, - Logout.self, + RegistryLogin.self, + RegistryLogout.self, + RegistryList.self, ], aliases: ["r"] ) diff --git a/Sources/ContainerCommands/Registry/RegistryList.swift b/Sources/ContainerCommands/Registry/RegistryList.swift new file mode 100644 index 000000000..9b83013f2 --- /dev/null +++ b/Sources/ContainerCommands/Registry/RegistryList.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import ContainerAPIClient +import ContainerizationOCI +import ContainerizationOS +import Foundation + +extension Application { + public struct RegistryList: AsyncLoggableCommand { + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the registry name") + var quiet = false + + public init() {} + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List image registry logins", + aliases: ["ls"]) + + public func run() async throws { + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + let registries = try keychain.list() + try printRegistries(registries: registries, format: format) + } + + private func createHeader() -> [[String]] { + [["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]] + } + + private func printRegistries(registries: [RegistryInfo], format: ListFormat) throws { + if format == .json { + let printables = registries.map { + PrintableRegistry($0) + } + let data = try JSONEncoder().encode(printables) + print(String(decoding: data, as: UTF8.self)) + + return + } + + if self.quiet { + registries.forEach { + print($0.hostname) + } + return + } + + var rows = createHeader() + for registry in registries { + rows.append(registry.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} +extension RegistryInfo { + fileprivate var asRow: [String] { + [ + self.hostname, + self.username, + self.modifiedDate.ISO8601Format(), + self.createdDate.ISO8601Format(), + ] + } +} +struct PrintableRegistry: Codable { + let hostname: String + let username: String + let modifiedDate: Date + let createdDate: Date + + init(_ registry: RegistryInfo) { + self.hostname = registry.hostname + self.username = registry.username + self.modifiedDate = registry.modifiedDate + self.createdDate = registry.createdDate + } +} diff --git a/Sources/ContainerCommands/Registry/Login.swift b/Sources/ContainerCommands/Registry/RegistryLogin.swift similarity index 95% rename from Sources/ContainerCommands/Registry/Login.swift rename to Sources/ContainerCommands/Registry/RegistryLogin.swift index b265ce4de..96d5919f5 100644 --- a/Sources/ContainerCommands/Registry/Login.swift +++ b/Sources/ContainerCommands/Registry/RegistryLogin.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. +// 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. @@ -22,9 +22,10 @@ import ContainerizationOCI import Foundation extension Application { - public struct Login: AsyncLoggableCommand { + public struct RegistryLogin: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( + commandName: "login", abstract: "Log in to a registry" ) diff --git a/Sources/ContainerCommands/Registry/Logout.swift b/Sources/ContainerCommands/Registry/RegistryLogout.swift similarity index 85% rename from Sources/ContainerCommands/Registry/Logout.swift rename to Sources/ContainerCommands/Registry/RegistryLogout.swift index e2cae4a77..2f5a2432b 100644 --- a/Sources/ContainerCommands/Registry/Logout.swift +++ b/Sources/ContainerCommands/Registry/RegistryLogout.swift @@ -1,5 +1,5 @@ //===----------------------------------------------------------------------===// -// Copyright © 2025-2026 Apple Inc. and the container project authors. +// 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. @@ -20,10 +20,12 @@ import Containerization import ContainerizationOCI extension Application { - public struct Logout: AsyncLoggableCommand { + public struct RegistryLogout: AsyncLoggableCommand { public init() {} public static let configuration = CommandConfiguration( - abstract: "Log out from a registry") + commandName: "logout", + abstract: "Log out from a registry" + ) @OptionGroup public var logOptions: Flags.Logging diff --git a/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift b/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift new file mode 100644 index 000000000..4b0b81783 --- /dev/null +++ b/Tests/CLITests/Subcommands/Registry/TestCLIRegistry.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 + +class TestCLIRegistry: CLITest { + @Test func testListDefaultFormat() throws { + let (_, output, error, status) = try run(arguments: ["registry", "list"]) + #expect(status == 0, "registry list should succeed, stderr: \(error)") + + // Check for table header + let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] + #expect( + requiredHeaders.allSatisfy { output.contains($0) }, + "output should contain all required headers" + ) + } + + @Test func testListQuietMode() throws { + let (_, output, error, status) = try run(arguments: ["registry", "list", "-q"]) + #expect(status == 0, "registry list -q should succeed, stderr: \(error)") + + #expect(!output.contains("HOSTNAME"), "quiet mode should not contain headers") + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 507971a3c..2d3d08fbb 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -912,6 +912,21 @@ container registry logout [--debug] No options. +### `container registry list` + +List image registry logins. + +**Usage** + +```bash +container registry list [--format ] [--quiet] [--debug] +``` + +**Options** + +* `--format `: Format of the output (values: json, table; default: table) +* `-q, --quiet`: Only output the image registry name + ## System Management System commands manage the container apiserver, logs, DNS settings and kernel. These are only available on macOS hosts.