Skip to content

Commit a6966f5

Browse files
authored
containertool: Use a null object to represent scratch images (#149)
Motivation ---------- When the `--from scratch` option is used, `containertool` does not download the [scratch](https://hub.docker.com/_/scratch) from Docker Hub - instead it synthesizes a suitable empty image. Currently this is handled by making the source client optional, but that causes `nil` checks to spread everywhere. Now that we have the `ImageSource` protocol (#148) we can instead use a [null object](https://en.wikipedia.org/wiki/Null_object_pattern) which can only return an empty image. This allows the `nil` checks to be removed and lets the same code path handle images built on real base images and the scratch base image. Modifications ------------- * Add `ScratchImage`, a null object implementing the `ImageSource` protocol * Remove special cases previously needed to make the image source optional. * Remove the option to pass `JSONEncoder` and `JSONDecoder` instances to `RegistryClient`. Specific configuration options must be used to match the requirements of the image specification, so there is not much value in allowing the user to pass in an encoder with an unknown configuration. Result ------ * Fewer special cases * No functional change Test Plan --------- Existing tests continue to pass.
1 parent 61d2f73 commit a6966f5

File tree

7 files changed

+212
-60
lines changed

7 files changed

+212
-60
lines changed

Sources/ContainerRegistry/ImageReference.swift

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,18 @@ public struct ImageReference: Sendable, Equatable, CustomStringConvertible, Cust
7878
public init(fromString reference: String, defaultRegistry: String = "localhost:5000") throws {
7979
let (registry, remainder) = try splitReference(reference)
8080
let (repository, reference) = try parseName(remainder)
81+
82+
// As a special case, do not expand the reference for the unqualified 'scratch' image as it is handled locally.
83+
if registry == nil && repository.value == "scratch" {
84+
self.registry = ""
85+
self.repository = repository
86+
self.reference = reference
87+
return
88+
}
89+
8190
self.registry = registry ?? defaultRegistry
8291
if self.registry == "docker.io" {
83-
self.registry = "index.docker.io" // Special case for docker client, there is no network-level redirect
92+
self.registry = "index.docker.io" // Special case for Docker Hub, there is no network-level redirect
8493
}
8594
// As a special case, official images can be referred to by a single name, such as `swift` or `swift:slim`.
8695
// 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
111120

112121
/// Printable description of an ImageReference in a form which can be understood by a runtime
113122
public var description: String {
114-
"\(registry)/\(repository)\(reference.separator)\(reference)"
123+
if registry == "" {
124+
"\(repository)\(reference.separator)\(reference)"
125+
} else {
126+
"\(registry)/\(repository)\(reference.separator)\(reference)"
127+
}
115128
}
116129

117130
/// Printable description of an ImageReference in a form suitable for debugging.

Sources/ContainerRegistry/ImageSource.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@
1313
//===----------------------------------------------------------------------===//
1414

1515
import struct Foundation.Data
16+
import class Foundation.JSONEncoder
17+
18+
/// Create a JSONEncoder configured according to the requirements of the image specification.
19+
func containerJSONEncoder() -> JSONEncoder {
20+
let encoder = JSONEncoder()
21+
encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
22+
encoder.dateEncodingStrategy = .iso8601
23+
return encoder
24+
}
1625

1726
/// A source, such as a registry, from which container images can be fetched.
1827
public protocol ImageSource {

Sources/ContainerRegistry/RegistryClient.swift

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,37 +66,21 @@ public struct RegistryClient {
6666
/// - registry: HTTP URL of the registry's API endpoint.
6767
/// - client: HTTPClient object to use to connect to the registry.
6868
/// - auth: An authentication handler which can provide authentication credentials.
69-
/// - encoder: JSONEncoder to use when encoding messages to the registry.
70-
/// - decoder: JSONDecoder to use when decoding messages from the registry.
7169
/// - Throws: If the registry name is invalid.
7270
/// - Throws: If a connection to the registry cannot be established.
7371
public init(
7472
registry: URL,
7573
client: HTTPClient,
76-
auth: AuthHandler? = nil,
77-
encodingWith encoder: JSONEncoder? = nil,
78-
decodingWith decoder: JSONDecoder? = nil
74+
auth: AuthHandler? = nil
7975
) async throws {
8076
registryURL = registry
8177
self.client = client
8278
self.auth = auth
8379

8480
// The registry server does not normalize JSON and calculates digests over the raw message text.
8581
// We must use consistent encoder settings when encoding and calculating digests.
86-
//
87-
// We must also configure the date encoding strategy otherwise the dates are printed as
88-
// fractional numbers of seconds, whereas the container image requires ISO8601.
89-
if let encoder {
90-
self.encoder = encoder
91-
} else {
92-
self.encoder = JSONEncoder()
93-
self.encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes]
94-
self.encoder.dateEncodingStrategy = .iso8601
95-
}
96-
97-
// No special configuration is required for the decoder, but we should use a single instance
98-
// rather than creating new instances where we need them.
99-
self.decoder = decoder ?? JSONDecoder()
82+
self.encoder = containerJSONEncoder()
83+
self.decoder = JSONDecoder()
10084

10185
// Verify that we can talk to the registry
10286
self.authChallenge = try await RegistryClient.checkAPI(client: self.client, registryURL: self.registryURL)
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the SwiftContainerPlugin open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import struct Foundation.Data
16+
import class Foundation.JSONEncoder
17+
18+
/// ScratchImage is a special-purpose ImageSource which represents the scratch image.
19+
public struct ScratchImage {
20+
var encoder: JSONEncoder
21+
22+
var architecture: String
23+
var os: String
24+
25+
var configuration: ImageConfiguration
26+
var manifest: ImageManifest
27+
var manifestDescriptor: ContentDescriptor
28+
var index: ImageIndex
29+
30+
public init(architecture: String, os: String) {
31+
self.encoder = containerJSONEncoder()
32+
33+
self.architecture = architecture
34+
self.os = os
35+
36+
self.configuration = ImageConfiguration(
37+
architecture: architecture,
38+
os: os,
39+
rootfs: .init(_type: "layers", diff_ids: [])
40+
)
41+
let encodedConfiguration = try! encoder.encode(self.configuration)
42+
43+
self.manifest = ImageManifest(
44+
schemaVersion: 2,
45+
config: ContentDescriptor(
46+
mediaType: "application/vnd.oci.image.config.v1+json",
47+
digest: "\(ImageReference.Digest(of: encodedConfiguration))",
48+
size: Int64(encodedConfiguration.count)
49+
),
50+
layers: []
51+
)
52+
let encodedManifest = try! encoder.encode(self.manifest)
53+
54+
self.manifestDescriptor = ContentDescriptor(
55+
mediaType: "application/vnd.oci.image.manifest.v1+json",
56+
digest: "\(ImageReference.Digest(of: encodedManifest))",
57+
size: Int64(encodedManifest.count)
58+
)
59+
60+
self.index = ImageIndex(
61+
schemaVersion: 2,
62+
mediaType: "application/vnd.oci.image.index.v1+json",
63+
manifests: [
64+
ContentDescriptor(
65+
mediaType: "application/vnd.oci.image.manifest.v1+json",
66+
digest: "\(ImageReference.Digest(of: encodedManifest))",
67+
size: Int64(encodedManifest.count),
68+
platform: .init(architecture: architecture, os: os)
69+
)
70+
]
71+
)
72+
}
73+
}
74+
75+
extension ScratchImage: ImageSource {
76+
/// The scratch image has no data layers, so `getBlob` returns an empty data blob.
77+
///
78+
/// - Parameters:
79+
/// - repository: Name of the repository containing the blob.
80+
/// - digest: Digest of the blob.
81+
/// - Returns: An empty blob.
82+
/// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements.
83+
public func getBlob(
84+
repository: ImageReference.Repository,
85+
digest: ImageReference.Digest
86+
) async throws -> Data {
87+
Data()
88+
}
89+
90+
/// Returns an empty manifest for the scratch image, with no image layers.
91+
///
92+
/// - Parameters:
93+
/// - repository: Name of the source repository.
94+
/// - reference: Tag or digest of the manifest to fetch.
95+
/// - Returns: The downloaded manifest.
96+
/// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements.
97+
public func getManifest(
98+
repository: ImageReference.Repository,
99+
reference: any ImageReference.Reference
100+
) async throws -> (ImageManifest, ContentDescriptor) {
101+
(self.manifest, self.manifestDescriptor)
102+
}
103+
104+
/// Fetches an image index.
105+
///
106+
/// - Parameters:
107+
/// - repository: Name of the source repository.
108+
/// - reference: Tag or digest of the index to fetch.
109+
/// - Returns: The downloaded index.
110+
/// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements.
111+
public func getIndex(
112+
repository: ImageReference.Repository,
113+
reference: any ImageReference.Reference
114+
) async throws -> ImageIndex {
115+
self.index
116+
}
117+
118+
/// Returns an almost empty image configuration scratch image.
119+
/// The processor architecture and operating system fields are populated,
120+
/// but the layer list is empty.
121+
///
122+
/// - Parameters:
123+
/// - image: Reference to the image containing the record.
124+
/// - digest: Digest of the record.
125+
/// - Returns: A suitable configuration for the scratch image.
126+
/// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements.
127+
///
128+
/// 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.
129+
public func getImageConfiguration(
130+
forImage image: ImageReference,
131+
digest: ImageReference.Digest
132+
) async throws -> ImageConfiguration {
133+
self.configuration
134+
}
135+
}

Sources/containertool/Extensions/RegistryClient+publish.swift

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import Tar
2121
func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
2222
baseImage: ImageReference,
2323
destinationImage: ImageReference,
24-
source: Source?,
24+
source: Source,
2525
destination: Destination,
2626
architecture: String,
2727
os: String,
@@ -33,34 +33,17 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
3333

3434
// MARK: Find the base image
3535

36-
let baseImageManifest: ImageManifest
37-
let baseImageConfiguration: ImageConfiguration
38-
let baseImageDescriptor: ContentDescriptor
39-
if let source {
40-
(baseImageManifest, baseImageDescriptor) = try await source.getImageManifest(
41-
forImage: baseImage,
42-
architecture: architecture
43-
)
44-
try log("Found base image manifest: \(ImageReference.Digest(baseImageDescriptor.digest))")
36+
let (baseImageManifest, baseImageDescriptor) = try await source.getImageManifest(
37+
forImage: baseImage,
38+
architecture: architecture
39+
)
40+
try log("Found base image manifest: \(ImageReference.Digest(baseImageDescriptor.digest))")
4541

46-
baseImageConfiguration = try await source.getImageConfiguration(
47-
forImage: baseImage,
48-
digest: ImageReference.Digest(baseImageManifest.config.digest)
49-
)
50-
log("Found base image configuration: \(baseImageManifest.config.digest)")
51-
} else {
52-
baseImageManifest = .init(
53-
schemaVersion: 2,
54-
config: .init(mediaType: "scratch", digest: "scratch", size: 0),
55-
layers: []
56-
)
57-
baseImageConfiguration = .init(
58-
architecture: architecture,
59-
os: os,
60-
rootfs: .init(_type: "layers", diff_ids: [])
61-
)
62-
if verbose { log("Using scratch as base image") }
63-
}
42+
let baseImageConfiguration = try await source.getImageConfiguration(
43+
forImage: baseImage,
44+
digest: ImageReference.Digest(baseImageManifest.config.digest)
45+
)
46+
log("Found base image configuration: \(baseImageManifest.config.digest)")
6447

6548
// MARK: Upload resource layers
6649

@@ -142,15 +125,13 @@ func publishContainerImage<Source: ImageSource, Destination: ImageDestination>(
142125
// Copy the base image layers to the destination repository
143126
// Layers could be checked and uploaded concurrently
144127
// This could also happen in parallel with the application image build
145-
if let source {
146-
for layer in baseImageManifest.layers {
147-
try await source.copyBlob(
148-
digest: ImageReference.Digest(layer.digest),
149-
fromRepository: baseImage.repository,
150-
toClient: destination,
151-
toRepository: destinationImage.repository
152-
)
153-
}
128+
for layer in baseImageManifest.layers {
129+
try await source.copyBlob(
130+
digest: ImageReference.Digest(layer.digest),
131+
fromRepository: baseImage.repository,
132+
toClient: destination,
133+
toRepository: destinationImage.repository
134+
)
154135
}
155136

156137
// MARK: Upload application manifest

Sources/containertool/containertool.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,9 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti
190190

191191
// The base image may be stored on a different registry to the final destination, so two clients are needed.
192192
// `scratch` is a special case and requires no source client.
193-
let source: RegistryClient?
193+
let source: ImageSource
194194
if from == "scratch" {
195-
source = nil
195+
source = ScratchImage(architecture: architecture, os: os)
196196
} else {
197197
source = try await RegistryClient(
198198
registry: baseImage.registry,

Tests/ContainerRegistryTests/ImageReferenceTests.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,36 @@ struct ReferenceTests {
273273
)
274274
)
275275
}
276+
277+
@Test
278+
func testScratchReferences() throws {
279+
// The unqualified "scratch" image is handled locally so should not be expanded.
280+
#expect(
281+
try! ImageReference(fromString: "scratch", defaultRegistry: "localhost:5000")
282+
== ImageReference(
283+
registry: "",
284+
repository: ImageReference.Repository("scratch"),
285+
reference: ImageReference.Tag("latest")
286+
)
287+
)
288+
}
289+
290+
@Test
291+
func testReferenceDescription() throws {
292+
#expect(
293+
"\(try! ImageReference(fromString: "swift", defaultRegistry: "localhost:5000"))"
294+
== "localhost:5000/swift:latest"
295+
)
296+
297+
#expect(
298+
"\(try! ImageReference(fromString: "library/swift:slim", defaultRegistry: "docker.io"))"
299+
== "index.docker.io/library/swift:slim"
300+
)
301+
302+
#expect(
303+
"\(try! ImageReference(fromString: "scratch", defaultRegistry: "localhost:5000"))" == "scratch:latest"
304+
)
305+
}
276306
}
277307

278308
struct DigestTests {

0 commit comments

Comments
 (0)