diff --git a/Sources/ContainerRegistry/ImageReference.swift b/Sources/ContainerRegistry/ImageReference.swift index eec1d61..63e02d9 100644 --- a/Sources/ContainerRegistry/ImageReference.swift +++ b/Sources/ContainerRegistry/ImageReference.swift @@ -78,9 +78,18 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust public init(fromString reference: String, defaultRegistry: String = "localhost:5000") throws { let (registry, remainder) = try splitReference(reference) let (repository, reference) = try parseName(remainder) + + // As a special case, do not expand the reference for the unqualified 'scratch' image as it is handled locally. + if registry == nil && repository.value == "scratch" { + self.registry = "" + self.repository = repository + self.reference = reference + return + } + self.registry = registry ?? defaultRegistry if self.registry == "docker.io" { - self.registry = "index.docker.io" // Special case for docker client, there is no network-level redirect + self.registry = "index.docker.io" // Special case for Docker Hub, there is no network-level redirect } // As a special case, official images can be referred to by a single name, such as `swift` or `swift:slim`. // moby/moby assumes that these names refer to images in `library`: `library/swift` or `library/swift:slim`. @@ -111,7 +120,11 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust /// Printable description of an ImageReference in a form which can be understood by a runtime public var description: String { - "\(registry)/\(repository)\(reference.separator)\(reference)" + if registry == "" { + "\(repository)\(reference.separator)\(reference)" + } else { + "\(registry)/\(repository)\(reference.separator)\(reference)" + } } /// Printable description of an ImageReference in a form suitable for debugging. diff --git a/Sources/ContainerRegistry/ImageSource.swift b/Sources/ContainerRegistry/ImageSource.swift index 44d9762..ed9d234 100644 --- a/Sources/ContainerRegistry/ImageSource.swift +++ b/Sources/ContainerRegistry/ImageSource.swift @@ -13,6 +13,15 @@ //===----------------------------------------------------------------------===// import struct Foundation.Data +import class Foundation.JSONEncoder + +/// Create a JSONEncoder configured according to the requirements of the image specification. +func containerJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder +} /// A source, such as a registry, from which container images can be fetched. public protocol ImageSource { diff --git a/Sources/ContainerRegistry/RegistryClient.swift b/Sources/ContainerRegistry/RegistryClient.swift index 3902cd8..d117d49 100644 --- a/Sources/ContainerRegistry/RegistryClient.swift +++ b/Sources/ContainerRegistry/RegistryClient.swift @@ -66,16 +66,12 @@ public struct RegistryClient { /// - registry: HTTP URL of the registry's API endpoint. /// - client: HTTPClient object to use to connect to the registry. /// - auth: An authentication handler which can provide authentication credentials. - /// - encoder: JSONEncoder to use when encoding messages to the registry. - /// - decoder: JSONDecoder to use when decoding messages from the registry. /// - Throws: If the registry name is invalid. /// - Throws: If a connection to the registry cannot be established. public init( registry: URL, client: HTTPClient, - auth: AuthHandler? = nil, - encodingWith encoder: JSONEncoder? = nil, - decodingWith decoder: JSONDecoder? = nil + auth: AuthHandler? = nil ) async throws { registryURL = registry self.client = client @@ -83,20 +79,8 @@ public struct RegistryClient { // The registry server does not normalize JSON and calculates digests over the raw message text. // We must use consistent encoder settings when encoding and calculating digests. - // - // We must also configure the date encoding strategy otherwise the dates are printed as - // fractional numbers of seconds, whereas the container image requires ISO8601. - if let encoder { - self.encoder = encoder - } else { - self.encoder = JSONEncoder() - self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] - self.encoder.dateEncodingStrategy = .iso8601 - } - - // No special configuration is required for the decoder, but we should use a single instance - // rather than creating new instances where we need them. - self.decoder = decoder ?? JSONDecoder() + self.encoder = containerJSONEncoder() + self.decoder = JSONDecoder() // Verify that we can talk to the registry self.authChallenge = try await RegistryClient.checkAPI(client: self.client, registryURL: self.registryURL) diff --git a/Sources/ContainerRegistry/ScratchImage.swift b/Sources/ContainerRegistry/ScratchImage.swift new file mode 100644 index 0000000..a67c05e --- /dev/null +++ b/Sources/ContainerRegistry/ScratchImage.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftContainerPlugin open source project +// +// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import struct Foundation.Data +import class Foundation.JSONEncoder + +/// ScratchImage is a special-purpose ImageSource which represents the scratch image. +public struct ScratchImage { + var encoder: JSONEncoder + + var architecture: String + var os: String + + var configuration: ImageConfiguration + var manifest: ImageManifest + var manifestDescriptor: ContentDescriptor + var index: ImageIndex + + public init(architecture: String, os: String) { + self.encoder = containerJSONEncoder() + + self.architecture = architecture + self.os = os + + self.configuration = ImageConfiguration( + architecture: architecture, + os: os, + rootfs: .init(_type: "layers", diff_ids: []) + ) + let encodedConfiguration = try! encoder.encode(self.configuration) + + self.manifest = ImageManifest( + schemaVersion: 2, + config: ContentDescriptor( + mediaType: "application/vnd.oci.image.config.v1+json", + digest: "\(ImageReference.Digest(of: encodedConfiguration))", + size: Int64(encodedConfiguration.count) + ), + layers: [] + ) + let encodedManifest = try! encoder.encode(self.manifest) + + self.manifestDescriptor = ContentDescriptor( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "\(ImageReference.Digest(of: encodedManifest))", + size: Int64(encodedManifest.count) + ) + + self.index = ImageIndex( + schemaVersion: 2, + mediaType: "application/vnd.oci.image.index.v1+json", + manifests: [ + ContentDescriptor( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "\(ImageReference.Digest(of: encodedManifest))", + size: Int64(encodedManifest.count), + platform: .init(architecture: architecture, os: os) + ) + ] + ) + } +} + +extension ScratchImage: ImageSource { + /// The scratch image has no data layers, so `getBlob` returns an empty data blob. + /// + /// - Parameters: + /// - repository: Name of the repository containing the blob. + /// - digest: Digest of the blob. + /// - Returns: An empty blob. + /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. + public func getBlob( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Data { + Data() + } + + /// Returns an empty manifest for the scratch image, with no image layers. + /// + /// - Parameters: + /// - repository: Name of the source repository. + /// - reference: Tag or digest of the manifest to fetch. + /// - Returns: The downloaded manifest. + /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. + public func getManifest( + repository: ImageReference.Repository, + reference: any ImageReference.Reference + ) async throws -> (ImageManifest, ContentDescriptor) { + (self.manifest, self.manifestDescriptor) + } + + /// Fetches an image index. + /// + /// - Parameters: + /// - repository: Name of the source repository. + /// - reference: Tag or digest of the index to fetch. + /// - Returns: The downloaded index. + /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. + public func getIndex( + repository: ImageReference.Repository, + reference: any ImageReference.Reference + ) async throws -> ImageIndex { + self.index + } + + /// Returns an almost empty image configuration scratch image. + /// The processor architecture and operating system fields are populated, + /// but the layer list is empty. + /// + /// - Parameters: + /// - image: Reference to the image containing the record. + /// - digest: Digest of the record. + /// - Returns: A suitable configuration for the scratch image. + /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. + /// + /// Image configuration records are stored as blobs in the registry. This function retrieves the requested blob and tries to decode it as a configuration record. + public func getImageConfiguration( + forImage image: ImageReference, + digest: ImageReference.Digest + ) async throws -> ImageConfiguration { + self.configuration + } +} diff --git a/Sources/containertool/Extensions/RegistryClient+publish.swift b/Sources/containertool/Extensions/RegistryClient+publish.swift index 3a72351..00d049a 100644 --- a/Sources/containertool/Extensions/RegistryClient+publish.swift +++ b/Sources/containertool/Extensions/RegistryClient+publish.swift @@ -21,7 +21,7 @@ import Tar func publishContainerImage( baseImage: ImageReference, destinationImage: ImageReference, - source: Source?, + source: Source, destination: Destination, architecture: String, os: String, @@ -33,34 +33,17 @@ func publishContainerImage( // MARK: Find the base image - let baseImageManifest: ImageManifest - let baseImageConfiguration: ImageConfiguration - let baseImageDescriptor: ContentDescriptor - if let source { - (baseImageManifest, baseImageDescriptor) = try await source.getImageManifest( - forImage: baseImage, - architecture: architecture - ) - try log("Found base image manifest: \(ImageReference.Digest(baseImageDescriptor.digest))") + let (baseImageManifest, baseImageDescriptor) = try await source.getImageManifest( + forImage: baseImage, + architecture: architecture + ) + try log("Found base image manifest: \(ImageReference.Digest(baseImageDescriptor.digest))") - baseImageConfiguration = try await source.getImageConfiguration( - forImage: baseImage, - digest: ImageReference.Digest(baseImageManifest.config.digest) - ) - log("Found base image configuration: \(baseImageManifest.config.digest)") - } else { - baseImageManifest = .init( - schemaVersion: 2, - config: .init(mediaType: "scratch", digest: "scratch", size: 0), - layers: [] - ) - baseImageConfiguration = .init( - architecture: architecture, - os: os, - rootfs: .init(_type: "layers", diff_ids: []) - ) - if verbose { log("Using scratch as base image") } - } + let baseImageConfiguration = try await source.getImageConfiguration( + forImage: baseImage, + digest: ImageReference.Digest(baseImageManifest.config.digest) + ) + log("Found base image configuration: \(baseImageManifest.config.digest)") // MARK: Upload resource layers @@ -142,15 +125,13 @@ func publishContainerImage( // Copy the base image layers to the destination repository // Layers could be checked and uploaded concurrently // This could also happen in parallel with the application image build - if let source { - for layer in baseImageManifest.layers { - try await source.copyBlob( - digest: ImageReference.Digest(layer.digest), - fromRepository: baseImage.repository, - toClient: destination, - toRepository: destinationImage.repository - ) - } + for layer in baseImageManifest.layers { + try await source.copyBlob( + digest: ImageReference.Digest(layer.digest), + fromRepository: baseImage.repository, + toClient: destination, + toRepository: destinationImage.repository + ) } // MARK: Upload application manifest diff --git a/Sources/containertool/containertool.swift b/Sources/containertool/containertool.swift index b9041f1..46d77ce 100644 --- a/Sources/containertool/containertool.swift +++ b/Sources/containertool/containertool.swift @@ -190,9 +190,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti // The base image may be stored on a different registry to the final destination, so two clients are needed. // `scratch` is a special case and requires no source client. - let source: RegistryClient? + let source: ImageSource if from == "scratch" { - source = nil + source = ScratchImage(architecture: architecture, os: os) } else { source = try await RegistryClient( registry: baseImage.registry, diff --git a/Tests/ContainerRegistryTests/ImageReferenceTests.swift b/Tests/ContainerRegistryTests/ImageReferenceTests.swift index aaa9fbe..3013b32 100644 --- a/Tests/ContainerRegistryTests/ImageReferenceTests.swift +++ b/Tests/ContainerRegistryTests/ImageReferenceTests.swift @@ -273,6 +273,36 @@ struct ReferenceTests { ) ) } + + @Test + func testScratchReferences() throws { + // The unqualified "scratch" image is handled locally so should not be expanded. + #expect( + try! ImageReference(fromString: "scratch", defaultRegistry: "localhost:5000") + == ImageReference( + registry: "", + repository: ImageReference.Repository("scratch"), + reference: ImageReference.Tag("latest") + ) + ) + } + + @Test + func testReferenceDescription() throws { + #expect( + "\(try! ImageReference(fromString: "swift", defaultRegistry: "localhost:5000"))" + == "localhost:5000/swift:latest" + ) + + #expect( + "\(try! ImageReference(fromString: "library/swift:slim", defaultRegistry: "docker.io"))" + == "index.docker.io/library/swift:slim" + ) + + #expect( + "\(try! ImageReference(fromString: "scratch", defaultRegistry: "localhost:5000"))" == "scratch:latest" + ) + } } struct DigestTests {