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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions Sources/ContainerRegistry/ImageReference.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions Sources/ContainerRegistry/ImageSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 3 additions & 19 deletions Sources/ContainerRegistry/RegistryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,37 +66,21 @@ 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
self.auth = auth

// 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)
Expand Down
135 changes: 135 additions & 0 deletions Sources/ContainerRegistry/ScratchImage.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 18 additions & 37 deletions Sources/containertool/Extensions/RegistryClient+publish.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import Tar
func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
baseImage: ImageReference,
destinationImage: ImageReference,
source: Source?,
source: Source,
destination: Destination,
architecture: String,
os: String,
Expand All @@ -33,34 +33,17 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(

// 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

Expand Down Expand Up @@ -142,15 +125,13 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
// 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
Expand Down
4 changes: 2 additions & 2 deletions Sources/containertool/containertool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions Tests/ContainerRegistryTests/ImageReferenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down