Skip to content
Draft
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
36 changes: 10 additions & 26 deletions Sources/ContainerCommands/Registry/RegistryList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationOCI
import ContainerizationOS
import Foundation
Expand All @@ -38,29 +39,25 @@ extension Application {
aliases: ["ls"])

public func run() async throws {
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
let registries = try keychain.list()
let client = RegistryKeychainClient()
let registries = try await client.list()
try printRegistries(registries: registries, format: format)
}

private func createHeader() -> [[String]] {
[["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"]]
}

private func printRegistries(registries: [RegistryInfo], format: ListFormat) throws {
private func printRegistries(registries: [RegistryResource], format: ListFormat) throws {
if format == .json {
let printables = registries.map {
PrintableRegistry($0)
}
let data = try JSONEncoder().encode(printables)
let data = try JSONEncoder().encode(registries)
print(String(decoding: data, as: UTF8.self))

return
}

if self.quiet {
registries.forEach {
print($0.hostname)
print($0.name)
}
return
}
Expand All @@ -75,26 +72,13 @@ extension Application {
}
}
}
extension RegistryInfo {
extension RegistryResource {
fileprivate var asRow: [String] {
[
self.hostname,
self.name,
self.username,
self.modifiedDate.ISO8601Format(),
self.createdDate.ISO8601Format(),
self.modificationDate.ISO8601Format(),
self.creationDate.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
}
}
9 changes: 5 additions & 4 deletions Sources/ContainerCommands/Registry/RegistryLogin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOCI
Expand Down Expand Up @@ -45,6 +46,7 @@ extension Application {
var server: String

public func run() async throws {
let keychainClient = RegistryKeychainClient()
var username = self.username
var password = ""
if passwordStdin {
Expand All @@ -57,12 +59,11 @@ extension Application {
}
password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
if username == "" {
username = try keychain.userPrompt(hostname: server)
username = try await keychainClient.userPrompt(hostname: server)
}
if password == "" {
password = try keychain.passwordPrompt()
password = try await keychainClient.passwordPrompt()
print()
}

Expand Down Expand Up @@ -90,7 +91,7 @@ extension Application {
)
)
try await client.ping()
try keychain.save(hostname: server, username: username, password: password)
try await keychainClient.login(hostname: server, username: username, password: password)
print("Login succeeded")
}
}
Expand Down
6 changes: 3 additions & 3 deletions Sources/ContainerCommands/Registry/RegistryLogout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ extension Application {
var registry: String

public func run() async throws {
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
let r = Reference.resolveDomain(domain: registry)
try keychain.delete(hostname: r)
let hostname = Reference.resolveDomain(domain: registry)
let client = RegistryKeychainClient()
try await client.logout(hostname: hostname)
}
}
}
120 changes: 120 additions & 0 deletions Sources/ContainerResource/Registry/RegistryResource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//===----------------------------------------------------------------------===//
// 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 ContainerizationOS
import Foundation

/// A container registry resource representing a configured registry endpoint.
///
/// Registry resources store authentication and configuration information for
/// container registries such as Docker Hub, GitHub Container Registry, or
/// private registries.
public struct RegistryResource: ManagedResource {
/// The registry hostname that uniquely identifies this resource.
///
/// For registry resources, the identifier is the same as the hostname.
public let id: String

/// The hostname of the registry.
///
/// This value must be a valid DNS hostname or IPv6 address, optionally
/// followed by a port number (e.g., "docker.io", "localhost:5000", "[::1]:5000").
public var name: String

/// The username used for authentication with this registry.
public let username: String

/// The time at which the system created this registry resource.
public var creationDate: Date

/// The time at which the registry resource was last modified.
public var modificationDate: Date

/// Key-value properties for the resource.
///
/// The user and system may both make use of labels to read and write
/// annotations or other metadata.
public var labels: [String: String]

/// Validates a registry hostname according to OCI distribution specification.
///
/// This method validates that a registry hostname conforms to the domain pattern
/// used by OCI image references. It supports DNS hostnames, IPv6 addresses, and
/// optional port numbers.
///
/// - Parameter name: The registry hostname to validate
/// - Returns: `true` if the hostname is syntactically valid, `false` otherwise
///
/// ## Valid Examples
/// - `docker.io`
/// - `registry.example.com`
/// - `localhost:5000`
/// - `[::1]:5000`
///
/// ## Implementation Notes
/// The validation logic is based on ContainerizationOCI's `Reference.domainPattern`.
/// See <https://github.com/apple/containerization/blob/main/Sources/ContainerizationOCI/Reference.swift>
public static func nameValid(_ name: String) -> Bool {
// Domain validation logic based on ContainerizationOCI Reference.domainPattern
// See: https://github.com/apple/containerization/blob/main/Sources/ContainerizationOCI/Reference.swift
// TODO: if we have domain IP validation API, use that instead
let domainNameComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])"
let optionalPort = "(?::[0-9]+)?"
let ipv6address = "\\[(?:[a-fA-F0-9:]+)\\]"
let domainName = "\(domainNameComponent)(?:\\.\(domainNameComponent))*"
let host = "(?:\(domainName)|\(ipv6address))"
let pattern = "^\(host)\(optionalPort)$"

return name.range(of: pattern, options: .regularExpression) != nil
}

