chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// AnnotationKeys contains a subset of "dictionary keys" for commonly used annotations in an OCI Image Descriptor
|
||||
/// https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
public struct AnnotationKeys: Codable, Sendable {
|
||||
public static let containerizationIndexIndirect = "com.apple.containerization.index.indirect"
|
||||
public static let containerizationImageName = "com.apple.containerization.image.name"
|
||||
public static let containerdImageName = "io.containerd.image.name"
|
||||
public static let openContainersImageName = "org.opencontainers.image.ref.name"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationError
|
||||
import Foundation
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
private let _mount = Musl.mount
|
||||
private let _umount = Musl.umount2
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
private let _mount = Glibc.mount
|
||||
private let _umount = Glibc.umount2
|
||||
#endif
|
||||
|
||||
/// `Bundle` represents an OCI runtime spec bundle for running
|
||||
/// a container.
|
||||
public struct Bundle: Sendable {
|
||||
/// The path to the bundle.
|
||||
public let path: URL
|
||||
|
||||
/// The path to the OCI runtime spec config.json file.
|
||||
public var configPath: URL {
|
||||
self.path.appending(path: "config.json")
|
||||
}
|
||||
|
||||
/// The path to a rootfs mount inside the bundle.
|
||||
public var rootfsPath: URL {
|
||||
self.path.appending(path: "rootfs")
|
||||
}
|
||||
|
||||
/// Create the OCI bundle.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: A URL pointing to where to create the bundle on the filesystem.
|
||||
/// - spec: A data blob that should contain an OCI runtime spec. This will be written
|
||||
/// to the bundle as a "config.json" file.
|
||||
public static func create(path: URL, spec: Data) throws -> Bundle {
|
||||
try self.init(path: path, spec: spec)
|
||||
}
|
||||
|
||||
/// Create the OCI bundle.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: A URL pointing to where to create the bundle on the filesystem.
|
||||
/// - spec: An OCI runtime spec that will be written to the bundle as a "config.json"
|
||||
/// file.
|
||||
public static func create(path: URL, spec: ContainerizationOCI.Spec) throws -> Bundle {
|
||||
try self.init(path: path, spec: spec)
|
||||
}
|
||||
|
||||
/// Load an OCI bundle from the provided path.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: A URL pointing to where to load the bundle from on the filesystem.
|
||||
public static func load(path: URL) throws -> Bundle {
|
||||
try self.init(path: path)
|
||||
}
|
||||
|
||||
private init(path: URL) throws {
|
||||
let fm = FileManager.default
|
||||
if !fm.fileExists(atPath: path.path) {
|
||||
throw ContainerizationError(.invalidArgument, message: "no bundle at \(path.path)")
|
||||
}
|
||||
self.path = path
|
||||
}
|
||||
|
||||
// This constructor does not do any validation that data is actually a
|
||||
// valid OCI spec.
|
||||
private init(path: URL, spec: Data) throws {
|
||||
self.path = path
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(
|
||||
atPath: self.path.appending(component: "rootfs").path,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
try spec.write(to: self.configPath)
|
||||
}
|
||||
|
||||
private init(path: URL, spec: ContainerizationOCI.Spec) throws {
|
||||
self.path = path
|
||||
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(
|
||||
atPath: self.path.appending(component: "rootfs").path,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let specData = try JSONEncoder().encode(spec)
|
||||
try specData.write(to: self.configPath)
|
||||
}
|
||||
|
||||
/// Delete the OCI bundle from the filesystem.
|
||||
public func delete() throws {
|
||||
// Unmount, and then blow away the dir.
|
||||
#if os(Linux)
|
||||
let rootfs = self.rootfsPath
|
||||
if Self.isMountpoint(rootfs) {
|
||||
guard _umount(rootfs.path, 0) == 0 else {
|
||||
throw POSIXError.fromErrno()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
// removeItem is recursive so should blow away the rootfs dir inside as well.
|
||||
let fm = FileManager.default
|
||||
try fm.removeItem(at: self.path)
|
||||
}
|
||||
|
||||
/// Load and return the OCI runtime spec written to the bundle.
|
||||
public func loadConfig() throws -> ContainerizationOCI.Spec {
|
||||
let data = try Data(contentsOf: self.configPath)
|
||||
return try JSONDecoder().decode(ContainerizationOCI.Spec.self, from: data)
|
||||
}
|
||||
|
||||
private static func isMountpoint(_ path: URL) -> Bool {
|
||||
var st = stat()
|
||||
var parent_st = stat()
|
||||
|
||||
guard stat(path.path, &st) == 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
let parentPath = path.deletingLastPathComponent()
|
||||
guard stat(parentPath.path, &parent_st) == 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
return st.st_dev != parent_st.st_dev
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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
|
||||
|
||||
/// Abstraction for returning a token needed for logging into an OCI compliant registry.
|
||||
public protocol Authentication: Sendable {
|
||||
func token() async throws -> String
|
||||
}
|
||||
|
||||
/// Type representing authentication information for client to access the registry.
|
||||
public struct BasicAuthentication: Authentication {
|
||||
/// The username for the authentication.
|
||||
let username: String
|
||||
/// The password or identity token for the user.
|
||||
let password: String
|
||||
|
||||
public init(username: String, password: String) {
|
||||
self.username = username
|
||||
self.password = password
|
||||
}
|
||||
|
||||
/// Get a token using the provided username and password. This will be a
|
||||
/// base64 encoded string of the username and password delimited by a colon.
|
||||
public func token() async throws -> String {
|
||||
let credentials = "\(username):\(password)"
|
||||
if let authenticationData = credentials.data(using: .utf8)?.base64EncodedString() {
|
||||
return "Basic \(authenticationData)"
|
||||
}
|
||||
throw Error.invalidCredentials
|
||||
}
|
||||
|
||||
/// `BasicAuthentication` errors.
|
||||
public enum Error: Swift.Error {
|
||||
case invalidCredentials
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
#if os(macOS)
|
||||
import Foundation
|
||||
import ContainerizationOS
|
||||
|
||||
/// Helper type to lookup registry related values in the macOS keychain.
|
||||
public struct KeychainHelper: Sendable {
|
||||
private let securityDomain: String
|
||||
private let accessGroup: String?
|
||||
|
||||
/// Create a new keychain helper.
|
||||
/// - Parameters:
|
||||
/// - securityDomain: The security domain used to fetch registry entries in the keychain.
|
||||
/// - accessGroup: If present, the access group used to fetch registry entries in the keychain.
|
||||
public init(securityDomain: String, accessGroup: String? = nil) {
|
||||
self.securityDomain = securityDomain
|
||||
self.accessGroup = accessGroup
|
||||
}
|
||||
|
||||
/// Lookup authentication data for a given registry hostname.
|
||||
/// - Parameters:
|
||||
/// - hostname: The hostname for the registry.
|
||||
/// - Returns: The authentication object for the registry.
|
||||
/// - Throws: An error if the keychain query fails.
|
||||
public func lookup(hostname: String) throws -> Authentication {
|
||||
let kq = KeychainQuery()
|
||||
|
||||
do {
|
||||
guard
|
||||
let fetched = try kq.get(
|
||||
securityDomain: self.securityDomain,
|
||||
accessGroup: self.accessGroup,
|
||||
hostname: hostname)
|
||||
else {
|
||||
throw Self.Error.keyNotFound
|
||||
}
|
||||
return BasicAuthentication(
|
||||
username: fetched.username,
|
||||
password: fetched.password
|
||||
)
|
||||
} catch let err as KeychainQuery.Error {
|
||||
switch err {
|
||||
case .keyNotPresent(_):
|
||||
throw Self.Error.keyNotFound
|
||||
default:
|
||||
throw Self.Error.queryError("query failure: \(String(describing: err))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lists all registry entries for this security domain.
|
||||
/// - Returns: An array of registry metadata for each matching entry, or an empty array if none are found.
|
||||
/// - Throws: An error if the keychain query fails.
|
||||
public func list() throws -> [RegistryInfo] {
|
||||
let kq = KeychainQuery()
|
||||
return try kq.list(securityDomain: self.securityDomain, accessGroup: self.accessGroup)
|
||||
}
|
||||
|
||||
/// Delete authorization data for a given hostname from the keychain.
|
||||
/// - Parameters:
|
||||
/// - hostname: The hostname for the registry.
|
||||
/// - Throws: An error if the keychain query fails.
|
||||
public func delete(hostname: String) throws {
|
||||
let kq = KeychainQuery()
|
||||
try kq.delete(securityDomain: self.securityDomain, accessGroup: self.accessGroup, hostname: hostname)
|
||||
}
|
||||
|
||||
/// Save authorization data for a given hostname to the keychain.
|
||||
/// - Parameters:
|
||||
/// - hostname: The hostname for the registry.
|
||||
/// - username: The username to present to the registry.
|
||||
/// - password: The password to present to the registry.
|
||||
/// - Throws: An error if the keychain query fails or returns unexpected data.
|
||||
public func save(hostname: String, username: String, password: String) throws {
|
||||
let kq = KeychainQuery()
|
||||
try kq.save(
|
||||
securityDomain: self.securityDomain,
|
||||
accessGroup: self.accessGroup,
|
||||
hostname: hostname,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
}
|
||||
|
||||
/// Prompt for authorization data for a given hostname to be saved to the keychain.
|
||||
/// This will cause the current terminal to enter a password prompt state where
|
||||
/// key strokes are hidden.
|
||||
public func credentialPrompt(hostname: String) throws -> Authentication {
|
||||
let username = try userPrompt(hostname: hostname)
|
||||
let password = try passwordPrompt()
|
||||
return BasicAuthentication(username: username, password: password)
|
||||
}
|
||||
|
||||
/// Prompts the current stdin for a username entry and then returns the value.
|
||||
public func userPrompt(hostname: String) throws -> String {
|
||||
print("Provide registry username \(hostname): ", terminator: "")
|
||||
guard let username = readLine() else {
|
||||
throw Self.Error.invalidInput
|
||||
}
|
||||
return username
|
||||
}
|
||||
|
||||
/// Prompts the current stdin for a password entry and then returns the value.
|
||||
/// This will cause the current stdin (if it is a terminal) to hide keystrokes
|
||||
/// by disabling echo.
|
||||
public func passwordPrompt() throws -> String {
|
||||
print("Provide registry password: ", terminator: "")
|
||||
let console = try Terminal.current
|
||||
defer { console.tryReset() }
|
||||
try console.disableEcho()
|
||||
|
||||
guard let password = readLine() else {
|
||||
throw Self.Error.invalidInput
|
||||
}
|
||||
return password
|
||||
}
|
||||
}
|
||||
|
||||
extension KeychainHelper {
|
||||
/// `KeychainHelper` errors.
|
||||
public enum Error: Swift.Error {
|
||||
case keyNotFound
|
||||
case invalidInput
|
||||
case queryError(String)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,242 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import NIOFoundationCompat
|
||||
|
||||
package final class LocalOCILayoutClient: ContentClient {
|
||||
let cs: LocalContentStore
|
||||
|
||||
package init(root: URL) throws {
|
||||
self.cs = try LocalContentStore(path: root)
|
||||
}
|
||||
|
||||
private func _fetch(digest: String) async throws -> Content {
|
||||
guard let c: Content = try await self.cs.get(digest: digest) else {
|
||||
throw Error.missingContent(digest)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
private func calculateFileDigest(at url: URL) throws -> SHA256Digest {
|
||||
let fileHandle = try FileHandle(forReadingFrom: url)
|
||||
defer {
|
||||
try? fileHandle.close()
|
||||
}
|
||||
|
||||
var hasher = SHA256()
|
||||
let chunkSize = Int(getpagesize()) * 1024
|
||||
|
||||
while true {
|
||||
let chunk = fileHandle.readData(ofLength: chunkSize)
|
||||
if chunk.isEmpty {
|
||||
break
|
||||
}
|
||||
hasher.update(data: chunk)
|
||||
}
|
||||
|
||||
return hasher.finalize()
|
||||
}
|
||||
|
||||
package func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
|
||||
let c = try await self._fetch(digest: descriptor.digest)
|
||||
return try c.decode()
|
||||
}
|
||||
|
||||
package func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
|
||||
let c = try await self._fetch(digest: descriptor.digest)
|
||||
let fileManager = FileManager.default
|
||||
let filePath = file.absolutePath()
|
||||
|
||||
do {
|
||||
let src = c.path
|
||||
try fileManager.copyItem(at: src, to: file)
|
||||
|
||||
if let progress, let fileSize = fileManager.fileSize(atPath: filePath) {
|
||||
await progress([
|
||||
.addSize(fileSize)
|
||||
])
|
||||
}
|
||||
} catch let error as NSError {
|
||||
guard error.code == NSFileWriteFileExistsError else {
|
||||
throw error
|
||||
}
|
||||
|
||||
do {
|
||||
let expectedDigest = try c.digest()
|
||||
let existingDigest = try calculateFileDigest(at: file)
|
||||
|
||||
guard existingDigest.digestString == expectedDigest.digestString else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message:
|
||||
"file \(filePath) exists but contains different content, expected digest: \(expectedDigest.digestString), existing digest: \(existingDigest.digestString)"
|
||||
)
|
||||
}
|
||||
|
||||
if let progress, let fileSize = fileManager.fileSize(atPath: filePath) {
|
||||
await progress([
|
||||
.addSize(fileSize)
|
||||
])
|
||||
}
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
let size = try Int64(c.size())
|
||||
let digest = try c.digest()
|
||||
return (size, digest)
|
||||
}
|
||||
|
||||
package func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
|
||||
let c = try await self._fetch(digest: descriptor.digest)
|
||||
return try c.data()
|
||||
}
|
||||
|
||||
package func push<T: Sendable & AsyncSequence>(
|
||||
name: String,
|
||||
ref: String,
|
||||
descriptor: Descriptor,
|
||||
streamGenerator: () throws -> T,
|
||||
progress: ProgressHandler?
|
||||
) async throws where T.Element == ByteBuffer {
|
||||
let input = try streamGenerator()
|
||||
|
||||
let (id, dir) = try await self.cs.newIngestSession()
|
||||
do {
|
||||
let into = dir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix)
|
||||
guard FileManager.default.createFile(atPath: into.path, contents: nil) else {
|
||||
throw Error.cannotCreateFile
|
||||
}
|
||||
let fd = try FileHandle(forWritingTo: into)
|
||||
defer {
|
||||
try? fd.close()
|
||||
}
|
||||
var wrote = 0
|
||||
var hasher = SHA256()
|
||||
|
||||
for try await buffer in input {
|
||||
wrote += buffer.readableBytes
|
||||
try fd.write(contentsOf: buffer.readableBytesView)
|
||||
hasher.update(data: buffer.readableBytesView)
|
||||
}
|
||||
try await self.cs.completeIngestSession(id)
|
||||
} catch {
|
||||
try await self.cs.cancelIngestSession(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalOCILayoutClient {
|
||||
private static let ociLayoutFileName = "oci-layout"
|
||||
private static let ociLayoutVersionString = "imageLayoutVersion"
|
||||
private static let ociLayoutIndexFileName = "index.json"
|
||||
|
||||
package func loadIndexFromOCILayout(directory: URL) throws -> ContainerizationOCI.Index {
|
||||
let fm = FileManager.default
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let ociLayoutFile = directory.appendingPathComponent(Self.ociLayoutFileName)
|
||||
guard fm.fileExists(atPath: ociLayoutFile.absolutePath()) else {
|
||||
throw ContainerizationError(.notFound, message: ociLayoutFile.absolutePath())
|
||||
}
|
||||
var data = try Data(contentsOf: ociLayoutFile)
|
||||
let ociLayout = try decoder.decode([String: String].self, from: data)
|
||||
guard ociLayout[Self.ociLayoutVersionString] != nil else {
|
||||
throw ContainerizationError(.empty, message: "missing key \(Self.ociLayoutVersionString) in \(ociLayoutFile.absolutePath())")
|
||||
}
|
||||
|
||||
let indexFile = directory.appendingPathComponent(Self.ociLayoutIndexFileName)
|
||||
guard fm.fileExists(atPath: indexFile.absolutePath()) else {
|
||||
throw ContainerizationError(.notFound, message: indexFile.absolutePath())
|
||||
}
|
||||
data = try Data(contentsOf: indexFile)
|
||||
let index = try decoder.decode(ContainerizationOCI.Index.self, from: data)
|
||||
return index
|
||||
}
|
||||
|
||||
package func createOCILayoutStructure(directory: URL, manifests: [Descriptor]) throws {
|
||||
let fm = FileManager.default
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.withoutEscapingSlashes]
|
||||
|
||||
let ingestDir = directory.appendingPathComponent("ingest")
|
||||
try? fm.removeItem(at: ingestDir)
|
||||
let ociLayoutContent: [String: String] = [
|
||||
Self.ociLayoutVersionString: "1.0.0"
|
||||
]
|
||||
|
||||
var data = try encoder.encode(ociLayoutContent)
|
||||
var p = directory.appendingPathComponent(Self.ociLayoutFileName).absolutePath()
|
||||
guard fm.createFile(atPath: p, contents: data) else {
|
||||
throw ContainerizationError(.internalError, message: "failed to create file \(p)")
|
||||
}
|
||||
let idx = ContainerizationOCI.Index(schemaVersion: 2, manifests: manifests)
|
||||
data = try encoder.encode(idx)
|
||||
p = directory.appendingPathComponent(Self.ociLayoutIndexFileName).absolutePath()
|
||||
guard fm.createFile(atPath: p, contents: data) else {
|
||||
throw ContainerizationError(.internalError, message: "failed to create file \(p)")
|
||||
}
|
||||
}
|
||||
|
||||
package func setImageReferenceAnnotation(descriptor: inout Descriptor, reference: String) {
|
||||
var annotations = descriptor.annotations ?? [:]
|
||||
annotations[AnnotationKeys.containerizationImageName] = reference
|
||||
annotations[AnnotationKeys.containerdImageName] = reference
|
||||
annotations[AnnotationKeys.openContainersImageName] = reference
|
||||
descriptor.annotations = annotations
|
||||
}
|
||||
|
||||
package func getImageReferencefromDescriptor(descriptor: Descriptor) -> String {
|
||||
let annotations = descriptor.annotations
|
||||
|
||||
// Annotations here do not conform to the OCI image specification.
|
||||
// The interpretation of the annotations "org.opencontainers.image.ref.name" and
|
||||
// "io.containerd.image.name" is under debate:
|
||||
// - OCI spec examples suggest it should be the image tag:
|
||||
// https://github.com/opencontainers/image-spec/blob/fbb4662eb53b80bd38f7597406cf1211317768f0/image-layout.md?plain=1#L175
|
||||
// - Buildkitd maintainers argue it should represent the full image name:
|
||||
// https://github.com/moby/buildkit/issues/4615#issuecomment-2521810830
|
||||
// Until a consensus is reached, the preference is given to "com.apple.containerization.image.name" and then to
|
||||
// using "io.containerd.image.name" as it is the next safest choice
|
||||
if let annotations {
|
||||
if let name = annotations[AnnotationKeys.containerizationImageName] {
|
||||
return name
|
||||
}
|
||||
if let name = annotations[AnnotationKeys.containerdImageName] {
|
||||
return name
|
||||
}
|
||||
if let name = annotations[AnnotationKeys.openContainersImageName] {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Generate digest-based reference for images without annotations
|
||||
// This makes sure OCI spec compliance as annotations are optional
|
||||
return "untagged@\(descriptor.digest)"
|
||||
}
|
||||
|
||||
package enum Error: Swift.Error {
|
||||
case missingContent(_ digest: String)
|
||||
case unsupportedInput
|
||||
case cannotCreateFile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import NIOFoundationCompat
|
||||
|
||||
private struct CatalogResponse: Sendable, Decodable {
|
||||
let repositories: [String]
|
||||
}
|
||||
|
||||
extension RegistryClient {
|
||||
/// List repositories in the registry.
|
||||
///
|
||||
/// Implements GET /v2/_catalog from the OCI Distribution Spec with pagination.
|
||||
/// When prefix is provided, pagination skips ahead to the relevant portion of
|
||||
/// the lexically-sorted catalog and stops once results move past the prefix.
|
||||
///
|
||||
/// - Parameter prefix: Optional prefix to filter repository names. Must be at least
|
||||
/// two characters long to enable the skip-ahead optimization; shorter values are
|
||||
/// treated as no prefix.
|
||||
/// - Returns: An array of repository names matching the prefix (or all repositories
|
||||
/// if no prefix is given).
|
||||
public func catalog(prefix: String? = nil) async throws -> [String] {
|
||||
let effectivePrefix = prefix.flatMap { $0.count >= 2 ? $0 : nil }
|
||||
|
||||
var allRepos: [String] = []
|
||||
// When a prefix is provided, skip ahead in the lexically-sorted catalog
|
||||
// by setting last to one position before the prefix. The OCI spec
|
||||
// returns entries that sort after last, so dropping the last character
|
||||
// of the prefix positions the cursor just before matching entries.
|
||||
var last: String? = effectivePrefix.map { String($0.dropLast()) }
|
||||
let pageSize = 100
|
||||
|
||||
while true {
|
||||
var components = base
|
||||
components.path = "/v2/_catalog"
|
||||
var queryItems = [URLQueryItem(name: "n", value: String(pageSize))]
|
||||
if let last {
|
||||
queryItems.append(URLQueryItem(name: "last", value: last))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
|
||||
let repos: [String] = try await request(components: components) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
let buffer = try await response.body.collect(upTo: self.bufferSize)
|
||||
return try JSONDecoder().decode(CatalogResponse.self, from: buffer).repositories
|
||||
}
|
||||
|
||||
if let effectivePrefix {
|
||||
let matching = repos.filter { $0.hasPrefix(effectivePrefix) }
|
||||
allRepos.append(contentsOf: matching)
|
||||
if let lastRepo = repos.last, !lastRepo.hasPrefix(effectivePrefix) && lastRepo > effectivePrefix {
|
||||
break
|
||||
}
|
||||
} else {
|
||||
allRepos.append(contentsOf: repos)
|
||||
}
|
||||
|
||||
if repos.count < pageSize { break }
|
||||
last = repos.last
|
||||
}
|
||||
|
||||
return allRepos
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import Foundation
|
||||
import NIOHTTP1
|
||||
|
||||
extension RegistryClient {
|
||||
/// `RegistryClient` errors.
|
||||
public enum Error: Swift.Error, CustomStringConvertible {
|
||||
case invalidStatus(url: String, HTTPResponseStatus, reason: String? = nil)
|
||||
|
||||
/// Description of the errors.
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidStatus(let u, let response, let reason):
|
||||
return "HTTP request to \(u) failed with response: \(response.description). Reason: \(reason ?? "Unknown")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The container registry typically returns actionable failure reasons in the response body
|
||||
/// of the failing HTTP Request. This type models the structure of the error message.
|
||||
/// Reference: https://distribution.github.io/distribution/spec/api/#errors
|
||||
internal struct ErrorResponse: Codable {
|
||||
let errors: [RemoteError]
|
||||
|
||||
internal struct RemoteError: Codable {
|
||||
let code: String
|
||||
let message: String
|
||||
let detail: String?
|
||||
}
|
||||
|
||||
internal static func fromResponseBody(_ body: HTTPClientResponse.Body) async -> ErrorResponse? {
|
||||
guard var buffer = try? await body.collect(upTo: Int(1.mib())) else {
|
||||
return nil
|
||||
}
|
||||
guard let bytes = buffer.readBytes(length: buffer.readableBytes) else {
|
||||
return nil
|
||||
}
|
||||
let data = Data(bytes)
|
||||
guard let jsonError = try? JSONDecoder().decode(ErrorResponse.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return jsonError
|
||||
}
|
||||
|
||||
public var jsonString: String {
|
||||
let data = try? JSONEncoder().encode(self)
|
||||
guard let data else {
|
||||
return "{}"
|
||||
}
|
||||
return String(data: data, encoding: .utf8) ?? "{}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOFoundationCompat
|
||||
|
||||
#if os(macOS)
|
||||
import _NIOFileSystem
|
||||
#endif
|
||||
|
||||
extension RegistryClient {
|
||||
/// Resolve sends a HEAD request to the registry to find root manifest descriptor.
|
||||
/// This descriptor serves as an entry point to retrieve resources from the registry.
|
||||
public func resolve(name: String, tag: String) async throws -> Descriptor {
|
||||
var components = base
|
||||
|
||||
// Make HEAD request to retrieve the digest header
|
||||
components.path = "/v2/\(name)/manifests/\(tag)"
|
||||
|
||||
// The client should include an Accept header indicating which manifest content types it supports.
|
||||
let mediaTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
"*/*",
|
||||
]
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaTypes.joined(separator: ", "))
|
||||
]
|
||||
|
||||
return try await request(components: components, method: .HEAD, headers: headers) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
guard let digest = response.headers.first(name: "Docker-Content-Digest") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Docker-Content-Digest")
|
||||
}
|
||||
|
||||
guard let type = response.headers.first(name: "Content-Type") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Type")
|
||||
}
|
||||
|
||||
guard let sizeStr = response.headers.first(name: "Content-Length") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
|
||||
}
|
||||
|
||||
guard let size = Int64(sizeStr) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot convert \(sizeStr) to Int64")
|
||||
}
|
||||
|
||||
return Descriptor(mediaType: type, digest: digest, size: size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch resource (either manifest or blob) to memory with JSON decoding.
|
||||
public func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
|
||||
var components = base
|
||||
|
||||
let manifestTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
]
|
||||
|
||||
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
|
||||
let resource = isManifest ? "manifests" : "blobs"
|
||||
|
||||
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
return try await requestJSON(components: components, headers: headers)
|
||||
}
|
||||
|
||||
/// Fetch resource (either manifest or blob) to memory as raw `Data`.
|
||||
public func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
|
||||
var components = base
|
||||
|
||||
let manifestTypes = [
|
||||
MediaTypes.dockerManifest,
|
||||
MediaTypes.dockerManifestList,
|
||||
MediaTypes.imageManifest,
|
||||
MediaTypes.index,
|
||||
]
|
||||
|
||||
let isManifest = manifestTypes.contains(where: { $0 == descriptor.mediaType })
|
||||
let resource = isManifest ? "manifests" : "blobs"
|
||||
|
||||
components.path = "/v2/\(name)/\(resource)/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
return try await requestData(components: components, headers: headers)
|
||||
}
|
||||
|
||||
/// Fetch a blob from remote registry.
|
||||
/// This method is suitable for streaming data.
|
||||
public func fetchBlob(
|
||||
name: String,
|
||||
descriptor: Descriptor,
|
||||
closure: (Int64, HTTPClientResponse.Body) async throws -> Void
|
||||
) async throws {
|
||||
var components = base
|
||||
components.path = "/v2/\(name)/blobs/\(descriptor.digest)"
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
let headers = [
|
||||
("Accept", mediaType)
|
||||
]
|
||||
|
||||
try await request(components: components, headers: headers) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
// How many bytes to expect
|
||||
guard let expectedBytes = response.headers.first(name: "Content-Length").flatMap(Int64.init) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Content-Length")
|
||||
}
|
||||
|
||||
try await closure(expectedBytes, response.body)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
|
||||
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
|
||||
var hasher = SHA256()
|
||||
var received: Int64 = 0
|
||||
let fs = _NIOFileSystem.FileSystem.shared
|
||||
let handle = try await fs.openFile(forWritingAt: FilePath(file.absolutePath()), options: .newFile(replaceExisting: true))
|
||||
var writer = handle.bufferedWriter()
|
||||
do {
|
||||
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
|
||||
var itr = body.makeAsyncIterator()
|
||||
while let buf = try await itr.next() {
|
||||
let readBytes = Int64(buf.readableBytes)
|
||||
received += readBytes
|
||||
let written = try await writer.write(contentsOf: buf)
|
||||
await progress?([
|
||||
.addSize(written)
|
||||
])
|
||||
guard written == readBytes else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "could not write \(readBytes) bytes to file \(file)"
|
||||
)
|
||||
}
|
||||
hasher.update(data: buf.readableBytesView)
|
||||
}
|
||||
}
|
||||
try await writer.flush()
|
||||
try await handle.close()
|
||||
} catch {
|
||||
do {
|
||||
try await handle.close()
|
||||
} catch {
|
||||
// Use `detachUnsafeFileDescriptor()` as suggested by the error message to prevent a leak detection crash when `close()` fails.
|
||||
_ = try handle.detachUnsafeFileDescriptor()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
let computedDigest = hasher.finalize()
|
||||
return (received, computedDigest)
|
||||
}
|
||||
#else
|
||||
/// Fetch a blob from remote registry and write the contents into a file in the provided directory.
|
||||
public func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest) {
|
||||
var hasher = SHA256()
|
||||
var received: Int64 = 0
|
||||
guard FileManager.default.createFile(atPath: file.path, contents: nil) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot create file at path \(file.path)")
|
||||
}
|
||||
try await self.fetchBlob(name: name, descriptor: descriptor) { (size, body) in
|
||||
let fd = try FileHandle(forWritingTo: file)
|
||||
defer {
|
||||
try? fd.close()
|
||||
}
|
||||
var itr = body.makeAsyncIterator()
|
||||
while let buf = try await itr.next() {
|
||||
let readBytes = Int64(buf.readableBytes)
|
||||
received += readBytes
|
||||
await progress?([
|
||||
.addSize(readBytes)
|
||||
])
|
||||
try fd.write(contentsOf: buf.readableBytesView)
|
||||
hasher.update(data: buf.readableBytesView)
|
||||
}
|
||||
}
|
||||
let computedDigest = hasher.finalize()
|
||||
return (received, computedDigest)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
import NIO
|
||||
|
||||
extension RegistryClient {
|
||||
/// Pushes the content specified by a descriptor to a remote registry.
|
||||
/// - Parameters:
|
||||
/// - name: The namespace which the descriptor should belong under.
|
||||
/// - tag: The tag or digest for uniquely identifying the manifest.
|
||||
/// By convention, any portion that may be a partial or whole digest
|
||||
/// will be proceeded by an `@`. Anything preceding the `@` will be referred
|
||||
/// to as "tag".
|
||||
/// This is usually broken down into the following possibilities:
|
||||
/// 1. <tag>
|
||||
/// 2. <tag>@<digest>
|
||||
/// 3. @<digest>
|
||||
/// The tag is anything except `@` and `:`, and digest is anything after the `@`
|
||||
/// - descriptor: The OCI descriptor of the content to be pushed.
|
||||
/// - streamGenerator: A closure that produces an`AsyncStream` of `ByteBuffer`
|
||||
/// for streaming data to the `HTTPClientRequest.Body`.
|
||||
/// The caller is responsible for providing the `AsyncStream` where the data may come from
|
||||
/// a file on disk, data in memory, etc.
|
||||
/// - progress: The progress handler to invoke as data is sent.
|
||||
public func push<T: Sendable & AsyncSequence>(
|
||||
name: String,
|
||||
ref tag: String,
|
||||
descriptor: Descriptor,
|
||||
streamGenerator: () throws -> T,
|
||||
progress: ProgressHandler?
|
||||
) async throws where T.Element == ByteBuffer {
|
||||
var components = base
|
||||
|
||||
let mediaType = descriptor.mediaType
|
||||
if mediaType.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
|
||||
}
|
||||
|
||||
var isManifest = false
|
||||
var existCheck: [String] = []
|
||||
|
||||
switch mediaType {
|
||||
case MediaTypes.dockerManifest, MediaTypes.dockerManifestList, MediaTypes.imageManifest, MediaTypes.index:
|
||||
isManifest = true
|
||||
existCheck = self.getManifestPath(tag: tag, digest: descriptor.digest)
|
||||
default:
|
||||
existCheck = ["blobs", descriptor.digest]
|
||||
}
|
||||
|
||||
// Check if the content already exists.
|
||||
components.path = "/v2/\(name)/\(existCheck.joined(separator: "/"))"
|
||||
|
||||
let mediaTypes = [
|
||||
mediaType,
|
||||
"*/*",
|
||||
]
|
||||
|
||||
var headers = [
|
||||
("Accept", mediaTypes.joined(separator: ", "))
|
||||
]
|
||||
|
||||
try await request(components: components, method: .HEAD, headers: headers) { response in
|
||||
if response.status == .ok {
|
||||
var exists = false
|
||||
if isManifest && existCheck[1] != descriptor.digest {
|
||||
if descriptor.digest == response.headers.first(name: "Docker-Content-Digest") {
|
||||
exists = true
|
||||
}
|
||||
} else {
|
||||
exists = true
|
||||
}
|
||||
|
||||
if exists {
|
||||
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
|
||||
}
|
||||
} else if response.status != .notFound {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
}
|
||||
|
||||
if isManifest {
|
||||
let path = self.getManifestPath(tag: tag, digest: descriptor.digest)
|
||||
components.path = "/v2/\(name)/\(path.joined(separator: "/"))"
|
||||
headers = [
|
||||
("Content-Type", mediaType)
|
||||
]
|
||||
} else {
|
||||
// Start upload request for blobs.
|
||||
components.path = "/v2/\(name)/blobs/uploads/"
|
||||
try await request(components: components, method: .POST) { response in
|
||||
switch response.status {
|
||||
case .ok, .accepted, .noContent:
|
||||
break
|
||||
case .created:
|
||||
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
|
||||
default:
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
// Get the location to upload the blob.
|
||||
guard let location = response.headers.first(name: "Location") else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing required header Location")
|
||||
}
|
||||
|
||||
guard let urlComponents = URLComponents(string: location) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid url \(location)")
|
||||
}
|
||||
var queryItems = urlComponents.queryItems ?? []
|
||||
queryItems.append(URLQueryItem(name: "digest", value: descriptor.digest))
|
||||
components.path = urlComponents.path
|
||||
components.queryItems = queryItems
|
||||
headers = [
|
||||
("Content-Type", "application/octet-stream"),
|
||||
("Content-Length", String(descriptor.size)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// We have to pass a body closure rather than a body to reset the stream when retrying.
|
||||
let bodyClosure = {
|
||||
let stream = try streamGenerator()
|
||||
let body = HTTPClientRequest.Body.stream(stream, length: .known(descriptor.size))
|
||||
return body
|
||||
}
|
||||
|
||||
return try await request(components: components, method: .PUT, bodyClosure: bodyClosure, headers: headers) { response in
|
||||
switch response.status {
|
||||
case .ok, .created, .noContent:
|
||||
break
|
||||
default:
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
guard descriptor.digest == response.headers.first(name: "Docker-Content-Digest") else {
|
||||
let required = response.headers.first(name: "Docker-Content-Digest") ?? ""
|
||||
throw ContainerizationError(.internalError, message: "digest mismatch \(descriptor.digest) != \(required)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getManifestPath(tag: String, digest: String) -> [String] {
|
||||
var object = tag
|
||||
if let i = tag.firstIndex(of: "@") {
|
||||
let index = tag.index(after: i)
|
||||
if String(tag[index...]) != digest {
|
||||
object = ""
|
||||
} else {
|
||||
object = String(tag[...i])
|
||||
}
|
||||
}
|
||||
|
||||
if object == "" {
|
||||
return ["manifests", digest]
|
||||
}
|
||||
|
||||
return ["manifests", object]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import NIOFoundationCompat
|
||||
|
||||
extension RegistryClient {
|
||||
/// Query the OCI referrers API for artifacts that reference a given manifest digest.
|
||||
///
|
||||
/// Implements `GET /v2/{name}/referrers/{digest}` from the OCI Distribution Spec v1.1.
|
||||
/// Falls back to the referrers tag schema when the API is not available (404).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - name: The repository name (e.g., "library/ubuntu").
|
||||
/// - digest: The digest of the subject manifest (e.g., "sha256:abc123...").
|
||||
/// - artifactType: Optional filter to return only referrers with a matching artifactType.
|
||||
/// - Returns: An `Index` whose `manifests` array contains descriptors of referring artifacts.
|
||||
/// Returns an empty index if the registry does not support the referrers API
|
||||
/// and no tag schema fallback is available.
|
||||
public func referrers(name: String, digest: String, artifactType: String? = nil) async throws -> Index {
|
||||
var components = base
|
||||
components.path = "/v2/\(name)/referrers/\(digest)"
|
||||
|
||||
if let artifactType {
|
||||
components.queryItems = [URLQueryItem(name: "artifactType", value: artifactType)]
|
||||
}
|
||||
|
||||
let headers = [("Accept", MediaTypes.index)]
|
||||
|
||||
let result: Index = try await request(components: components, method: .GET, headers: headers) { response in
|
||||
if response.status == .notFound {
|
||||
return await self.referrersTagFallback(name: name, digest: digest, artifactType: artifactType)
|
||||
}
|
||||
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
let buffer = try await response.body.collect(upTo: self.bufferSize)
|
||||
return try JSONDecoder().decode(Index.self, from: buffer)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Fallback for registries that don't support the referrers API.
|
||||
///
|
||||
/// Uses the OCI referrers tag schema: referrers for a digest are stored as an
|
||||
/// index at the tag `<algorithm>-<hex>` (e.g., `sha256-abc123...`).
|
||||
private func referrersTagFallback(name: String, digest: String, artifactType: String? = nil) async -> Index {
|
||||
let referrerTag = digest.replacingOccurrences(of: ":", with: "-")
|
||||
|
||||
let descriptor: Descriptor
|
||||
do {
|
||||
descriptor = try await resolve(name: name, tag: referrerTag)
|
||||
} catch {
|
||||
return Index(schemaVersion: 2, manifests: [])
|
||||
}
|
||||
|
||||
let index: Index
|
||||
do {
|
||||
index = try await fetch(name: name, descriptor: descriptor)
|
||||
} catch {
|
||||
return Index(schemaVersion: 2, manifests: [])
|
||||
}
|
||||
|
||||
guard let artifactType else {
|
||||
return index
|
||||
}
|
||||
|
||||
let filtered = index.manifests.filter { $0.artifactType == artifactType }
|
||||
return Index(schemaVersion: 2, manifests: filtered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
struct TokenRequest {
|
||||
public static let authenticateHeaderName = "WWW-Authenticate"
|
||||
|
||||
/// The credentials that will be used in the authentication header when fetching the token.
|
||||
let authentication: Authentication?
|
||||
/// The realm against which the token should be requested.
|
||||
let realm: String
|
||||
/// The name of the service which hosts the resource.
|
||||
let service: String
|
||||
/// Whether to return a refresh token along with the bearer token.
|
||||
let offlineToken: Bool
|
||||
/// String identifying the client.
|
||||
let clientId: String
|
||||
/// The resource in question, formatted as one of the space-delimited entries from the scope parameters from the WWW-Authenticate header shown above.
|
||||
let scope: String?
|
||||
|
||||
init(
|
||||
realm: String,
|
||||
service: String,
|
||||
clientId: String,
|
||||
scope: String?,
|
||||
offlineToken: Bool = false,
|
||||
authentication: Authentication? = nil
|
||||
) {
|
||||
self.realm = realm
|
||||
self.service = service
|
||||
self.offlineToken = offlineToken
|
||||
self.clientId = clientId
|
||||
self.scope = scope
|
||||
self.authentication = authentication
|
||||
}
|
||||
}
|
||||
|
||||
struct TokenResponse: Codable, Hashable {
|
||||
/// An opaque Bearer token that clients should supply to subsequent requests in the Authorization header.
|
||||
let token: String?
|
||||
/// For compatibility with OAuth 2.0, we will also accept token under the name access_token.
|
||||
/// At least one of these fields must be specified, but both may also appear (for compatibility with older clients).
|
||||
/// When both are specified, they should be equivalent; if they differ the client's choice is undefined.
|
||||
let accessToken: String?
|
||||
/// The duration in seconds since the token was issued that it will remain valid.
|
||||
/// When omitted, this defaults to 60 seconds.
|
||||
let expiresIn: UInt?
|
||||
/// The RFC3339-serialized UTC standard time at which a given token was issued.
|
||||
/// If issued_at is omitted, the expiration is from when the token exchange completed.
|
||||
let issuedAt: String?
|
||||
/// Token which can be used to get additional access tokens for the same subject with different scopes.
|
||||
/// This token should be kept secure by the client and only sent to the authorization server which issues bearer tokens.
|
||||
/// This field will only be set when `offline_token=true` is provided in the request.
|
||||
let refreshToken: String?
|
||||
|
||||
var scope: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case token = "token"
|
||||
case accessToken = "access_token"
|
||||
case expiresIn = "expires_in"
|
||||
case issuedAt = "issued_at"
|
||||
case refreshToken = "refresh_token"
|
||||
}
|
||||
|
||||
func getToken() -> String? {
|
||||
if let t = token ?? accessToken {
|
||||
return "Bearer \(t)"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValid(scope: String?) -> Bool {
|
||||
guard let issuedAt else {
|
||||
return false
|
||||
}
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
guard let issued = isoFormatter.date(from: issuedAt) else {
|
||||
return false
|
||||
}
|
||||
let expiresIn = expiresIn ?? 0
|
||||
let now = Date()
|
||||
let elapsed = now.timeIntervalSince(issued)
|
||||
guard elapsed < Double(expiresIn) else {
|
||||
return false
|
||||
}
|
||||
if let requiredScope = scope {
|
||||
return requiredScope == self.scope
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct AuthenticateChallenge: Equatable {
|
||||
let type: String
|
||||
let realm: String?
|
||||
let service: String?
|
||||
let scope: String?
|
||||
let error: String?
|
||||
|
||||
init(type: String, realm: String?, service: String?, scope: String?, error: String?) {
|
||||
self.type = type
|
||||
self.realm = realm
|
||||
self.service = service
|
||||
self.scope = scope
|
||||
self.error = error
|
||||
}
|
||||
|
||||
init(type: String, values: [String: String]) {
|
||||
self.type = type
|
||||
self.realm = values["realm"]
|
||||
self.service = values["service"]
|
||||
self.scope = values["scope"]
|
||||
self.error = values["error"]
|
||||
}
|
||||
}
|
||||
|
||||
extension RegistryClient {
|
||||
/// Fetch an auto token for all subsequent HTTP requests
|
||||
/// See https://docs.docker.com/registry/spec/auth/token/
|
||||
internal func fetchToken(request: TokenRequest) async throws -> TokenResponse {
|
||||
guard var components = URLComponents(string: request.realm) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot create URL from \(request.realm)")
|
||||
}
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: request.clientId),
|
||||
URLQueryItem(name: "service", value: request.service),
|
||||
]
|
||||
var scope = ""
|
||||
if let reqScope = request.scope {
|
||||
scope = reqScope
|
||||
components.queryItems?.append(URLQueryItem(name: "scope", value: reqScope))
|
||||
}
|
||||
|
||||
if request.offlineToken {
|
||||
components.queryItems?.append(URLQueryItem(name: "offline_token", value: "true"))
|
||||
}
|
||||
var response: TokenResponse = try await requestJSON(components: components, headers: [])
|
||||
response.scope = scope
|
||||
return response
|
||||
}
|
||||
|
||||
internal func createTokenRequest(parsing authenticateHeaders: [String]) throws -> TokenRequest {
|
||||
let parsedHeaders = Self.parseWWWAuthenticateHeaders(headers: authenticateHeaders)
|
||||
let bearerChallenge = parsedHeaders.first { $0.type == "Bearer" }
|
||||
guard let bearerChallenge else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing Bearer challenge in \(TokenRequest.authenticateHeaderName) header")
|
||||
}
|
||||
guard let realm = bearerChallenge.realm else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot parse realm from \(TokenRequest.authenticateHeaderName) header")
|
||||
}
|
||||
guard let service = bearerChallenge.service else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot parse service from \(TokenRequest.authenticateHeaderName) header")
|
||||
}
|
||||
let scope = bearerChallenge.scope
|
||||
let tokenRequest = TokenRequest(realm: realm, service: service, clientId: self.clientID, scope: scope, authentication: self.authentication)
|
||||
return tokenRequest
|
||||
}
|
||||
|
||||
internal static func parseWWWAuthenticateHeaders(headers: [String]) -> [AuthenticateChallenge] {
|
||||
var parsed: [String: [String: String]] = [:]
|
||||
for challenge in headers {
|
||||
let trimmedChallenge = challenge.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = trimmedChallenge.split(separator: " ", maxSplits: 1)
|
||||
guard parts.count == 2 else {
|
||||
continue
|
||||
}
|
||||
guard let scheme = parts.first else {
|
||||
continue
|
||||
}
|
||||
var params: [String: String] = [:]
|
||||
let header = String(parts[1])
|
||||
let pattern = #"(\w+)="([^"]+)"#
|
||||
let regex = try! NSRegularExpression(pattern: pattern, options: [])
|
||||
let matches = regex.matches(in: header, options: [], range: NSRange(header.startIndex..., in: header))
|
||||
for match in matches {
|
||||
if let keyRange = Range(match.range(at: 1), in: header),
|
||||
let valueRange = Range(match.range(at: 2), in: header)
|
||||
{
|
||||
let key = String(header[keyRange])
|
||||
let value = String(header[valueRange])
|
||||
params[key] = value
|
||||
}
|
||||
}
|
||||
parsed[String(scheme)] = params
|
||||
}
|
||||
var parsedChallenges: [AuthenticateChallenge] = []
|
||||
for (type, values) in parsed {
|
||||
parsedChallenges.append(.init(type: type, values: values))
|
||||
}
|
||||
return parsedChallenges
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 AsyncHTTPClient
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import NIO
|
||||
import NIOHTTP1
|
||||
import NIOSSL
|
||||
|
||||
#if os(macOS)
|
||||
import Network
|
||||
#endif
|
||||
|
||||
/// Data used to control retry behavior for `RegistryClient`.
|
||||
public struct RetryOptions: Sendable {
|
||||
/// The maximum number of retries to attempt before failing.
|
||||
public var maxRetries: Int
|
||||
/// The retry interval in nanoseconds.
|
||||
public var retryInterval: UInt64
|
||||
/// A provided closure to handle if a given HTTP response should be
|
||||
/// retried.
|
||||
public var shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)?
|
||||
|
||||
public init(maxRetries: Int, retryInterval: UInt64, shouldRetry: (@Sendable (HTTPClientResponse) -> Bool)? = nil) {
|
||||
self.maxRetries = maxRetries
|
||||
self.retryInterval = retryInterval
|
||||
self.shouldRetry = shouldRetry
|
||||
}
|
||||
}
|
||||
|
||||
/// A client for interacting with OCI compliant container registries.
|
||||
public final class RegistryClient: ContentClient {
|
||||
private static let defaultRetryOptions = RetryOptions(
|
||||
maxRetries: 3,
|
||||
retryInterval: 1_000_000_000,
|
||||
shouldRetry: ({ response in
|
||||
response.status.code >= 500
|
||||
})
|
||||
)
|
||||
|
||||
let client: HTTPClient
|
||||
let proxyURL: URL?
|
||||
let base: URLComponents
|
||||
let clientID: String
|
||||
let authentication: Authentication?
|
||||
let retryOptions: RetryOptions?
|
||||
let bufferSize: Int
|
||||
|
||||
public convenience init(
|
||||
reference: String,
|
||||
insecure: Bool = false,
|
||||
auth: Authentication? = nil,
|
||||
tlsConfiguration: TLSConfiguration? = nil,
|
||||
logger: Logger? = nil,
|
||||
) throws {
|
||||
let ref = try Reference.parse(reference)
|
||||
guard let domain = ref.resolvedDomain else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid domain for image reference \(reference)")
|
||||
}
|
||||
let scheme = insecure ? "http" : "https"
|
||||
let _url = "\(scheme)://\(domain)"
|
||||
guard let url = URL(string: _url) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL")
|
||||
}
|
||||
guard let host = url.host else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid host \(domain)")
|
||||
}
|
||||
let port = url.port
|
||||
self.init(
|
||||
host: host,
|
||||
scheme: scheme,
|
||||
port: port,
|
||||
authentication: auth,
|
||||
retryOptions: Self.defaultRetryOptions,
|
||||
tlsConfiguration: tlsConfiguration,
|
||||
)
|
||||
}
|
||||
|
||||
public init(
|
||||
host: String,
|
||||
scheme: String? = "https",
|
||||
port: Int? = nil,
|
||||
authentication: Authentication? = nil,
|
||||
clientID: String? = nil,
|
||||
retryOptions: RetryOptions? = nil,
|
||||
bufferSize: Int = Int(4.mib()),
|
||||
tlsConfiguration: TLSConfiguration? = nil,
|
||||
logger: Logger? = nil,
|
||||
) {
|
||||
var components = URLComponents()
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.port = port
|
||||
|
||||
self.base = components
|
||||
self.clientID = clientID ?? "containerization-registry-client"
|
||||
self.authentication = authentication
|
||||
self.retryOptions = retryOptions
|
||||
self.bufferSize = bufferSize
|
||||
var httpConfiguration = HTTPClient.Configuration()
|
||||
|
||||
// proxy configuration assumes all client requests will go to `base` URL
|
||||
self.proxyURL = ProxyUtils.proxyFromEnvironment(scheme: scheme, host: host)
|
||||
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
|
||||
let proxyPort = proxyURL.port ?? (proxyURL.scheme == "https" ? 443 : 80)
|
||||
httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyPort)
|
||||
}
|
||||
if tlsConfiguration != nil {
|
||||
httpConfiguration.tlsConfiguration = tlsConfiguration
|
||||
}
|
||||
|
||||
if let logger {
|
||||
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration, backgroundActivityLogger: logger)
|
||||
} else {
|
||||
self.client = HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
_ = client.shutdown()
|
||||
}
|
||||
|
||||
func host() -> String {
|
||||
base.host ?? ""
|
||||
}
|
||||
|
||||
internal func request<T>(
|
||||
components: URLComponents,
|
||||
method: HTTPMethod = .GET,
|
||||
bodyClosure: () throws -> HTTPClientRequest.Body? = { nil },
|
||||
headers: [(String, String)]? = nil,
|
||||
closure: (HTTPClientResponse) async throws -> T
|
||||
) async throws -> T {
|
||||
guard let path = components.url?.absoluteString else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid url \(components.path)")
|
||||
}
|
||||
|
||||
var request = HTTPClientRequest(url: path)
|
||||
request.method = method
|
||||
|
||||
var currentToken: TokenResponse?
|
||||
let token: String? = try await {
|
||||
if let basicAuth = authentication {
|
||||
return try await basicAuth.token()
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
if let token {
|
||||
request.headers.add(name: "Authorization", value: "\(token)")
|
||||
}
|
||||
|
||||
// Add any arbitrary headers
|
||||
headers?.forEach { (k, v) in request.headers.add(name: k, value: v) }
|
||||
var retryCount = 0
|
||||
var response: HTTPClientResponse?
|
||||
while true {
|
||||
request.body = try bodyClosure()
|
||||
do {
|
||||
let _response = try await client.execute(request, deadline: .distantFuture)
|
||||
response = _response
|
||||
if _response.status == .unauthorized || _response.status == .forbidden {
|
||||
let authHeader = _response.headers[TokenRequest.authenticateHeaderName]
|
||||
let tokenRequest: TokenRequest
|
||||
do {
|
||||
tokenRequest = try self.createTokenRequest(parsing: authHeader)
|
||||
} catch {
|
||||
// The server did not tell us how to authenticate our requests,
|
||||
// Or we do not support scheme the server is requesting for.
|
||||
// Throw the 401/403 to the caller, and let them decide how to proceed.
|
||||
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: String(describing: error))
|
||||
}
|
||||
if let ct = currentToken, ct.isValid(scope: tokenRequest.scope) {
|
||||
break
|
||||
}
|
||||
|
||||
do {
|
||||
let _currentToken = try await fetchToken(request: tokenRequest)
|
||||
guard let token = _currentToken.getToken() else {
|
||||
throw ContainerizationError(.internalError, message: "failed to fetch Bearer token")
|
||||
}
|
||||
currentToken = _currentToken
|
||||
request.headers.replaceOrAdd(name: "Authorization", value: token)
|
||||
retryCount += 1
|
||||
} catch let err as RegistryClient.Error {
|
||||
guard case .invalidStatus(_, let status, _) = err else {
|
||||
throw err
|
||||
}
|
||||
if status == .unauthorized || status == .forbidden {
|
||||
throw RegistryClient.Error.invalidStatus(url: path, _response.status, reason: "access denied or wrong credentials")
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
continue
|
||||
} else if _response.status == .badRequest && request.headers.contains(name: "Authorization") {
|
||||
// Retry without basic auth
|
||||
request.headers.remove(name: "Authorization")
|
||||
retryCount += 1
|
||||
continue
|
||||
}
|
||||
guard let retryOptions = self.retryOptions else {
|
||||
break
|
||||
}
|
||||
guard retryCount < retryOptions.maxRetries else {
|
||||
break
|
||||
}
|
||||
guard let shouldRetry = retryOptions.shouldRetry, shouldRetry(_response) else {
|
||||
break
|
||||
}
|
||||
retryCount += 1
|
||||
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
|
||||
continue
|
||||
} catch let err as RegistryClient.Error {
|
||||
throw err
|
||||
} catch {
|
||||
#if os(macOS)
|
||||
if let err = error as? NWError {
|
||||
if err.errorCode == kDNSServiceErr_NoSuchRecord {
|
||||
let message: String
|
||||
if let proxyURL = self.proxyURL, let proxyHost = proxyURL.host {
|
||||
message = "failed to resolve either repository hostname \(host()) or proxy hostname \(proxyHost)"
|
||||
} else {
|
||||
message = "failed to resolve either repository hostname \(host())"
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: message)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
guard let retryOptions = self.retryOptions, retryCount < retryOptions.maxRetries else {
|
||||
throw error
|
||||
}
|
||||
retryCount += 1
|
||||
try await Task.sleep(nanoseconds: retryOptions.retryInterval)
|
||||
}
|
||||
}
|
||||
guard let response else {
|
||||
throw ContainerizationError(.internalError, message: "invalid response")
|
||||
}
|
||||
return try await closure(response)
|
||||
}
|
||||
|
||||
internal func requestData(
|
||||
components: URLComponents,
|
||||
headers: [(String, String)]? = nil
|
||||
) async throws -> Data {
|
||||
let bytes: ByteBuffer = try await requestBuffer(components: components, headers: headers)
|
||||
return Data(buffer: bytes)
|
||||
}
|
||||
|
||||
internal func requestBuffer(
|
||||
components: URLComponents,
|
||||
headers: [(String, String)]? = nil
|
||||
) async throws -> ByteBuffer {
|
||||
try await request(components: components, method: .GET, headers: headers) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
|
||||
return try await response.body.collect(upTo: self.bufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
internal func requestJSON<T: Decodable>(
|
||||
components: URLComponents,
|
||||
headers: [(String, String)]? = nil
|
||||
) async throws -> T {
|
||||
let buffer = try await self.requestBuffer(components: components, headers: headers)
|
||||
return try JSONDecoder().decode(T.self, from: buffer)
|
||||
}
|
||||
|
||||
/// A minimal endpoint, mounted at /v2/ will provide version support information based on its response statuses.
|
||||
/// See https://distribution.github.io/distribution/spec/api/#api-version-check
|
||||
public func ping() async throws {
|
||||
var components = base
|
||||
components.path = "/v2/"
|
||||
|
||||
try await request(components: components) { response in
|
||||
guard response.status == .ok else {
|
||||
let url = components.url?.absoluteString ?? "unknown"
|
||||
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
|
||||
throw Error.invalidStatus(url: url, response.status, reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
package actor AsyncStore<T> {
|
||||
private var _value: T?
|
||||
|
||||
package init(_ value: T? = nil) {
|
||||
self._value = value
|
||||
}
|
||||
|
||||
package func get() -> T? {
|
||||
self._value
|
||||
}
|
||||
|
||||
package func set(_ value: T) {
|
||||
self._value = value
|
||||
}
|
||||
}
|
||||
|
||||
package actor AsyncSet<T: Hashable> {
|
||||
private var buffer: Set<T>
|
||||
|
||||
package init<S: Sequence>(_ elements: S) where S.Element == T {
|
||||
buffer = Set(elements)
|
||||
}
|
||||
|
||||
package var count: Int {
|
||||
buffer.count
|
||||
}
|
||||
|
||||
package func insert(_ element: T) {
|
||||
buffer.insert(element)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
package func remove(_ element: T) -> T? {
|
||||
buffer.remove(element)
|
||||
}
|
||||
|
||||
package func contains(_ element: T) -> Bool {
|
||||
buffer.contains(element)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationExtras
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Protocol for defining a single OCI content
|
||||
public protocol Content: Sendable {
|
||||
/// URL to the content
|
||||
var path: URL { get }
|
||||
|
||||
/// sha256 of content
|
||||
func digest() throws -> SHA256.Digest
|
||||
|
||||
/// Size of content
|
||||
func size() throws -> UInt64
|
||||
|
||||
/// Data representation of entire content
|
||||
func data() throws -> Data
|
||||
|
||||
/// Data representation partial content
|
||||
func data(offset: UInt64, length: Int) throws -> Data?
|
||||
|
||||
/// Decode the content into an object
|
||||
func decode<T>() throws -> T where T: Decodable
|
||||
}
|
||||
|
||||
/// Protocol defining methods to fetch and push OCI content
|
||||
public protocol ContentClient: Sendable {
|
||||
func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T
|
||||
|
||||
func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256Digest)
|
||||
|
||||
func fetchData(name: String, descriptor: Descriptor) async throws -> Data
|
||||
|
||||
func push<T: Sendable & AsyncSequence>(
|
||||
name: String,
|
||||
ref: String,
|
||||
descriptor: Descriptor,
|
||||
streamGenerator: () throws -> T,
|
||||
progress: ProgressHandler?
|
||||
) async throws where T.Element == ByteBuffer
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Crypto
|
||||
import Foundation
|
||||
|
||||
/// Protocol for defining a content store where OCI image metadata and layers will be managed
|
||||
/// and manipulated.
|
||||
public protocol ContentStore: Sendable {
|
||||
/// Retrieves a piece of Content based on the digest string.
|
||||
/// Returns `nil` if the requested digest is not found.
|
||||
func get(digest: String) async throws -> Content?
|
||||
|
||||
/// Retrieves a specific content metadata type based on the digest string.
|
||||
/// Returns `nil` if the requested digest is not found.
|
||||
func get<T: Decodable>(digest: String) async throws -> T?
|
||||
|
||||
/// Remove a list of digests in the content store.
|
||||
@discardableResult
|
||||
func delete(digests: [String]) async throws -> ([String], UInt64)
|
||||
|
||||
/// Removes all content from the store except for the digests in the provided list.
|
||||
@discardableResult
|
||||
func delete(keeping: [String]) async throws -> ([String], UInt64)
|
||||
|
||||
/// Creates a transactional write to the content store.
|
||||
/// The function takes a closure given a temporary `URL` of the base directory which all contents should be written to.
|
||||
/// This is transaction write where any failed operation in the closure (caught exception) will result in all contents written
|
||||
/// in the closure to be deleted.
|
||||
///
|
||||
/// If the closure succeeds, then all the content that have been written to the temporary `URL` will be moved into the actual
|
||||
/// blobs path of the content store.
|
||||
@discardableResult
|
||||
func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String]
|
||||
|
||||
/// Creates a new ingest session and returns the session ID and temporary ingest directory corresponding to the session.
|
||||
/// The contents from the ingest directory are processed and moved into the content store once the session is marked complete.
|
||||
/// This can be done by invoking the `completeIngestSession` method with the returned session ID.
|
||||
func newIngestSession() async throws -> (id: String, ingestDir: URL)
|
||||
|
||||
/// Completes a previously started ingest session corresponding to `id`.
|
||||
/// The contents from the ingest directory from the session are moved into the content store atomically.
|
||||
/// Any failure encountered will result in a transaction failure causing none of the contents to be ingested into the store.
|
||||
@discardableResult
|
||||
func completeIngestSession(_ id: String) async throws -> [String]
|
||||
|
||||
/// Cancels a previously started ingest session corresponding to `id`.
|
||||
/// The contents from the ingest directory corresponding to the session are removed.
|
||||
func cancelIngestSession(_ id: String) async throws
|
||||
|
||||
/// Total bytes allocated on disk for the content store, covering
|
||||
/// committed blobs and any active ingest sessions.
|
||||
func totalAllocatedSize() async throws -> UInt64
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationError
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIOCore
|
||||
|
||||
/// Provides a context to write data into a directory.
|
||||
public class ContentWriter {
|
||||
private let base: URL
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
/// Create a new ContentWriter.
|
||||
/// - Parameters:
|
||||
/// - base: The URL to write content to. If this is not a directory a
|
||||
/// ContainerizationError will be thrown with a code of .internalError.
|
||||
public init(for base: URL) throws {
|
||||
self.encoder.outputFormatting = [JSONEncoder.OutputFormatting.sortedKeys]
|
||||
|
||||
self.base = base
|
||||
var isDirectory = ObjCBool(true)
|
||||
let exists = FileManager.default.fileExists(atPath: base.path, isDirectory: &isDirectory)
|
||||
|
||||
guard exists && isDirectory.boolValue else {
|
||||
throw ContainerizationError(.internalError, message: "cannot create ContentWriter for path \(base.absolutePath()), not a directory")
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the data blob to the base URL provided in the constructor.
|
||||
/// - Parameters:
|
||||
/// - data: The data blob to write to a file under the base path.
|
||||
@discardableResult
|
||||
public func write(_ data: Data) throws -> (size: Int64, digest: SHA256.Digest) {
|
||||
let digest = SHA256.hash(data: data)
|
||||
let destination = base.appendingPathComponent(digest.encoded)
|
||||
try data.write(to: destination)
|
||||
return (Int64(data.count), digest)
|
||||
}
|
||||
|
||||
/// Reads the data present in the passed in URL and writes it to the base path.
|
||||
/// - Parameters:
|
||||
/// - url: The URL to read the data from.
|
||||
@discardableResult
|
||||
public func create(from url: URL) throws -> (size: Int64, digest: SHA256.Digest) {
|
||||
let sourceFD = Foundation.open(url.path, O_RDONLY)
|
||||
guard sourceFD >= 0 else {
|
||||
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
|
||||
let err = POSIXError(errCode)
|
||||
throw ContainerizationError(.internalError, message: "failed to open \(url.path) for reading", cause: err)
|
||||
}
|
||||
defer { close(sourceFD) }
|
||||
|
||||
let tempURL = base.appendingPathComponent(UUID().uuidString)
|
||||
let destFD = Foundation.open(tempURL.path, O_WRONLY | O_CREAT | O_TRUNC, 0o644)
|
||||
guard destFD >= 0 else {
|
||||
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
|
||||
let err = POSIXError(errCode)
|
||||
throw ContainerizationError(.internalError, message: "failed to create temporary file at \(tempURL.absolutePath())", cause: err)
|
||||
}
|
||||
|
||||
let chunkSize = 1024 * 1024 // 1 MiB
|
||||
let buf = UnsafeMutableRawBufferPointer.allocate(byteCount: chunkSize, alignment: 1)
|
||||
defer { buf.deallocate() }
|
||||
guard let baseAddress = buf.baseAddress else {
|
||||
close(destFD)
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw ContainerizationError(.internalError, message: "failed to allocate read buffer of size \(chunkSize)")
|
||||
}
|
||||
|
||||
var hasher = SHA256()
|
||||
var totalSize: Int64 = 0
|
||||
while true {
|
||||
let n = read(sourceFD, baseAddress, chunkSize)
|
||||
if n == 0 { break }
|
||||
if n < 0 {
|
||||
close(destFD)
|
||||
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
|
||||
let err = POSIXError(errCode)
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw ContainerizationError(.internalError, message: "failed to read from \(url.path)", cause: err)
|
||||
}
|
||||
hasher.update(data: UnsafeRawBufferPointer(start: baseAddress, count: n))
|
||||
var written = 0
|
||||
while written < n {
|
||||
let w = Foundation.write(destFD, baseAddress.advanced(by: written), n - written)
|
||||
if w < 0 {
|
||||
close(destFD)
|
||||
let errCode = POSIXErrorCode(rawValue: errno) ?? .EINVAL
|
||||
let err = POSIXError(errCode)
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw ContainerizationError(.internalError, message: "failed to write to \(tempURL.absolutePath())", cause: err)
|
||||
}
|
||||
written += w
|
||||
}
|
||||
totalSize += Int64(n)
|
||||
}
|
||||
close(destFD)
|
||||
|
||||
let digest = hasher.finalize()
|
||||
let destination = base.appendingPathComponent(digest.encoded)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: tempURL, to: destination)
|
||||
} catch let error as NSError {
|
||||
guard error.code == NSFileWriteFileExistsError else {
|
||||
throw error
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw error
|
||||
}
|
||||
return (totalSize, digest)
|
||||
}
|
||||
|
||||
/// Encodes the passed in type as a JSON blob and writes it to the base path.
|
||||
/// - Parameters:
|
||||
/// - content: The type to convert to JSON.
|
||||
@discardableResult
|
||||
public func create<T: Encodable>(from content: T) throws -> (size: Int64, digest: SHA256.Digest) {
|
||||
let data = try self.encoder.encode(content)
|
||||
return try self.write(data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationError
|
||||
import Crypto
|
||||
import Foundation
|
||||
|
||||
public final class LocalContent: Content {
|
||||
public let path: URL
|
||||
private let file: FileHandle
|
||||
|
||||
public init(path: URL) throws {
|
||||
guard FileManager.default.fileExists(atPath: path.path) else {
|
||||
throw ContainerizationError(.notFound, message: "content at path \(path.absolutePath())")
|
||||
}
|
||||
|
||||
self.file = try FileHandle(forReadingFrom: path)
|
||||
self.path = path
|
||||
}
|
||||
|
||||
public func digest() throws -> SHA256.Digest {
|
||||
let bufferSize = 64 * 1024 // 64 KB
|
||||
var hasher = SHA256()
|
||||
|
||||
try self.file.seek(toOffset: 0)
|
||||
while case let data = file.readData(ofLength: bufferSize), !data.isEmpty {
|
||||
hasher.update(data: data)
|
||||
}
|
||||
|
||||
let digest = hasher.finalize()
|
||||
|
||||
try self.file.seek(toOffset: 0)
|
||||
return digest
|
||||
}
|
||||
|
||||
public func data(offset: UInt64 = 0, length size: Int = 0) throws -> Data? {
|
||||
try file.seek(toOffset: offset)
|
||||
if size == 0 {
|
||||
return try file.readToEnd()
|
||||
}
|
||||
return try file.read(upToCount: size)
|
||||
}
|
||||
|
||||
public func data() throws -> Data {
|
||||
try Data(contentsOf: self.path)
|
||||
}
|
||||
|
||||
public func size() throws -> UInt64 {
|
||||
let fileAttrs = try FileManager.default.attributesOfItem(atPath: self.path.absolutePath())
|
||||
if let size = fileAttrs[FileAttributeKey.size] as? UInt64 {
|
||||
return size
|
||||
}
|
||||
throw ContainerizationError(.internalError, message: "could not determine file size for \(path.absolutePath())")
|
||||
}
|
||||
|
||||
public func decode<T>() throws -> T where T: Decodable {
|
||||
let json = JSONDecoder()
|
||||
let data = try Data(contentsOf: self.path)
|
||||
return try json.decode(T.self, from: data)
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? self.file.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// swiftlint:disable unused_optional_binding
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import Crypto
|
||||
import Foundation
|
||||
|
||||
/// A `ContentStore` implementation that stores content on the local filesystem.
|
||||
public actor LocalContentStore: ContentStore {
|
||||
private static let encoder = JSONEncoder()
|
||||
|
||||
private let _basePath: URL
|
||||
private let _ingestPath: URL
|
||||
private let _blobPath: URL
|
||||
private let _lock: AsyncLock
|
||||
|
||||
private var activeIngestSessions: AsyncSet<String> = AsyncSet([])
|
||||
|
||||
/// Create a new `LocalContentStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: The path where content should be written under.
|
||||
public init(path: URL) throws {
|
||||
let ingestPath = path.appendingPathComponent("ingest")
|
||||
let blobPath = path.appendingPathComponent("blobs/sha256")
|
||||
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(at: ingestPath, withIntermediateDirectories: true)
|
||||
try fileManager.createDirectory(at: blobPath, withIntermediateDirectories: true)
|
||||
|
||||
self._basePath = path
|
||||
self._ingestPath = ingestPath
|
||||
self._blobPath = blobPath
|
||||
self._lock = AsyncLock()
|
||||
Self.encoder.outputFormatting = .sortedKeys
|
||||
}
|
||||
|
||||
/// Get a piece of content from the store. Returns nil if not
|
||||
/// found.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - digest: The string digest of the content.
|
||||
public func get(digest: String) throws -> Content? {
|
||||
let d = digest.trimmingDigestPrefix
|
||||
let path = self._blobPath.appendingPathComponent(d)
|
||||
do {
|
||||
return try LocalContent(path: path)
|
||||
} catch let err as ContainerizationError {
|
||||
switch err.code {
|
||||
case .notFound:
|
||||
return nil
|
||||
default:
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a piece of content from the store and return the decoded version of
|
||||
/// it.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - digest: The string digest of the content.
|
||||
public func get<T: Decodable & Sendable>(digest: String) throws -> T? {
|
||||
guard let content: Content = try self.get(digest: digest) else {
|
||||
return nil
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Delete all content besides a set provided.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - keeping: The set of string digests to keep.
|
||||
public func delete(keeping: [String]) async throws -> ([String], UInt64) {
|
||||
let fileManager = FileManager.default
|
||||
let all = try fileManager.contentsOfDirectory(at: self._blobPath, includingPropertiesForKeys: nil)
|
||||
let allDigests = Set(all.map { $0.lastPathComponent })
|
||||
let toDelete = allDigests.subtracting(keeping)
|
||||
return try await self.delete(digests: Array(toDelete))
|
||||
}
|
||||
|
||||
/// Delete a specific set of content.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - digests: Array of strings denoting the digests of the content to delete.
|
||||
@discardableResult
|
||||
public func delete(digests: [String]) async throws -> ([String], UInt64) {
|
||||
let store = AsyncStore<([String], UInt64)>()
|
||||
try await self._lock.withLock { context in
|
||||
let fileManager = FileManager.default
|
||||
var deleted: [String] = []
|
||||
var deletedBytes: UInt64 = 0
|
||||
for toDelete in digests {
|
||||
let p = self._blobPath.appendingPathComponent(toDelete)
|
||||
guard let content = try? LocalContent(path: p) else {
|
||||
continue
|
||||
}
|
||||
deletedBytes += try content.size()
|
||||
try fileManager.removeItem(at: p)
|
||||
deleted.append(toDelete)
|
||||
}
|
||||
await store.set((deleted, deletedBytes))
|
||||
}
|
||||
return await store.get() ?? ([], 0)
|
||||
}
|
||||
|
||||
/// Creates a transactional write to the content store.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - body: Closure that is given a temporary `URL` of the base directory which all contents should be written to.
|
||||
/// This is a transaction write where any failed operation in the closure (caught exception) will result in all contents written
|
||||
/// in the closure to be deleted. If the closure succeeds, then all the content that have been written to the temporary `URL`
|
||||
/// will be moved into the actual blobs path of the content store.
|
||||
@discardableResult
|
||||
public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] {
|
||||
let (id, tempPath) = try await self.newIngestSession()
|
||||
try await body(tempPath)
|
||||
return try await self.completeIngestSession(id)
|
||||
}
|
||||
|
||||
/// Creates a new ingest session and returns the session ID and temporary ingest directory corresponding to the session.
|
||||
/// The contents from the ingest directory are processed and moved into the content store once the session is marked complete.
|
||||
/// This can be done by invoking the `completeIngestSession` method with the returned session ID.
|
||||
public func newIngestSession() async throws -> (id: String, ingestDir: URL) {
|
||||
let id = UUID().uuidString
|
||||
let temporaryPath = self._ingestPath.appendingPathComponent(id)
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(atPath: temporaryPath.path, withIntermediateDirectories: true)
|
||||
await self.activeIngestSessions.insert(id)
|
||||
return (id, temporaryPath)
|
||||
}
|
||||
|
||||
/// Completes a previously started ingest session corresponding to `id`. The contents from the ingest
|
||||
/// directory from the session are moved into the content store atomically. Any failure encountered will
|
||||
/// result in a transaction failure causing none of the contents to be ingested into the store.
|
||||
/// - Parameters:
|
||||
/// - id: id of the ingest session to complete.
|
||||
@discardableResult
|
||||
public func completeIngestSession(_ id: String) async throws -> [String] {
|
||||
guard await activeIngestSessions.contains(id) else {
|
||||
throw ContainerizationError(.internalError, message: "invalid session id \(id)")
|
||||
}
|
||||
await activeIngestSessions.remove(id)
|
||||
let temporaryPath = self._ingestPath.appendingPathComponent(id)
|
||||
let fileManager = FileManager.default
|
||||
defer {
|
||||
try? fileManager.removeItem(at: temporaryPath)
|
||||
}
|
||||
let tempDigests: [URL] = try fileManager.contentsOfDirectory(at: temporaryPath, includingPropertiesForKeys: nil)
|
||||
return try await self._lock.withLock { context in
|
||||
var moved: [String] = []
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
try tempDigests.forEach {
|
||||
let digest = $0.lastPathComponent
|
||||
let target = self._blobPath.appendingPathComponent(digest)
|
||||
// only ingest if not exists
|
||||
if !fileManager.fileExists(atPath: target.path) {
|
||||
try fileManager.moveItem(at: $0, to: target)
|
||||
moved.append(digest)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
moved.forEach {
|
||||
try? fileManager.removeItem(at: self._blobPath.appendingPathComponent($0))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return tempDigests.map { $0.lastPathComponent }
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a previously started ingest session corresponding to `id`.
|
||||
/// The contents from the ingest directory corresponding to the session are removed.
|
||||
/// - Parameters:
|
||||
/// - id: id of the ingest session to complete.
|
||||
public func cancelIngestSession(_ id: String) async throws {
|
||||
guard let _ = await self.activeIngestSessions.remove(id) else {
|
||||
return
|
||||
}
|
||||
let temporaryPath = self._ingestPath.appendingPathComponent(id)
|
||||
let fileManager = FileManager.default
|
||||
try? fileManager.removeItem(at: temporaryPath)
|
||||
}
|
||||
|
||||
/// Total bytes allocated on disk for the content store, covering
|
||||
/// committed blobs and any active ingest sessions.
|
||||
public func totalAllocatedSize() throws -> UInt64 {
|
||||
let fileManager = FileManager.default
|
||||
guard
|
||||
let enumerator = fileManager.enumerator(
|
||||
at: self._basePath,
|
||||
includingPropertiesForKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
else {
|
||||
throw ContainerizationError(.internalError, message: "failed to enumerate content store at \(self._basePath.path)")
|
||||
}
|
||||
var size: UInt64 = 0
|
||||
for case let fileURL as URL in enumerator {
|
||||
guard let values = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .isRegularFileKey]), values.isRegularFile == true,
|
||||
let fileSize = values.totalFileAllocatedSize
|
||||
else {
|
||||
// Skip directories and other non-regular entries. On Linux,
|
||||
// `.totalFileAllocatedSizeKey` reports block allocation for
|
||||
// directories, which would otherwise count empty-store
|
||||
// inode overhead as content.
|
||||
continue
|
||||
}
|
||||
size += UInt64(fileSize)
|
||||
}
|
||||
return size
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Crypto
|
||||
import Foundation
|
||||
|
||||
extension SHA256.Digest {
|
||||
/// Returns the digest as a string.
|
||||
public var digestString: String {
|
||||
let parts = self.description.split(separator: ": ")
|
||||
return "sha256:\(parts[1])"
|
||||
}
|
||||
|
||||
/// Returns the digest without a 'sha256:' prefix.
|
||||
public var encoded: String {
|
||||
let parts = self.description.split(separator: ": ")
|
||||
return String(parts[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
extension String {
|
||||
/// Removes any prefix (sha256:) from a digest string.
|
||||
public var trimmingDigestPrefix: String {
|
||||
let split = self.split(separator: ":")
|
||||
if split.count == 2 {
|
||||
return String(split[1])
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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
|
||||
|
||||
extension URL {
|
||||
/// Returns the unescaped absolutePath of a URL joined by separator.
|
||||
public func absolutePath() -> String {
|
||||
#if os(macOS)
|
||||
return self.path(percentEncoded: false)
|
||||
#else
|
||||
return self.path
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Returns the domain name of a registry.
|
||||
public var domain: String? {
|
||||
guard let host = self.absoluteString.split(separator: ":").first else {
|
||||
return nil
|
||||
}
|
||||
return String(host)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/descriptor.go
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Descriptor describes the disposition of targeted content.
|
||||
/// This structure provides `application/vnd.oci.descriptor.v1+json` mediatype
|
||||
/// when marshalled to JSON.
|
||||
public struct Descriptor: Codable, Sendable, Equatable {
|
||||
/// mediaType is the media type of the object this schema refers to.
|
||||
public let mediaType: String
|
||||
|
||||
/// digest is the digest of the targeted content.
|
||||
public let digest: String
|
||||
|
||||
/// size specifies the size in bytes of the blob.
|
||||
public let size: Int64
|
||||
|
||||
/// urls specifies a list of URLs from which this object MAY be downloaded.
|
||||
public let urls: [String]?
|
||||
|
||||
/// annotations contains arbitrary metadata relating to the targeted content.
|
||||
public var annotations: [String: String]?
|
||||
|
||||
/// platform describes the platform which the image in the manifest runs on.
|
||||
///
|
||||
/// This should only be used when referring to a manifest.
|
||||
public var platform: Platform?
|
||||
|
||||
/// artifactType specifies the IANA media type of the artifact.
|
||||
///
|
||||
/// Used in referrers API responses to indicate the type of each referring artifact.
|
||||
public let artifactType: String?
|
||||
|
||||
public init(
|
||||
mediaType: String, digest: String, size: Int64, urls: [String]? = nil, annotations: [String: String]? = nil,
|
||||
platform: Platform? = nil, artifactType: String? = nil
|
||||
) {
|
||||
self.mediaType = mediaType
|
||||
self.digest = digest
|
||||
self.size = size
|
||||
self.urls = urls
|
||||
self.annotations = annotations
|
||||
self.platform = platform
|
||||
self.artifactType = artifactType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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
|
||||
|
||||
extension FileManager {
|
||||
func fileSize(atPath path: String) -> Int64? {
|
||||
do {
|
||||
let attributes = try attributesOfItem(atPath: path)
|
||||
guard let fileSize = attributes[.size] as? NSNumber else {
|
||||
return nil
|
||||
}
|
||||
return fileSize.int64Value
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/config.go
|
||||
|
||||
import Foundation
|
||||
|
||||
/// ImageConfig defines the execution parameters which should be used as a base when running a container using an image.
|
||||
public struct ImageConfig: Codable, Sendable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case user = "User"
|
||||
case env = "Env"
|
||||
case entrypoint = "Entrypoint"
|
||||
case cmd = "Cmd"
|
||||
case workingDir = "WorkingDir"
|
||||
case labels = "Labels"
|
||||
case stopSignal = "StopSignal"
|
||||
}
|
||||
|
||||
/// user defines the username or UID which the process in the container should run as.
|
||||
public let user: String?
|
||||
|
||||
/// env is a list of environment variables to be used in a container.
|
||||
public let env: [String]?
|
||||
|
||||
/// entrypoint defines a list of arguments to use as the command to execute when the container starts.
|
||||
public let entrypoint: [String]?
|
||||
|
||||
/// cmd defines the default arguments to the entrypoint of the container.
|
||||
public let cmd: [String]?
|
||||
|
||||
/// workingDir sets the current working directory of the entrypoint process in the container.
|
||||
public let workingDir: String?
|
||||
|
||||
/// labels contains arbitrary metadata for the container.
|
||||
public let labels: [String: String]?
|
||||
|
||||
/// stopSignal contains the system call signal that will be sent to the container to exit.
|
||||
public let stopSignal: String?
|
||||
|
||||
public init(
|
||||
user: String? = nil, env: [String]? = nil, entrypoint: [String]? = nil, cmd: [String]? = nil,
|
||||
workingDir: String? = nil, labels: [String: String]? = nil, stopSignal: String? = nil
|
||||
) {
|
||||
self.user = user
|
||||
self.env = env
|
||||
self.entrypoint = entrypoint
|
||||
self.cmd = cmd
|
||||
self.workingDir = workingDir
|
||||
self.labels = labels
|
||||
self.stopSignal = stopSignal
|
||||
}
|
||||
}
|
||||
|
||||
/// RootFS describes a layer content addresses
|
||||
public struct Rootfs: Codable, Sendable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case diffIDs = "diff_ids"
|
||||
}
|
||||
|
||||
/// type is the type of the rootfs.
|
||||
public let type: String
|
||||
|
||||
/// diffIDs is an array of layer content hashes (DiffIDs), in order from bottom-most to top-most.
|
||||
public let diffIDs: [String]
|
||||
|
||||
public init(type: String, diffIDs: [String]) {
|
||||
self.type = type
|
||||
self.diffIDs = diffIDs
|
||||
}
|
||||
}
|
||||
|
||||
/// History describes the history of a layer.
|
||||
public struct History: Codable, Sendable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case created
|
||||
case createdBy = "created_by"
|
||||
case author
|
||||
case comment
|
||||
case emptyLayer = "empty_layer"
|
||||
}
|
||||
|
||||
/// created is the combined date and time at which the layer was created, formatted as defined by RFC 3339, section 5.6.
|
||||
public let created: String?
|
||||
|
||||
/// createdBy is the command which created the layer.
|
||||
public let createdBy: String?
|
||||
|
||||
/// author is the author of the build point.
|
||||
public let author: String?
|
||||
|
||||
/// comment is a custom message set when creating the layer.
|
||||
public let comment: String?
|
||||
|
||||
/// emptyLayer is used to mark if the history item created a filesystem diff.
|
||||
public let emptyLayer: Bool?
|
||||
|
||||
public init(
|
||||
created: String? = nil, createdBy: String? = nil, author: String? = nil, comment: String? = nil,
|
||||
emptyLayer: Bool? = nil
|
||||
) {
|
||||
self.created = created
|
||||
self.createdBy = createdBy
|
||||
self.author = author
|
||||
self.comment = comment
|
||||
self.emptyLayer = emptyLayer
|
||||
}
|
||||
}
|
||||
|
||||
/// Image is the JSON structure which describes some basic information about the image.
|
||||
/// This provides the `application/vnd.oci.image.config.v1+json` mediatype when marshalled to JSON.
|
||||
public struct Image: Codable, Sendable {
|
||||
/// created is the combined date and time at which the image was created, formatted as defined by RFC 3339, section 5.6.
|
||||
public let created: String?
|
||||
|
||||
/// author defines the name and/or email address of the person or entity which created and is responsible for maintaining the image.
|
||||
public let author: String?
|
||||
|
||||
/// architecture field specifies the CPU architecture, for example `amd64` or `ppc64`.
|
||||
public let architecture: String
|
||||
|
||||
/// os specifies the operating system, for example `linux` or `windows`.
|
||||
public let os: String
|
||||
|
||||
/// osVersion is an optional field specifying the operating system version, for example on Windows `10.0.14393.1066`.
|
||||
public let osVersion: String?
|
||||
|
||||
/// osFeatures is an optional field specifying an array of strings, each listing a required OS feature (for example on Windows `win32k`).
|
||||
public let osFeatures: [String]?
|
||||
|
||||
/// variant is an optional field specifying a variant of the CPU, for example `v7` to specify ARMv7 when architecture is `arm`.
|
||||
public let variant: String?
|
||||
|
||||
/// config defines the execution parameters which should be used as a base when running a container using the image.
|
||||
public let config: ImageConfig?
|
||||
|
||||
/// rootfs references the layer content addresses used by the image.
|
||||
public let rootfs: Rootfs
|
||||
|
||||
/// history describes the history of each layer.
|
||||
public let history: [History]?
|
||||
|
||||
public init(
|
||||
created: String? = nil, author: String? = nil, architecture: String, os: String, osVersion: String? = nil,
|
||||
osFeatures: [String]? = nil, variant: String? = nil, config: ImageConfig? = nil, rootfs: Rootfs,
|
||||
history: [History]? = nil
|
||||
) {
|
||||
self.created = created
|
||||
self.author = author
|
||||
self.architecture = architecture
|
||||
self.os = os
|
||||
self.osVersion = osVersion
|
||||
self.osFeatures = osFeatures
|
||||
self.variant = variant
|
||||
self.config = config
|
||||
self.rootfs = rootfs
|
||||
self.history = history
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/index.go
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Index references manifests for various platforms.
|
||||
/// This structure provides `application/vnd.oci.image.index.v1+json` mediatype when marshalled to JSON.
|
||||
public struct Index: Codable, Sendable {
|
||||
/// schemaVersion is the image manifest schema that this image follows
|
||||
public let schemaVersion: Int
|
||||
|
||||
/// mediaType specifies the type of this document data structure e.g. `application/vnd.oci.image.index.v1+json`
|
||||
/// This field is optional per the OCI Image Index Specification (omitempty)
|
||||
public let mediaType: String
|
||||
|
||||
/// manifests references platform specific manifests.
|
||||
public var manifests: [Descriptor]
|
||||
|
||||
/// annotations contains arbitrary metadata for the image index.
|
||||
public var annotations: [String: String]?
|
||||
|
||||
/// `subject` references another manifest this index is an artifact of.
|
||||
public let subject: Descriptor?
|
||||
|
||||
/// `artifactType` specifies the IANA media type of the artifact this index represents.
|
||||
public let artifactType: String?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = 2, mediaType: String = MediaTypes.index, manifests: [Descriptor],
|
||||
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.mediaType = mediaType
|
||||
self.manifests = manifests
|
||||
self.annotations = annotations
|
||||
self.subject = subject
|
||||
self.artifactType = artifactType
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.schemaVersion = try container.decode(Int.self, forKey: .schemaVersion)
|
||||
self.mediaType = try container.decodeIfPresent(String.self, forKey: .mediaType) ?? ""
|
||||
self.manifests = try container.decode([Descriptor].self, forKey: .manifests)
|
||||
self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations)
|
||||
self.subject = try container.decodeIfPresent(Descriptor.self, forKey: .subject)
|
||||
self.artifactType = try container.decodeIfPresent(String.self, forKey: .artifactType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/manifest.go
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Manifest provides `application/vnd.oci.image.manifest.v1+json` mediatype structure when marshalled to JSON.
|
||||
public struct Manifest: Codable, Sendable {
|
||||
/// `schemaVersion` is the image manifest schema that this image follows.
|
||||
public let schemaVersion: Int
|
||||
|
||||
/// `mediaType` specifies the type of this document data structure, e.g. `application/vnd.oci.image.manifest.v1+json`.
|
||||
public let mediaType: String?
|
||||
|
||||
/// `config` references a configuration object for a container, by digest.
|
||||
/// The referenced configuration object is a JSON blob that the runtime uses to set up the container.
|
||||
public let config: Descriptor
|
||||
|
||||
/// `layers` is an indexed list of layers referenced by the manifest.
|
||||
public let layers: [Descriptor]
|
||||
|
||||
/// `annotations` contains arbitrary metadata for the image manifest.
|
||||
public let annotations: [String: String]?
|
||||
|
||||
/// `subject` references another manifest this manifest is an artifact of.
|
||||
public let subject: Descriptor?
|
||||
|
||||
/// `artifactType` specifies the IANA media type of the artifact this manifest represents.
|
||||
public let artifactType: String?
|
||||
|
||||
public init(
|
||||
schemaVersion: Int = 2, mediaType: String = MediaTypes.imageManifest, config: Descriptor, layers: [Descriptor],
|
||||
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.mediaType = mediaType
|
||||
self.config = config
|
||||
self.layers = layers
|
||||
self.annotations = annotations
|
||||
self.subject = subject
|
||||
self.artifactType = artifactType
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// MediaTypes represent all supported OCI image content types for both metadata and layer formats.
|
||||
/// Follows all distributable media types in: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/mediatype.go
|
||||
public struct MediaTypes: Codable, Sendable {
|
||||
/// Specifies the media type for a content descriptor.
|
||||
public static let descriptor = "application/vnd.oci.descriptor.v1+json"
|
||||
|
||||
/// Specifies the media type for the oci-layout.
|
||||
public static let layoutHeader = "application/vnd.oci.layout.header.v1+json"
|
||||
|
||||
/// Specifies the media type for an image index.
|
||||
public static let index = "application/vnd.oci.image.index.v1+json"
|
||||
|
||||
/// Specifies the media type for an image manifest.
|
||||
public static let imageManifest = "application/vnd.oci.image.manifest.v1+json"
|
||||
|
||||
/// Specifies the media type for the image configuration.
|
||||
public static let imageConfig = "application/vnd.oci.image.config.v1+json"
|
||||
|
||||
/// Specifies the media type for an unused blob containing the value "{}".
|
||||
public static let emptyJSON = "application/vnd.oci.empty.v1+json"
|
||||
|
||||
/// Specifies the media type for a Docker image manifest.
|
||||
public static let dockerManifest = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
/// Specifies the media type for a Docker image manifest list.
|
||||
public static let dockerManifestList = "application/vnd.docker.distribution.manifest.list.v2+json"
|
||||
|
||||
/// The Docker media type used for image configurations.
|
||||
public static let dockerImageConfig = "application/vnd.docker.container.image.v1+json"
|
||||
|
||||
/// The media type used for layers referenced by the manifest.
|
||||
public static let imageLayer = "application/vnd.oci.image.layer.v1.tar"
|
||||
|
||||
/// The media type used for gzipped layers referenced by the manifest.
|
||||
public static let imageLayerGzip = "application/vnd.oci.image.layer.v1.tar+gzip"
|
||||
|
||||
/// The media type used for zstd compressed layers referenced by the manifest.
|
||||
public static let imageLayerZstd = "application/vnd.oci.image.layer.v1.tar+zstd"
|
||||
|
||||
/// The Docker media type used for uncompressed layers referenced by an image manifest.
|
||||
public static let dockerImageLayer = "application/vnd.docker.image.rootfs.diff.tar"
|
||||
|
||||
/// The Docker media type used for gzipped layers referenced by an image manifest.
|
||||
public static let dockerImageLayerGzip = "application/vnd.docker.image.rootfs.diff.tar.gzip"
|
||||
|
||||
/// The Docker media type used for zstd compressed layers referenced by an image manifest.
|
||||
public static let dockerImageLayerZstd = "application/vnd.docker.image.rootfs.diff.tar.zstd"
|
||||
|
||||
/// The media type used for in-toto attestations blobs.
|
||||
public static let inTotoAttestationBlob = "application/vnd.in-toto+json"
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// Source: https://github.com/opencontainers/image-spec/blob/main/specs-go/v1/config.go
|
||||
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
|
||||
/// Platform describes the platform which the image in the manifest runs on.
|
||||
public struct Platform: Sendable, Equatable {
|
||||
/// Normalizes a raw architecture string (e.g. from uname) to its OCI equivalent.
|
||||
static func normalizeArch(_ raw: String) -> (arch: String, variant: String?) {
|
||||
switch raw {
|
||||
case "aarch64", "arm64":
|
||||
return ("arm64", "v8")
|
||||
case "x86_64", "x86-64", "amd64":
|
||||
return ("amd64", nil)
|
||||
case "arm", "armhf", "armel":
|
||||
return ("arm", "v7")
|
||||
default:
|
||||
return (raw, nil)
|
||||
}
|
||||
}
|
||||
|
||||
public static var current: Self {
|
||||
var systemInfo = utsname()
|
||||
uname(&systemInfo)
|
||||
let arch = withUnsafePointer(to: &systemInfo.machine) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
let normalized = normalizeArch(arch)
|
||||
return .init(arch: normalized.arch, os: "linux", variant: normalized.variant)
|
||||
}
|
||||
|
||||
/// The computed description, for example, `linux/arm64/v8`.
|
||||
public var description: String {
|
||||
let architecture = architecture
|
||||
if let variant = variant {
|
||||
return "\(os)/\(architecture)/\(variant)"
|
||||
}
|
||||
return "\(os)/\(architecture)"
|
||||
}
|
||||
|
||||
/// The CPU architecture, for example, `amd64` or `arm64`.
|
||||
public var architecture: String {
|
||||
Self.normalizeArch(_rawArch).arch
|
||||
}
|
||||
|
||||
/// The operating system, for example, `linux` or `windows`.
|
||||
public var os: String {
|
||||
_rawOS
|
||||
}
|
||||
|
||||
/// An optional field specifying the operating system version, for example on Windows `10.0.14393.1066`.
|
||||
public var osVersion: String?
|
||||
|
||||
/// An optional field specifying an array of strings, each listing a required OS feature (for example on Windows `win32k`).
|
||||
public var osFeatures: [String]?
|
||||
|
||||
/// An optional field specifying a variant of the CPU, for example `v7` to specify ARMv7 when architecture is `arm`.
|
||||
public var variant: String?
|
||||
|
||||
/// The operation system of the image (eg. `linux`).
|
||||
private let _rawOS: String
|
||||
/// The CPU architecture (eg. `arm64`).
|
||||
private let _rawArch: String
|
||||
|
||||
public init(arch: String, os: String, osVersion: String? = nil, osFeatures: [String]? = nil, variant: String? = nil) {
|
||||
self._rawArch = arch
|
||||
self._rawOS = os
|
||||
self.osVersion = osVersion
|
||||
self.osFeatures = osFeatures
|
||||
self.variant = variant
|
||||
}
|
||||
|
||||
/// Initializes a new platform from a string.
|
||||
/// - Parameters:
|
||||
/// - platform: A `string` value representing the platform.
|
||||
/// ```swift
|
||||
/// // Create a new `ImagePlatform` from string.
|
||||
/// let platform = try Platform(from: "linux/amd64")
|
||||
/// ```
|
||||
/// ## Throws ##
|
||||
/// - Throws: `Error.missingOS` if input is empty
|
||||
/// - Throws: `Error.invalidOS` if os is not `linux`
|
||||
/// - Throws: `Error.missingArch` if only one `/` is present
|
||||
/// - Throws: `Error.invalidArch` if an unrecognized architecture is provided
|
||||
/// - Throws: `Error.invalidVariant` if a variant is provided, and it does not apply to the specified architecture
|
||||
public init(from platform: String) throws {
|
||||
let items = platform.split(separator: "/", maxSplits: 1)
|
||||
guard let osValue = items.first else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing OS in \(platform)")
|
||||
}
|
||||
switch osValue {
|
||||
case "linux", "windows", "darwin":
|
||||
_rawOS = osValue.description
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "unknown OS in \(osValue)")
|
||||
}
|
||||
guard items.count > 1 else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
|
||||
}
|
||||
|
||||
guard let archItems = items.last?.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
|
||||
}
|
||||
|
||||
guard let archName = archItems.first else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing architecture in \(platform)")
|
||||
}
|
||||
|
||||
switch archName {
|
||||
case "arm", "armhf", "armel":
|
||||
_rawArch = "arm"
|
||||
variant = "v7"
|
||||
case "aarch64", "arm64":
|
||||
variant = "v8"
|
||||
_rawArch = "arm64"
|
||||
case "x86_64", "x86-64", "amd64":
|
||||
_rawArch = "amd64"
|
||||
default:
|
||||
_rawArch = archName.description
|
||||
}
|
||||
|
||||
if archItems.count == 2 {
|
||||
guard let archVariant = archItems.last else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing variant in \(platform)")
|
||||
}
|
||||
|
||||
switch archName {
|
||||
case "arm":
|
||||
switch archVariant {
|
||||
case "v5", "v6", "v7", "v8":
|
||||
variant = archVariant.description
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
case "armhf":
|
||||
switch archVariant {
|
||||
case "v7":
|
||||
variant = "v7"
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
case "armel":
|
||||
switch archVariant {
|
||||
case "v6":
|
||||
variant = "v6"
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
case "aarch64", "arm64":
|
||||
switch archVariant {
|
||||
case "v8", "8":
|
||||
variant = "v8"
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
case "x86_64", "x86-64", "amd64":
|
||||
switch archVariant {
|
||||
case "v1":
|
||||
variant = nil
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
case "i386", "386", "ppc64le", "riscv64":
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
default:
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid variant \(archVariant)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Platform: Hashable {
|
||||
/// `~=` compares two platforms to check if **lhs** platform images are compatible with **rhs** platform
|
||||
/// This operator can be used to check if an image of **lhs** platform can run on **rhs**:
|
||||
/// - `true`: when **rhs**=`arm/v8`, **lhs** is any of `arm/v8`, `arm/v7`, `arm/v6` and `arm/v5`
|
||||
/// - `true`: when **rhs**=`arm/v7`, **lhs** is any of `arm/v7`, `arm/v6` and `arm/v5`
|
||||
/// - `true`: when **rhs**=`arm/v6`, **lhs** is any of `arm/v6` and `arm/v5`
|
||||
/// - `true`: when **rhs**=`amd64`, **lhs** is any of `amd64` and `386`
|
||||
/// - `true`: when **rhs**=**lhs**
|
||||
/// - `false`: otherwise
|
||||
/// - Parameters:
|
||||
/// - lhs: platform whose compatibility is being checked
|
||||
/// - rhs: platform against which compatibility is being checked
|
||||
/// - Returns: `true | false`
|
||||
public static func ~= (lhs: Platform, rhs: Platform) -> Bool {
|
||||
if lhs.os == rhs.os {
|
||||
if lhs._rawArch == rhs._rawArch {
|
||||
switch rhs._rawArch {
|
||||
case "arm":
|
||||
guard let lVariant = lhs.variant else {
|
||||
return lhs == rhs
|
||||
}
|
||||
guard let rVariant = rhs.variant else {
|
||||
return lhs == rhs
|
||||
}
|
||||
switch rVariant {
|
||||
case "v8":
|
||||
switch lVariant {
|
||||
case "v5", "v6", "v7", "v8":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case "v7":
|
||||
switch lVariant {
|
||||
case "v5", "v6", "v7":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
case "v6":
|
||||
switch lVariant {
|
||||
case "v5", "v6":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return lhs == rhs
|
||||
}
|
||||
default:
|
||||
return lhs == rhs
|
||||
}
|
||||
}
|
||||
if lhs._rawArch == "386" && rhs._rawArch == "amd64" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// `==` compares if **lhs** and **rhs** are the exact same platforms.
|
||||
public static func == (lhs: Platform, rhs: Platform) -> Bool {
|
||||
// NOTE:
|
||||
// If the platform struct was created by setting the fields directly and not using (from: String)
|
||||
// then, there is a possibility that for arm64 architecture, the variant may be set to nil
|
||||
// In that case, the variant should be assumed to v8
|
||||
if lhs.architecture == "arm64" && rhs.architecture == "arm64" {
|
||||
// The following checks effectively verify
|
||||
// that one operand has nil value and other has "v8"
|
||||
if lhs.variant == nil || rhs.variant == nil {
|
||||
if lhs.variant == "v8" || rhs.variant == "v8" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let osEqual = lhs.os == rhs.os
|
||||
let archEqual = lhs.architecture == rhs.architecture
|
||||
let variantEqual = lhs.variant == rhs.variant
|
||||
|
||||
return osEqual && archEqual && variantEqual
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Swift.Hasher) {
|
||||
hasher.combine(os)
|
||||
hasher.combine(architecture)
|
||||
// arm64 with no variant is equivalent to arm64/v8 per the == implementation
|
||||
if architecture == "arm64" {
|
||||
hasher.combine(variant ?? "v8")
|
||||
} else {
|
||||
hasher.combine(variant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Platform: Codable {
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case os = "os"
|
||||
case architecture = "architecture"
|
||||
case variant = "variant"
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(os, forKey: .os)
|
||||
try container.encode(architecture, forKey: .architecture)
|
||||
try container.encodeIfPresent(variant, forKey: .variant)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let architecture = try container.decodeIfPresent(String.self, forKey: .architecture)
|
||||
guard let architecture else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing architecture")
|
||||
}
|
||||
let os = try container.decodeIfPresent(String.self, forKey: .os)
|
||||
guard let os else {
|
||||
throw ContainerizationError(.invalidArgument, message: "missing OS")
|
||||
}
|
||||
let variant = try container.decodeIfPresent(String.self, forKey: .variant)
|
||||
self.init(arch: architecture, os: os, variant: variant)
|
||||
}
|
||||
}
|
||||
|
||||
public func createPlatformMatcher(for platform: Platform?) -> @Sendable (Platform) -> Bool {
|
||||
if let platform {
|
||||
return { other in
|
||||
platform == other
|
||||
}
|
||||
}
|
||||
return { _ in
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
public func filterPlatforms(matcher: (Platform) -> Bool, _ descriptors: [Descriptor]) throws -> [Descriptor] {
|
||||
var outDescriptors: [Descriptor] = []
|
||||
for desc in descriptors {
|
||||
guard let p = desc.platform else {
|
||||
// pass along descriptor if the platform is not defined
|
||||
outDescriptors.append(desc)
|
||||
continue
|
||||
}
|
||||
if matcher(p) {
|
||||
outDescriptors.append(desc)
|
||||
}
|
||||
}
|
||||
return outDescriptors
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 ContainerizationError
|
||||
import Foundation
|
||||
|
||||
// nameTotalLengthMax matches the OCI distribution spec which allows up to 255 bytes for the
|
||||
// repository name component (domain + "/" + path).
|
||||
private let nameTotalLengthMax = 255
|
||||
// referenceTotalLengthMax is the upper bound for the full reference string: max name (255) +
|
||||
// separator (1) + max tag length (128) = 384.
|
||||
private let tagLengthMax = 128
|
||||
private let referenceTotalLengthMax = nameTotalLengthMax + 1 + tagLengthMax
|
||||
private let legacyDockerRegistryHost = "docker.io"
|
||||
private let dockerRegistryHost = "registry-1.docker.io"
|
||||
private let defaultDockerRegistryRepo = "library"
|
||||
private let defaultTag = "latest"
|
||||
|
||||
/// A Reference is composed of the various parts of an OCI image reference.
|
||||
/// For example:
|
||||
/// let imageReference = "my-registry.com/repository/image:tag2"
|
||||
/// let reference = Reference.parse(imageReference)
|
||||
/// print(reference.domain!) // gives us "my-registry.com"
|
||||
/// print(reference.name) // gives us "my-registry.com/repository/image"
|
||||
/// print(reference.path) // gives us "repository/image"
|
||||
/// print(reference.tag!) // gives us "tag2"
|
||||
/// print(reference.digest) // gives us "nil"
|
||||
public class Reference: CustomStringConvertible {
|
||||
private var _domain: String?
|
||||
public var domain: String? {
|
||||
_domain
|
||||
}
|
||||
public var resolvedDomain: String? {
|
||||
if let d = _domain {
|
||||
return Self.resolveDomain(domain: d)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private var _path: String
|
||||
public var path: String {
|
||||
_path
|
||||
}
|
||||
|
||||
private var _tag: String?
|
||||
public var tag: String? {
|
||||
_tag
|
||||
}
|
||||
|
||||
private var _digest: String?
|
||||
public var digest: String? {
|
||||
_digest
|
||||
}
|
||||
|
||||
public var name: String {
|
||||
if let domain, !domain.isEmpty {
|
||||
return "\(domain)/\(path)"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
if let tag {
|
||||
return "\(name):\(tag)"
|
||||
}
|
||||
if let digest {
|
||||
return "\(name)@\(digest)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
static let identifierPattern = "([a-f0-9]{64})"
|
||||
|
||||
static let domainPattern = {
|
||||
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 domainAndPort = "\(host)\(optionalPort)"
|
||||
return domainAndPort
|
||||
}()
|
||||
|
||||
static let pathPattern = "(?<path>(?:[a-z0-9]+(?:[._]|__|-|/)?)*[a-z0-9]+)"
|
||||
static let tagPattern = "(?::(?<tag>[\\w][\\w.-]{0,127}))?(?:@(?<digest>sha256:[0-9a-fA-F]{64}))?"
|
||||
static let pathTagPattern = "\(pathPattern)\(tagPattern)"
|
||||
|
||||
public init(path: String, domain: String? = nil, tag: String? = nil, digest: String? = nil) throws {
|
||||
if let domain, !domain.isEmpty {
|
||||
self._domain = domain
|
||||
}
|
||||
|
||||
self._path = path
|
||||
self._tag = tag
|
||||
self._digest = digest
|
||||
}
|
||||
|
||||
public static func parse(_ s: String) throws -> Reference {
|
||||
if s.count > referenceTotalLengthMax {
|
||||
throw ContainerizationError(.invalidArgument, message: "reference length \(s.count) greater than \(referenceTotalLengthMax)")
|
||||
}
|
||||
|
||||
let identifierRegex = try Regex(Self.identifierPattern)
|
||||
guard try identifierRegex.wholeMatch(in: s) == nil else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot specify 64 byte hex string as reference")
|
||||
}
|
||||
|
||||
let (domain, remainder) = try Self.parseDomain(from: s)
|
||||
let constructedRawReference: String = remainder
|
||||
if let domain {
|
||||
let domainRegex = try Regex(domainPattern)
|
||||
guard try domainRegex.wholeMatch(in: domain) != nil else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid domain \(domain) for reference \(s)")
|
||||
}
|
||||
}
|
||||
let fields = try constructedRawReference.matches(regex: pathTagPattern)
|
||||
guard let path = fields["path"] else {
|
||||
throw ContainerizationError(.invalidArgument, message: "cannot parse path for reference \(s)")
|
||||
}
|
||||
|
||||
let ref = try Reference(path: path, domain: domain)
|
||||
if ref.name.count > nameTotalLengthMax {
|
||||
throw ContainerizationError(.invalidArgument, message: "repo length \(ref.name.count) greater than \(nameTotalLengthMax)")
|
||||
}
|
||||
|
||||
// Extract tag and digest
|
||||
let tag = fields["tag"] ?? ""
|
||||
let digest = fields["digest"] ?? ""
|
||||
|
||||
if !digest.isEmpty {
|
||||
return try ref.withDigest(digest)
|
||||
} else if !tag.isEmpty {
|
||||
return try ref.withTag(tag)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
private static func parseDomain(from s: String) throws -> (domain: String?, remainder: String) {
|
||||
var domain: String? = nil
|
||||
var path: String = s
|
||||
let charset = CharacterSet(charactersIn: ".:")
|
||||
let splits = s.split(separator: "/", maxSplits: 1)
|
||||
guard splits.count == 2 else {
|
||||
if s.starts(with: "localhost") {
|
||||
return (s, "")
|
||||
}
|
||||
return (nil, s)
|
||||
}
|
||||
let _domain = String(splits[0])
|
||||
let _path = String(splits[1])
|
||||
if _domain.starts(with: "localhost") || _domain.rangeOfCharacter(from: charset) != nil {
|
||||
domain = _domain
|
||||
path = _path
|
||||
}
|
||||
return (domain, path)
|
||||
}
|
||||
|
||||
public static func withName(_ name: String) throws -> Reference {
|
||||
if name.count > nameTotalLengthMax {
|
||||
throw ContainerizationError(.invalidArgument, message: "name length \(name.count) greater than \(nameTotalLengthMax)")
|
||||
}
|
||||
let fields = try name.matches(regex: Self.domainPattern)
|
||||
// Extract domain and path
|
||||
let domain = fields["domain"] ?? ""
|
||||
let path = fields["path"] ?? ""
|
||||
|
||||
if domain.isEmpty || path.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "image reference domain or path is empty")
|
||||
}
|
||||
|
||||
return try Reference(path: path, domain: domain)
|
||||
}
|
||||
|
||||
public func withTag(_ tag: String) throws -> Reference {
|
||||
var tag = tag
|
||||
if !tag.starts(with: ":") {
|
||||
tag = ":" + tag
|
||||
}
|
||||
let fields = try tag.matches(regex: Self.tagPattern)
|
||||
tag = fields["tag"] ?? ""
|
||||
|
||||
if tag.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing tag")
|
||||
}
|
||||
return try Reference(path: self.path, domain: self.domain, tag: tag)
|
||||
}
|
||||
|
||||
public func withDigest(_ digest: String) throws -> Reference {
|
||||
var digest = digest
|
||||
if !digest.starts(with: "@") {
|
||||
digest = "@" + digest
|
||||
}
|
||||
let fields = try digest.matches(regex: Self.tagPattern)
|
||||
digest = fields["digest"] ?? ""
|
||||
|
||||
if digest.isEmpty {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference, missing digest")
|
||||
}
|
||||
return try Reference(path: self.path, domain: self.domain, digest: digest)
|
||||
}
|
||||
|
||||
private static func splitDomain(_ name: String) -> (domain: String, path: String) {
|
||||
let parts = name.split(separator: "/")
|
||||
guard parts.count == 2 else {
|
||||
return ("", name)
|
||||
}
|
||||
return (String(parts[0]), String(parts[1]))
|
||||
}
|
||||
|
||||
/// Normalize the reference object.
|
||||
/// Normalization is useful in cases where the reference object is to be used to
|
||||
/// fetch/push an image from/to a remote registry.
|
||||
/// It does the following:
|
||||
/// - Adds a default tag of "latest" if the reference had no tag/digest set.
|
||||
/// - If the domain is "registry-1.docker.io" or "docker.io" and the path has no repository set,
|
||||
/// it adds a default "library/" repository name.
|
||||
public func normalize() {
|
||||
if let domain = self.domain, domain == dockerRegistryHost || domain == legacyDockerRegistryHost {
|
||||
// Check if the image is being referenced by a named tag.
|
||||
// If it is, and a repository is not specified, prefix it with "library/".
|
||||
// This needs to be done only if we are using the Docker registry.
|
||||
if !self.path.contains("/") {
|
||||
self._path = "\(defaultDockerRegistryRepo)/\(self._path)"
|
||||
}
|
||||
}
|
||||
let identifier = self._tag ?? self._digest
|
||||
if identifier == nil {
|
||||
// If the user did not specify a tag or a digest for the reference, set the tag to "latest".
|
||||
self._tag = defaultTag
|
||||
}
|
||||
}
|
||||
|
||||
public static func resolveDomain(domain: String) -> String {
|
||||
if domain == legacyDockerRegistryHost {
|
||||
return dockerRegistryHost
|
||||
}
|
||||
return domain
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
func matches(regex: String) throws -> [String: String] {
|
||||
do {
|
||||
let regex = try NSRegularExpression(pattern: regex, options: [])
|
||||
let nsRange = NSRange(self.startIndex..<self.endIndex, in: self)
|
||||
guard let match = regex.firstMatch(in: self, options: [], range: nsRange), match.range == nsRange else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid format for image reference")
|
||||
}
|
||||
var results = [String: String]()
|
||||
for name in try regex.captureGroupNames() {
|
||||
if let range = Range(match.range(withName: name), in: self) {
|
||||
results[name] = String(self[range])
|
||||
}
|
||||
}
|
||||
return results
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NSRegularExpression {
|
||||
func captureGroupNames() throws -> [String] {
|
||||
let pattern = self.pattern
|
||||
let regex = try NSRegularExpression(pattern: "\\(\\?<(\\w+)>", options: [])
|
||||
let nsRange = NSRange(pattern.startIndex..<pattern.endIndex, in: pattern)
|
||||
let matches = regex.matches(in: pattern, options: [], range: nsRange)
|
||||
return matches.map {
|
||||
String(pattern[Range($0.range(at: 1), in: pattern)!])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,939 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
/// NOTE: This is not a complete recreation of the runtime spec. Other platforms outside of Linux
|
||||
/// have been left off, and some APIs for Linux aren't present. This was manually ported starting
|
||||
/// at the v1.2.0 release.
|
||||
|
||||
public struct Spec: Codable, Sendable {
|
||||
public var version: String
|
||||
public var hooks: Hooks?
|
||||
public var process: Process?
|
||||
public var hostname, domainname: String
|
||||
public var mounts: [Mount]
|
||||
public var annotations: [String: String]?
|
||||
public var root: Root?
|
||||
public var linux: Linux?
|
||||
|
||||
public init(
|
||||
version: String = "",
|
||||
hooks: Hooks? = nil,
|
||||
process: Process? = nil,
|
||||
hostname: String = "",
|
||||
domainname: String = "",
|
||||
mounts: [Mount] = [],
|
||||
annotations: [String: String]? = nil,
|
||||
root: Root? = nil,
|
||||
linux: Linux? = nil
|
||||
) {
|
||||
self.version = version
|
||||
self.hooks = hooks
|
||||
self.process = process
|
||||
self.hostname = hostname
|
||||
self.domainname = domainname
|
||||
self.mounts = mounts
|
||||
self.annotations = annotations
|
||||
self.root = root
|
||||
self.linux = linux
|
||||
}
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case version = "ociVersion"
|
||||
case hooks
|
||||
case process
|
||||
case hostname
|
||||
case domainname
|
||||
case mounts
|
||||
case annotations
|
||||
case root
|
||||
case linux
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
self.init()
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.version = try container.decode(String.self, forKey: .version)
|
||||
self.hooks = try container.decodeIfPresent(Hooks.self, forKey: .hooks)
|
||||
self.process = try container.decodeIfPresent(Process.self, forKey: .process)
|
||||
if let hostname = try container.decodeIfPresent(String.self, forKey: .hostname) {
|
||||
self.hostname = hostname
|
||||
}
|
||||
if let domainname = try container.decodeIfPresent(String.self, forKey: .domainname) {
|
||||
self.domainname = domainname
|
||||
}
|
||||
if let mounts = try container.decodeIfPresent([Mount].self, forKey: .mounts) {
|
||||
self.mounts = mounts
|
||||
}
|
||||
self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations)
|
||||
self.root = try container.decodeIfPresent(Root.self, forKey: .root)
|
||||
self.linux = try container.decodeIfPresent(Linux.self, forKey: .linux)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Process: Codable, Sendable {
|
||||
public var cwd: String
|
||||
public var env: [String]
|
||||
public var consoleSize: Box?
|
||||
public var selinuxLabel: String
|
||||
public var noNewPrivileges: Bool
|
||||
public var commandLine: String
|
||||
public var oomScoreAdj: Int?
|
||||
public var capabilities: LinuxCapabilities?
|
||||
public var apparmorProfile: String
|
||||
public var user: User
|
||||
public var rlimits: [POSIXRlimit]
|
||||
public var args: [String]
|
||||
public var terminal: Bool
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case cwd
|
||||
case env
|
||||
case consoleSize
|
||||
case selinuxLabel
|
||||
case noNewPrivileges
|
||||
case commandLine
|
||||
case oomScoreAdj
|
||||
case capabilities
|
||||
case apparmorProfile
|
||||
case user
|
||||
case rlimits
|
||||
case args
|
||||
case terminal
|
||||
}
|
||||
|
||||
public init(
|
||||
args: [String] = [],
|
||||
cwd: String = "/",
|
||||
env: [String] = [],
|
||||
consoleSize: Box? = nil,
|
||||
selinuxLabel: String = "",
|
||||
noNewPrivileges: Bool = false,
|
||||
commandLine: String = "",
|
||||
oomScoreAdj: Int? = nil,
|
||||
capabilities: LinuxCapabilities? = nil,
|
||||
apparmorProfile: String = "",
|
||||
user: User = .init(),
|
||||
rlimits: [POSIXRlimit] = [],
|
||||
terminal: Bool = false
|
||||
) {
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.consoleSize = consoleSize
|
||||
self.selinuxLabel = selinuxLabel
|
||||
self.noNewPrivileges = noNewPrivileges
|
||||
self.commandLine = commandLine
|
||||
self.oomScoreAdj = oomScoreAdj
|
||||
self.capabilities = capabilities
|
||||
self.apparmorProfile = apparmorProfile
|
||||
self.user = user
|
||||
self.rlimits = rlimits
|
||||
self.args = args
|
||||
self.terminal = terminal
|
||||
}
|
||||
|
||||
public init(from config: ImageConfig) {
|
||||
let cwd = config.workingDir ?? "/"
|
||||
let env = config.env ?? []
|
||||
let args = (config.entrypoint ?? []) + (config.cmd ?? [])
|
||||
let user: User = {
|
||||
if let rawString = config.user {
|
||||
return User(username: rawString)
|
||||
}
|
||||
return User()
|
||||
}()
|
||||
self.init(args: args, cwd: cwd, env: env, user: user)
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
self.init()
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.cwd = try container.decode(String.self, forKey: .cwd)
|
||||
if let env = try container.decodeIfPresent([String].self, forKey: .env) {
|
||||
self.env = env
|
||||
}
|
||||
self.consoleSize = try container.decodeIfPresent(Box.self, forKey: .consoleSize)
|
||||
if let selinuxLabel = try container.decodeIfPresent(String.self, forKey: .selinuxLabel) {
|
||||
self.selinuxLabel = selinuxLabel
|
||||
}
|
||||
if let noNewPrivileges = try container.decodeIfPresent(Bool.self, forKey: .noNewPrivileges) {
|
||||
self.noNewPrivileges = noNewPrivileges
|
||||
}
|
||||
if let commandLine = try container.decodeIfPresent(String.self, forKey: .commandLine) {
|
||||
self.commandLine = commandLine
|
||||
}
|
||||
self.oomScoreAdj = try container.decodeIfPresent(Int.self, forKey: .oomScoreAdj)
|
||||
self.capabilities = try container.decodeIfPresent(LinuxCapabilities.self, forKey: .capabilities)
|
||||
if let apparmorProfile = try container.decodeIfPresent(String.self, forKey: .apparmorProfile) {
|
||||
self.apparmorProfile = apparmorProfile
|
||||
}
|
||||
self.user = try container.decode(User.self, forKey: .user)
|
||||
if let rlimits = try container.decodeIfPresent([POSIXRlimit].self, forKey: .rlimits) {
|
||||
self.rlimits = rlimits
|
||||
}
|
||||
if let args = try container.decodeIfPresent([String].self, forKey: .args) {
|
||||
self.args = args
|
||||
}
|
||||
if let terminal = try container.decodeIfPresent(Bool.self, forKey: .terminal) {
|
||||
self.terminal = terminal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxCapabilities: Codable, Sendable {
|
||||
public var bounding: [String]?
|
||||
public var effective: [String]?
|
||||
public var inheritable: [String]?
|
||||
public var permitted: [String]?
|
||||
public var ambient: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case bounding
|
||||
case effective
|
||||
case inheritable
|
||||
case permitted
|
||||
case ambient
|
||||
}
|
||||
|
||||
public init(
|
||||
bounding: [String]? = nil,
|
||||
effective: [String]? = nil,
|
||||
inheritable: [String]? = nil,
|
||||
permitted: [String]? = nil,
|
||||
ambient: [String]? = nil
|
||||
) {
|
||||
self.bounding = bounding
|
||||
self.effective = effective
|
||||
self.inheritable = inheritable
|
||||
self.permitted = permitted
|
||||
self.ambient = ambient
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.bounding = try container.decodeIfPresent([String].self, forKey: .bounding)
|
||||
self.effective = try container.decodeIfPresent([String].self, forKey: .effective)
|
||||
self.inheritable = try container.decodeIfPresent([String].self, forKey: .inheritable)
|
||||
self.permitted = try container.decodeIfPresent([String].self, forKey: .permitted)
|
||||
self.ambient = try container.decodeIfPresent([String].self, forKey: .ambient)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(bounding, forKey: .bounding)
|
||||
try container.encodeIfPresent(effective, forKey: .effective)
|
||||
try container.encodeIfPresent(inheritable, forKey: .inheritable)
|
||||
try container.encodeIfPresent(permitted, forKey: .permitted)
|
||||
try container.encodeIfPresent(ambient, forKey: .ambient)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Box: Codable, Sendable {
|
||||
var height, width: UInt
|
||||
|
||||
public init(height: UInt, width: UInt) {
|
||||
self.height = height
|
||||
self.width = width
|
||||
}
|
||||
}
|
||||
|
||||
public struct User: Codable, Sendable {
|
||||
public var uid: UInt32
|
||||
public var gid: UInt32
|
||||
public var umask: UInt32?
|
||||
public var additionalGids: [UInt32]
|
||||
public var username: String
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case uid
|
||||
case gid
|
||||
case umask
|
||||
case additionalGids
|
||||
case username
|
||||
}
|
||||
|
||||
public init(
|
||||
uid: UInt32 = 0,
|
||||
gid: UInt32 = 0,
|
||||
umask: UInt32? = nil,
|
||||
additionalGids: [UInt32] = [],
|
||||
username: String = ""
|
||||
) {
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
self.umask = umask
|
||||
self.additionalGids = additionalGids
|
||||
self.username = username
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
self.init()
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.uid = try container.decode(UInt32.self, forKey: .uid)
|
||||
self.gid = try container.decode(UInt32.self, forKey: .gid)
|
||||
self.umask = try container.decodeIfPresent(UInt32.self, forKey: .umask)
|
||||
if let additionalGids = try container.decodeIfPresent([UInt32].self, forKey: .additionalGids) {
|
||||
self.additionalGids = additionalGids
|
||||
}
|
||||
if let username = try container.decodeIfPresent(String.self, forKey: .username) {
|
||||
self.username = username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Root: Codable, Sendable {
|
||||
public var path: String
|
||||
public var readonly: Bool
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case readonly
|
||||
}
|
||||
|
||||
public init(path: String, readonly: Bool) {
|
||||
self.path = path
|
||||
self.readonly = readonly
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.path = try container.decode(String.self, forKey: .path)
|
||||
self.readonly = try container.decodeIfPresent(Bool.self, forKey: .readonly) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
public struct Mount: Codable, Sendable {
|
||||
public var type: String
|
||||
public var source: String
|
||||
public var destination: String
|
||||
public var options: [String]
|
||||
|
||||
public var uidMappings: [LinuxIDMapping]?
|
||||
public var gidMappings: [LinuxIDMapping]?
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case type
|
||||
case source
|
||||
case destination
|
||||
case options
|
||||
case uidMappings
|
||||
case gidMappings
|
||||
}
|
||||
|
||||
public init(
|
||||
type: String = "",
|
||||
source: String = "",
|
||||
destination: String,
|
||||
options: [String] = [],
|
||||
uidMappings: [LinuxIDMapping]? = nil,
|
||||
gidMappings: [LinuxIDMapping]? = nil
|
||||
) {
|
||||
self.destination = destination
|
||||
self.type = type
|
||||
self.source = source
|
||||
self.options = options
|
||||
self.uidMappings = uidMappings
|
||||
self.gidMappings = gidMappings
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
|
||||
self.source = try container.decodeIfPresent(String.self, forKey: .source) ?? ""
|
||||
self.destination = try container.decode(String.self, forKey: .destination)
|
||||
self.options = try container.decodeIfPresent([String].self, forKey: .options) ?? []
|
||||
self.uidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .uidMappings)
|
||||
self.gidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .gidMappings)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(type, forKey: .type)
|
||||
try container.encode(source, forKey: .source)
|
||||
try container.encode(destination, forKey: .destination)
|
||||
try container.encode(options, forKey: .options)
|
||||
try container.encodeIfPresent(uidMappings, forKey: .uidMappings)
|
||||
try container.encodeIfPresent(gidMappings, forKey: .gidMappings)
|
||||
}
|
||||
}
|
||||
|
||||
public struct Hook: Codable, Sendable {
|
||||
public var path: String
|
||||
public var args: [String]
|
||||
public var env: [String]
|
||||
public var timeout: Int?
|
||||
|
||||
public init(path: String, args: [String], env: [String], timeout: Int?) {
|
||||
self.path = path
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
public struct Hooks: Codable, Sendable {
|
||||
public var prestart: [Hook]
|
||||
public var createRuntime: [Hook]
|
||||
public var createContainer: [Hook]
|
||||
public var startContainer: [Hook]
|
||||
public var poststart: [Hook]
|
||||
public var poststop: [Hook]
|
||||
|
||||
public init(
|
||||
prestart: [Hook],
|
||||
createRuntime: [Hook],
|
||||
createContainer: [Hook],
|
||||
startContainer: [Hook],
|
||||
poststart: [Hook],
|
||||
poststop: [Hook]
|
||||
) {
|
||||
self.prestart = prestart
|
||||
self.createRuntime = createRuntime
|
||||
self.createContainer = createContainer
|
||||
self.startContainer = startContainer
|
||||
self.poststart = poststart
|
||||
self.poststop = poststop
|
||||
}
|
||||
}
|
||||
|
||||
public struct Linux: Codable, Sendable {
|
||||
public var uidMappings: [LinuxIDMapping]
|
||||
public var gidMappings: [LinuxIDMapping]
|
||||
public var sysctl: [String: String]?
|
||||
public var resources: LinuxResources?
|
||||
public var cgroupsPath: String
|
||||
public var namespaces: [LinuxNamespace]
|
||||
public var devices: [LinuxDevice]
|
||||
public var seccomp: LinuxSeccomp?
|
||||
public var rootfsPropagation: String
|
||||
public var maskedPaths: [String]
|
||||
public var readonlyPaths: [String]
|
||||
public var mountLabel: String
|
||||
public var personality: LinuxPersonality?
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case uidMappings
|
||||
case gidMappings
|
||||
case sysctl
|
||||
case resources
|
||||
case cgroupsPath
|
||||
case namespaces
|
||||
case devices
|
||||
case seccomp
|
||||
case rootfsPropagation
|
||||
case maskedPaths
|
||||
case readonlyPaths
|
||||
case mountLabel
|
||||
case personality
|
||||
}
|
||||
|
||||
public init(
|
||||
uidMappings: [LinuxIDMapping] = [],
|
||||
gidMappings: [LinuxIDMapping] = [],
|
||||
sysctl: [String: String]? = nil,
|
||||
resources: LinuxResources? = nil,
|
||||
cgroupsPath: String = "",
|
||||
namespaces: [LinuxNamespace] = [],
|
||||
devices: [LinuxDevice] = [],
|
||||
seccomp: LinuxSeccomp? = nil,
|
||||
rootfsPropagation: String = "",
|
||||
maskedPaths: [String] = [],
|
||||
readonlyPaths: [String] = [],
|
||||
mountLabel: String = "",
|
||||
personality: LinuxPersonality? = nil
|
||||
) {
|
||||
self.uidMappings = uidMappings
|
||||
self.gidMappings = gidMappings
|
||||
self.sysctl = sysctl
|
||||
self.resources = resources
|
||||
self.cgroupsPath = cgroupsPath
|
||||
self.namespaces = namespaces
|
||||
self.devices = devices
|
||||
self.seccomp = seccomp
|
||||
self.rootfsPropagation = rootfsPropagation
|
||||
self.maskedPaths = maskedPaths
|
||||
self.readonlyPaths = readonlyPaths
|
||||
self.mountLabel = mountLabel
|
||||
self.personality = personality
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
self.init()
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let uidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .uidMappings) {
|
||||
self.uidMappings = uidMappings
|
||||
}
|
||||
if let gidMappings = try container.decodeIfPresent([LinuxIDMapping].self, forKey: .gidMappings) {
|
||||
self.gidMappings = gidMappings
|
||||
}
|
||||
self.sysctl = try container.decodeIfPresent([String: String].self, forKey: .sysctl)
|
||||
self.resources = try container.decodeIfPresent(LinuxResources.self, forKey: .resources)
|
||||
if let cgroupsPath = try container.decodeIfPresent(String.self, forKey: .cgroupsPath) {
|
||||
self.cgroupsPath = cgroupsPath
|
||||
}
|
||||
if let namespaces = try container.decodeIfPresent([LinuxNamespace].self, forKey: .namespaces) {
|
||||
self.namespaces = namespaces
|
||||
}
|
||||
if let devices = try container.decodeIfPresent([LinuxDevice].self, forKey: .devices) {
|
||||
self.devices = devices
|
||||
}
|
||||
self.seccomp = try container.decodeIfPresent(LinuxSeccomp.self, forKey: .seccomp)
|
||||
if let rootfsPropagation = try container.decodeIfPresent(String.self, forKey: .rootfsPropagation) {
|
||||
self.rootfsPropagation = rootfsPropagation
|
||||
}
|
||||
if let maskedPaths = try container.decodeIfPresent([String].self, forKey: .maskedPaths) {
|
||||
self.maskedPaths = maskedPaths
|
||||
}
|
||||
if let readonlyPaths = try container.decodeIfPresent([String].self, forKey: .readonlyPaths) {
|
||||
self.readonlyPaths = readonlyPaths
|
||||
}
|
||||
if let mountLabel = try container.decodeIfPresent(String.self, forKey: .mountLabel) {
|
||||
self.mountLabel = mountLabel
|
||||
}
|
||||
self.personality = try container.decodeIfPresent(LinuxPersonality.self, forKey: .personality)
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxNamespace: Codable, Sendable {
|
||||
public var type: LinuxNamespaceType
|
||||
public var path: String
|
||||
|
||||
public init(type: LinuxNamespaceType, path: String = "") {
|
||||
self.type = type
|
||||
self.path = path
|
||||
}
|
||||
}
|
||||
|
||||
public enum LinuxNamespaceType: String, Codable, Sendable {
|
||||
case pid
|
||||
case network
|
||||
case uts
|
||||
case mount
|
||||
case ipc
|
||||
case user
|
||||
case cgroup
|
||||
}
|
||||
|
||||
public struct LinuxIDMapping: Codable, Sendable {
|
||||
public var containerID: UInt32
|
||||
public var hostID: UInt32
|
||||
public var size: UInt32
|
||||
|
||||
public init(containerID: UInt32, hostID: UInt32, size: UInt32) {
|
||||
self.containerID = containerID
|
||||
self.hostID = hostID
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
public struct POSIXRlimit: Codable, Sendable {
|
||||
public var type: String
|
||||
public var hard: UInt64
|
||||
public var soft: UInt64
|
||||
|
||||
public init(type: String, hard: UInt64, soft: UInt64) {
|
||||
self.type = type
|
||||
self.hard = hard
|
||||
self.soft = soft
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxHugepageLimit: Codable, Sendable {
|
||||
public var pagesize: String
|
||||
public var limit: UInt64
|
||||
|
||||
public init(pagesize: String, limit: UInt64) {
|
||||
self.pagesize = pagesize
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxInterfacePriority: Codable, Sendable {
|
||||
public var name: String
|
||||
public var priority: UInt32
|
||||
|
||||
public init(name: String, priority: UInt32) {
|
||||
self.name = name
|
||||
self.priority = priority
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxBlockIODevice: Codable, Sendable {
|
||||
public var major: Int64
|
||||
public var minor: Int64
|
||||
|
||||
public init(major: Int64, minor: Int64) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxWeightDevice: Codable, Sendable {
|
||||
public var major: Int64
|
||||
public var minor: Int64
|
||||
public var weight: UInt16?
|
||||
public var leafWeight: UInt16?
|
||||
|
||||
public init(major: Int64, minor: Int64, weight: UInt16?, leafWeight: UInt16?) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.weight = weight
|
||||
self.leafWeight = leafWeight
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxThrottleDevice: Codable, Sendable {
|
||||
public var major: Int64
|
||||
public var minor: Int64
|
||||
public var rate: UInt64
|
||||
|
||||
public init(major: Int64, minor: Int64, rate: UInt64) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.rate = rate
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxBlockIO: Codable, Sendable {
|
||||
public var weight: UInt16?
|
||||
public var leafWeight: UInt16?
|
||||
public var weightDevice: [LinuxWeightDevice]
|
||||
public var throttleReadBpsDevice: [LinuxThrottleDevice]
|
||||
public var throttleWriteBpsDevice: [LinuxThrottleDevice]
|
||||
public var throttleReadIOPSDevice: [LinuxThrottleDevice]
|
||||
public var throttleWriteIOPSDevice: [LinuxThrottleDevice]
|
||||
|
||||
public init(
|
||||
weight: UInt16?,
|
||||
leafWeight: UInt16?,
|
||||
weightDevice: [LinuxWeightDevice],
|
||||
throttleReadBpsDevice: [LinuxThrottleDevice],
|
||||
throttleWriteBpsDevice: [LinuxThrottleDevice],
|
||||
throttleReadIOPSDevice: [LinuxThrottleDevice],
|
||||
throttleWriteIOPSDevice: [LinuxThrottleDevice]
|
||||
) {
|
||||
self.weight = weight
|
||||
self.leafWeight = leafWeight
|
||||
self.weightDevice = weightDevice
|
||||
self.throttleReadBpsDevice = throttleReadBpsDevice
|
||||
self.throttleWriteBpsDevice = throttleWriteBpsDevice
|
||||
self.throttleReadIOPSDevice = throttleReadIOPSDevice
|
||||
self.throttleWriteIOPSDevice = throttleWriteIOPSDevice
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxMemory: Codable, Sendable {
|
||||
public var limit: Int64?
|
||||
public var reservation: Int64?
|
||||
public var swap: Int64?
|
||||
public var kernel: Int64?
|
||||
public var kernelTCP: Int64?
|
||||
public var swappiness: UInt64?
|
||||
public var disableOOMKiller: Bool?
|
||||
public var useHierarchy: Bool?
|
||||
public var checkBeforeUpdate: Bool?
|
||||
|
||||
public init(
|
||||
limit: Int64? = nil,
|
||||
reservation: Int64? = nil,
|
||||
swap: Int64? = nil,
|
||||
kernel: Int64? = nil,
|
||||
kernelTCP: Int64? = nil,
|
||||
swappiness: UInt64? = nil,
|
||||
disableOOMKiller: Bool? = nil,
|
||||
useHierarchy: Bool? = nil,
|
||||
checkBeforeUpdate: Bool? = nil
|
||||
) {
|
||||
self.limit = limit
|
||||
self.reservation = reservation
|
||||
self.swap = swap
|
||||
self.kernel = kernel
|
||||
self.kernelTCP = kernelTCP
|
||||
self.swappiness = swappiness
|
||||
self.disableOOMKiller = disableOOMKiller
|
||||
self.useHierarchy = useHierarchy
|
||||
self.checkBeforeUpdate = checkBeforeUpdate
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxCPU: Codable, Sendable {
|
||||
public var shares: UInt64?
|
||||
public var quota: Int64?
|
||||
public var burst: UInt64?
|
||||
public var period: UInt64?
|
||||
public var realtimeRuntime: Int64?
|
||||
public var realtimePeriod: Int64?
|
||||
public var cpus: String
|
||||
public var mems: String
|
||||
public var idle: Int64?
|
||||
|
||||
public init(
|
||||
shares: UInt64? = nil,
|
||||
quota: Int64? = nil,
|
||||
burst: UInt64? = nil,
|
||||
period: UInt64? = nil,
|
||||
realtimeRuntime: Int64? = nil,
|
||||
realtimePeriod: Int64? = nil,
|
||||
cpus: String = "",
|
||||
mems: String = "",
|
||||
idle: Int64? = nil
|
||||
) {
|
||||
self.shares = shares
|
||||
self.quota = quota
|
||||
self.burst = burst
|
||||
self.period = period
|
||||
self.realtimeRuntime = realtimeRuntime
|
||||
self.realtimePeriod = realtimePeriod
|
||||
self.cpus = cpus
|
||||
self.mems = mems
|
||||
self.idle = idle
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxPids: Codable, Sendable {
|
||||
public var limit: Int64
|
||||
|
||||
public init(limit: Int64) {
|
||||
self.limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxNetwork: Codable, Sendable {
|
||||
public var classID: UInt32?
|
||||
public var priorities: [LinuxInterfacePriority]
|
||||
|
||||
public init(classID: UInt32?, priorities: [LinuxInterfacePriority]) {
|
||||
self.classID = classID
|
||||
self.priorities = priorities
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxRdma: Codable, Sendable {
|
||||
public var hcsHandles: UInt32?
|
||||
public var hcaObjects: UInt32?
|
||||
|
||||
public init(hcsHandles: UInt32?, hcaObjects: UInt32?) {
|
||||
self.hcsHandles = hcsHandles
|
||||
self.hcaObjects = hcaObjects
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxResources: Codable, Sendable {
|
||||
public var devices: [LinuxDeviceCgroup]
|
||||
public var memory: LinuxMemory?
|
||||
public var cpu: LinuxCPU?
|
||||
public var pids: LinuxPids?
|
||||
public var blockIO: LinuxBlockIO?
|
||||
public var hugepageLimits: [LinuxHugepageLimit]
|
||||
public var network: LinuxNetwork?
|
||||
public var rdma: [String: LinuxRdma]?
|
||||
public var unified: [String: String]?
|
||||
|
||||
public init(
|
||||
devices: [LinuxDeviceCgroup] = [],
|
||||
memory: LinuxMemory? = nil,
|
||||
cpu: LinuxCPU? = nil,
|
||||
pids: LinuxPids? = nil,
|
||||
blockIO: LinuxBlockIO? = nil,
|
||||
hugepageLimits: [LinuxHugepageLimit] = [],
|
||||
network: LinuxNetwork? = nil,
|
||||
rdma: [String: LinuxRdma]? = nil,
|
||||
unified: [String: String] = [:]
|
||||
) {
|
||||
self.devices = devices
|
||||
self.memory = memory
|
||||
self.cpu = cpu
|
||||
self.pids = pids
|
||||
self.blockIO = blockIO
|
||||
self.hugepageLimits = hugepageLimits
|
||||
self.network = network
|
||||
self.rdma = rdma
|
||||
self.unified = unified
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxDevice: Codable, Sendable {
|
||||
public var path: String
|
||||
public var type: String
|
||||
public var major: Int64
|
||||
public var minor: Int64
|
||||
public var fileMode: UInt32?
|
||||
public var uid: UInt32?
|
||||
public var gid: UInt32?
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
type: String,
|
||||
major: Int64,
|
||||
minor: Int64,
|
||||
fileMode: UInt32?,
|
||||
uid: UInt32?,
|
||||
gid: UInt32?
|
||||
) {
|
||||
self.path = path
|
||||
self.type = type
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.fileMode = fileMode
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxDeviceCgroup: Codable, Sendable {
|
||||
public var allow: Bool
|
||||
public var type: String
|
||||
public var major: Int64?
|
||||
public var minor: Int64?
|
||||
public var access: String?
|
||||
|
||||
public init(allow: Bool, type: String, major: Int64?, minor: Int64?, access: String?) {
|
||||
self.allow = allow
|
||||
self.type = type
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.access = access
|
||||
}
|
||||
}
|
||||
|
||||
public enum LinuxPersonalityDomain: String, Codable, Sendable {
|
||||
case perLinux = "LINUX"
|
||||
case perLinux32 = "LINUX32"
|
||||
}
|
||||
|
||||
public struct LinuxPersonality: Codable, Sendable {
|
||||
public var domain: LinuxPersonalityDomain
|
||||
public var flags: [String]
|
||||
|
||||
public init(domain: LinuxPersonalityDomain, flags: [String]) {
|
||||
self.domain = domain
|
||||
self.flags = flags
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxSeccomp: Codable, Sendable {
|
||||
public var defaultAction: LinuxSeccompAction
|
||||
public var defaultErrnoRet: UInt?
|
||||
public var architectures: [Arch]
|
||||
public var flags: [LinuxSeccompFlag]
|
||||
public var listenerPath: String
|
||||
public var listenerMetadata: String
|
||||
public var syscalls: [LinuxSyscall]
|
||||
|
||||
public init(
|
||||
defaultAction: LinuxSeccompAction,
|
||||
defaultErrnoRet: UInt?,
|
||||
architectures: [Arch],
|
||||
flags: [LinuxSeccompFlag],
|
||||
listenerPath: String,
|
||||
listenerMetadata: String,
|
||||
syscalls: [LinuxSyscall]
|
||||
) {
|
||||
self.defaultAction = defaultAction
|
||||
self.defaultErrnoRet = defaultErrnoRet
|
||||
self.architectures = architectures
|
||||
self.flags = flags
|
||||
self.listenerPath = listenerPath
|
||||
self.listenerMetadata = listenerMetadata
|
||||
self.syscalls = syscalls
|
||||
}
|
||||
}
|
||||
|
||||
public enum LinuxSeccompFlag: String, Codable, Sendable {
|
||||
case flagLog = "SECCOMP_FILTER_FLAG_LOG"
|
||||
case flagSpecAllow = "SECCOMP_FILTER_FLAG_SPEC_ALLOW"
|
||||
case flagWaitKillableRecv = "SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV"
|
||||
}
|
||||
|
||||
public enum Arch: String, Codable, Sendable {
|
||||
case archX86 = "SCMP_ARCH_X86"
|
||||
case archX86_64 = "SCMP_ARCH_X86_64"
|
||||
case archX32 = "SCMP_ARCH_X32"
|
||||
case archARM = "SCMP_ARCH_ARM"
|
||||
case archAARCH64 = "SCMP_ARCH_AARCH64"
|
||||
case archMIPS = "SCMP_ARCH_MIPS"
|
||||
case archMIPS64 = "SCMP_ARCH_MIPS64"
|
||||
case archMIPS64N32 = "SCMP_ARCH_MIPS64N32"
|
||||
case archMIPSEL = "SCMP_ARCH_MIPSEL"
|
||||
case archMIPSEL64 = "SCMP_ARCH_MIPSEL64"
|
||||
case archMIPSEL64N32 = "SCMP_ARCH_MIPSEL64N32"
|
||||
case archPPC = "SCMP_ARCH_PPC"
|
||||
case archPPC64 = "SCMP_ARCH_PPC64"
|
||||
case archPPC64LE = "SCMP_ARCH_PPC64LE"
|
||||
case archS390 = "SCMP_ARCH_S390"
|
||||
case archS390X = "SCMP_ARCH_S390X"
|
||||
case archPARISC = "SCMP_ARCH_PARISC"
|
||||
case archPARISC64 = "SCMP_ARCH_PARISC64"
|
||||
case archRISCV64 = "SCMP_ARCH_RISCV64"
|
||||
}
|
||||
|
||||
public enum LinuxSeccompAction: String, Codable, Sendable {
|
||||
case actKill = "SCMP_ACT_KILL"
|
||||
case actKillProcess = "SCMP_ACT_KILL_PROCESS"
|
||||
case actKillThread = "SCMP_ACT_KILL_THREAD"
|
||||
case actTrap = "SCMP_ACT_TRAP"
|
||||
case actErrno = "SCMP_ACT_ERRNO"
|
||||
case actTrace = "SCMP_ACT_TRACE"
|
||||
case actAllow = "SCMP_ACT_ALLOW"
|
||||
case actLog = "SCMP_ACT_LOG"
|
||||
case actNotify = "SCMP_ACT_NOTIFY"
|
||||
}
|
||||
|
||||
public enum LinuxSeccompOperator: String, Codable, Sendable {
|
||||
case opNotEqual = "SCMP_CMP_NE"
|
||||
case opLessThan = "SCMP_CMP_LT"
|
||||
case opLessEqual = "SCMP_CMP_LE"
|
||||
case opEqualTo = "SCMP_CMP_EQ"
|
||||
case opGreaterEqual = "SCMP_CMP_GE"
|
||||
case opGreaterThan = "SCMP_CMP_GT"
|
||||
case opMaskedEqual = "SCMP_CMP_MASKED_EQ"
|
||||
}
|
||||
|
||||
public struct LinuxSeccompArg: Codable, Sendable {
|
||||
public var index: UInt
|
||||
public var value: UInt64
|
||||
public var valueTwo: UInt64
|
||||
public var op: LinuxSeccompOperator
|
||||
|
||||
public init(index: UInt, value: UInt64, valueTwo: UInt64, op: LinuxSeccompOperator) {
|
||||
self.index = index
|
||||
self.value = value
|
||||
self.valueTwo = valueTwo
|
||||
self.op = op
|
||||
}
|
||||
}
|
||||
|
||||
public struct LinuxSyscall: Codable, Sendable {
|
||||
public var names: [String]
|
||||
public var action: LinuxSeccompAction
|
||||
public var errnoRet: UInt?
|
||||
public var args: [LinuxSeccompArg]
|
||||
|
||||
public init(
|
||||
names: [String],
|
||||
action: LinuxSeccompAction,
|
||||
errnoRet: UInt?,
|
||||
args: [LinuxSeccompArg]
|
||||
) {
|
||||
self.names = names
|
||||
self.action = action
|
||||
self.errnoRet = errnoRet
|
||||
self.args = args
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
public enum ContainerState: String, Codable, Sendable {
|
||||
case creating
|
||||
case created
|
||||
case running
|
||||
case stopped
|
||||
}
|
||||
|
||||
public struct State: Codable, Sendable {
|
||||
public init(
|
||||
version: String,
|
||||
id: String,
|
||||
status: ContainerState,
|
||||
pid: Int,
|
||||
bundle: String,
|
||||
annotations: [String: String]?
|
||||
) {
|
||||
self.ociVersion = version
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.pid = pid
|
||||
self.bundle = bundle
|
||||
self.annotations = annotations
|
||||
}
|
||||
|
||||
public init(instance: State) {
|
||||
self.ociVersion = instance.ociVersion
|
||||
self.id = instance.id
|
||||
self.status = instance.status
|
||||
self.pid = instance.pid
|
||||
self.bundle = instance.bundle
|
||||
self.annotations = instance.annotations
|
||||
}
|
||||
|
||||
public let ociVersion: String
|
||||
public let id: String
|
||||
public let status: ContainerState
|
||||
public let pid: Int
|
||||
public let bundle: String
|
||||
public var annotations: [String: String]?
|
||||
}
|
||||
|
||||
public let seccompFdName: String = "seccompFd"
|
||||
|
||||
public struct ContainerProcessState: Codable, Sendable {
|
||||
public init(version: String, fds: [String], pid: Int, metadata: String, state: State) {
|
||||
self.ociVersion = version
|
||||
self.fds = fds
|
||||
self.pid = pid
|
||||
self.metadata = metadata
|
||||
self.state = state
|
||||
}
|
||||
|
||||
public init(instance: ContainerProcessState) {
|
||||
self.ociVersion = instance.ociVersion
|
||||
self.fds = instance.fds
|
||||
self.pid = instance.pid
|
||||
self.metadata = instance.metadata
|
||||
self.state = instance.state
|
||||
}
|
||||
|
||||
public let ociVersion: String
|
||||
public var fds: [String]
|
||||
public let pid: Int
|
||||
public let metadata: String
|
||||
public let state: State
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
public struct RuntimeSpecVersion: Sendable {
|
||||
public let major, minor, patch: Int
|
||||
public let dev: String
|
||||
|
||||
public static let current = RuntimeSpecVersion(
|
||||
major: 1,
|
||||
minor: 0,
|
||||
patch: 2,
|
||||
dev: "-dev"
|
||||
)
|
||||
|
||||
public init(major: Int, minor: Int, patch: Int, dev: String) {
|
||||
self.major = major
|
||||
self.minor = minor
|
||||
self.patch = patch
|
||||
self.dev = dev
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user