/// Creates a new registry resource.
///
/// - Parameters:
/// - hostname: The registry hostname (also used as the resource ID)
/// - username: The username for authentication
/// - creationDate: The time the resource was created
/// - modifiedDate: The time the resource was last modified
/// - labels: Optional key-value labels for metadata (default: empty dictionary)
public init(
hostname: String,
username: String,
creationDate: Date,
modifiedDate: Date,
labels: [String: String] = [:]
) {
self.id = hostname
self.name = hostname
self.username = username
self.creationDate = creationDate
self.modificationDate = modifiedDate
self.labels = labels
}
}

extension RegistryResource {
/// Creates a registry resource from registry information.
///
/// - Parameter registryInfo: The registry information to convert
public init(from registryInfo: RegistryInfo) {
self.init(
hostname: registryInfo.hostname,
username: registryInfo.username,
creationDate: registryInfo.createdDate,
modifiedDate: registryInfo.modifiedDate
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//===----------------------------------------------------------------------===//
// 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 ContainerResource
import ContainerXPC
import ContainerizationOCI
import ContainerizationOS
import Foundation

/// A client for managing registry credentials.
public struct RegistryKeychainClient {
private let keychain: KeychainHelper

/// Creates a a new instance using the default registry keychain domain.
public init() {
self.keychain = KeychainHelper(securityDomain: Constants.keychainID)
}

/// Returns all registry credentials stored in the keychain.
/// - Returns: An array of `RegistryResource` values representing saved registry entries.
/// - Throws: An error if the keychain query fails.
public func list() async throws -> [RegistryResource] {
let registries = try keychain.list()
return registries.map { RegistryResource(from: $0) }
}

/// Stores credentials for a registry in the keychain.
/// - Parameters:
/// - hostname: The registry hostname.
/// - username: The username for authentication.
/// - password: The password for authentication.
/// - Throws: An error if the credentials cannot be saved to the keychain.
public func login(hostname: String, username: String, password: String) async throws {
try keychain.save(hostname: hostname, username: username, password: password)
}

/// Removes stored credentials for a registry from the keychain.
/// - Parameter hostname: The registry hostname.
/// - Throws: An error if the credentials cannot be removed.
public func logout(hostname: String) async throws {
try keychain.delete(hostname: hostname)
}

/// Prompts the user to enter a registry username.
/// - Parameter hostname: The registry hostname being authenticated.
/// - Returns: The username entered by the user.
/// - Throws: An error if input cannot be read.
public func userPrompt(hostname: String) async throws -> String {
try keychain.userPrompt(hostname: hostname)
}

/// Prompts the user to enter a registry password.
/// - Returns: The password entered by the user.
/// - Throws: An error if input cannot be read.
public func passwordPrompt() async throws -> String {
try keychain.passwordPrompt()
}
}
Loading