diff --git a/Sources/ContainerRegistry/ImageDestination.swift b/Sources/ContainerRegistry/ImageDestination.swift new file mode 100644 index 0000000..4e4e88d --- /dev/null +++ b/Sources/ContainerRegistry/ImageDestination.swift @@ -0,0 +1,124 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// A destination, such as a registry, to which container images can be uploaded. +public protocol ImageDestination { + /// Checks whether a blob exists. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - digest: Digest of the requested blob. + /// - Returns: True if the blob exists, otherwise false. + /// - Throws: If the destination encounters an error. + func blobExists( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Bool + + /// Uploads a blob of unstructured data. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - mediaType: mediaType field for returned ContentDescriptor. + /// On the wire, all blob uploads are `application/octet-stream'. + /// - data: Object to be uploaded. + /// - Returns: An ContentDescriptor object representing the + /// uploaded blob. + /// - Throws: If the upload fails. + func putBlob( + repository: ImageReference.Repository, + mediaType: String, + data: Data + ) async throws -> ContentDescriptor + + /// Encodes and uploads a JSON object. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - mediaType: mediaType field for returned ContentDescriptor. + /// On the wire, all blob uploads are `application/octet-stream'. + /// - data: Object to be uploaded. + /// - Returns: An ContentDescriptor object representing the + /// uploaded blob. + /// - Throws: If the blob cannot be encoded or the upload fails. + /// + /// Some JSON objects, such as ImageConfiguration, are stored + /// in the registry as plain blobs with MIME type "application/octet-stream". + /// This function encodes the data parameter and uploads it as a generic blob. + func putBlob( + repository: ImageReference.Repository, + mediaType: String, + data: Body + ) async throws -> ContentDescriptor + + /// Encodes and uploads an image manifest. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - reference: Optional tag to apply to this manifest. + /// - manifest: Manifest to be uploaded. + /// - Returns: An ContentDescriptor object representing the + /// uploaded blob. + /// - Throws: If the blob cannot be encoded or the upload fails. + /// + /// Manifests are not treated as blobs by the distribution specification. + /// They have their own MIME types and are uploaded to different + /// registry endpoints than blobs. + func putManifest( + repository: ImageReference.Repository, + reference: (any ImageReference.Reference)?, + manifest: ImageManifest + ) async throws -> ContentDescriptor +} + +extension ImageDestination { + /// Uploads a blob of unstructured data. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - mediaType: mediaType field for returned ContentDescriptor. + /// On the wire, all blob uploads are `application/octet-stream'. + /// - data: Object to be uploaded. + /// - Returns: An ContentDescriptor object representing the + /// uploaded blob. + /// - Throws: If the upload fails. + public func putBlob( + repository: ImageReference.Repository, + mediaType: String = "application/octet-stream", + data: Data + ) async throws -> ContentDescriptor { + try await putBlob(repository: repository, mediaType: mediaType, data: data) + } + + /// Upload an image configuration record to the registry. + /// - Parameters: + /// - image: Reference to the image associated with the record. + /// - configuration: An image configuration record + /// - Returns: An `ContentDescriptor` referring to the blob stored in the registry. + /// - Throws: If the blob upload fails. + /// + /// Image configuration records are stored as blobs in the registry. This function encodes the provided configuration record and stores it as a blob in the registry. + public func putImageConfiguration( + forImage image: ImageReference, + configuration: ImageConfiguration + ) async throws -> ContentDescriptor { + try await putBlob( + repository: image.repository, + mediaType: "application/vnd.oci.image.config.v1+json", + data: configuration + ) + } +} diff --git a/Sources/ContainerRegistry/ImageSource.swift b/Sources/ContainerRegistry/ImageSource.swift new file mode 100644 index 0000000..44d9762 --- /dev/null +++ b/Sources/ContainerRegistry/ImageSource.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +/// A source, such as a registry, from which container images can be fetched. +public protocol ImageSource { + /// Fetches a blob of unstructured data. + /// + /// - Parameters: + /// - repository: Name of the source repository. + /// - digest: Digest of the blob. + /// - Returns: The downloaded data. + /// - Throws: If the blob download fails. + func getBlob( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Data + + /// Fetches an image manifest. + /// + /// - Parameters: + /// - repository: Name of the source repository. + /// - reference: Tag or digest of the manifest to fetch. + /// - Returns: The downloaded manifest. + /// - Throws: If the download fails or the manifest cannot be decoded. + func getManifest( + repository: ImageReference.Repository, + reference: any ImageReference.Reference + ) async throws -> (ImageManifest, ContentDescriptor) + + /// 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: If the download fails or the index cannot be decoded. + func getIndex( + repository: ImageReference.Repository, + reference: any ImageReference.Reference + ) async throws -> ImageIndex + + /// Fetches an image configuration from the registry. + /// + /// - Parameters: + /// - image: Reference to the image containing the record. + /// - digest: Digest of the configuration object to fetch. + /// - Returns: The image confguration record. + /// - Throws: If the download fails or the configuration record cannot be decoded. + /// + /// 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. + func getImageConfiguration( + forImage image: ImageReference, + digest: ImageReference.Digest + ) async throws -> ImageConfiguration +} diff --git a/Sources/ContainerRegistry/CheckAPI.swift b/Sources/ContainerRegistry/RegistryClient+CheckAPI.swift similarity index 91% rename from Sources/ContainerRegistry/CheckAPI.swift rename to Sources/ContainerRegistry/RegistryClient+CheckAPI.swift index 8fab9b2..eaee9af 100644 --- a/Sources/ContainerRegistry/CheckAPI.swift +++ b/Sources/ContainerRegistry/RegistryClient+CheckAPI.swift @@ -12,13 +12,13 @@ // //===----------------------------------------------------------------------===// -import Foundation +import struct Foundation.URL -public extension RegistryClient { +extension RegistryClient { /// Checks whether the registry supports v2 of the distribution specification. /// - Returns: an `true` if the registry supports the distribution specification. /// - Throws: if the registry does not support the distribution specification. - static func checkAPI(client: HTTPClient, registryURL: URL) async throws -> AuthChallenge { + public static func checkAPI(client: HTTPClient, registryURL: URL) async throws -> AuthChallenge { // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support // The registry indicates that it supports the v2 protocol by returning a 200 OK response. diff --git a/Sources/ContainerRegistry/RegistryClient+ImageConfiguration.swift b/Sources/ContainerRegistry/RegistryClient+ImageConfiguration.swift deleted file mode 100644 index 02a8d11..0000000 --- a/Sources/ContainerRegistry/RegistryClient+ImageConfiguration.swift +++ /dev/null @@ -1,50 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftContainerPlugin open source project -// -// Copyright (c) 2024 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 -// -//===----------------------------------------------------------------------===// - -extension RegistryClient { - /// Get an image configuration record from the registry. - /// - Parameters: - /// - image: Reference to the image containing the record. - /// - digest: Digest of the record. - /// - Returns: The image confguration record stored in `repository` with digest `digest`. - /// - Throws: If the blob cannot be decoded as an `ImageConfiguration`. - /// - /// 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 { - let data = try await getBlob(repository: image.repository, digest: digest) - return try decoder.decode(ImageConfiguration.self, from: data) - } - - /// Upload an image configuration record to the registry. - /// - Parameters: - /// - image: Reference to the image associated with the record. - /// - configuration: An image configuration record - /// - Returns: An `ContentDescriptor` referring to the blob stored in the registry. - /// - Throws: If the blob upload fails. - /// - /// Image configuration records are stored as blobs in the registry. This function encodes the provided configuration record and stores it as a blob in the registry. - public func putImageConfiguration( - forImage image: ImageReference, - configuration: ImageConfiguration - ) async throws -> ContentDescriptor { - try await putBlob( - repository: image.repository, - mediaType: "application/vnd.oci.image.config.v1+json", - data: configuration - ) - } -} diff --git a/Sources/ContainerRegistry/Blobs.swift b/Sources/ContainerRegistry/RegistryClient+ImageDestination.swift similarity index 70% rename from Sources/ContainerRegistry/Blobs.swift rename to Sources/ContainerRegistry/RegistryClient+ImageDestination.swift index 8ccbad6..bb7f3cf 100644 --- a/Sources/ContainerRegistry/Blobs.swift +++ b/Sources/ContainerRegistry/RegistryClient+ImageDestination.swift @@ -12,10 +12,11 @@ // //===----------------------------------------------------------------------===// -import Foundation +import struct Foundation.Data +import struct Foundation.URL import HTTPTypes -extension RegistryClient { +extension RegistryClient: ImageDestination { // Internal helper method to initiate a blob upload in 'two shot' mode func startBlobUploadSession(repository: ImageReference.Repository) async throws -> URL { // Upload in "two shot" mode. @@ -48,10 +49,18 @@ extension RegistryClient { return locationURL } -} -public extension RegistryClient { - func blobExists(repository: ImageReference.Repository, digest: ImageReference.Digest) async throws -> Bool { + /// Checks whether a blob exists. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - digest: Digest of the requested blob. + /// - Returns: True if the blob exists, otherwise false. + /// - Throws: If the destination encounters an error. + public func blobExists( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Bool { do { let _ = try await executeRequestThrowing( .head(repository, path: "blobs/\(digest)"), @@ -61,21 +70,6 @@ public extension RegistryClient { } catch HTTPClientError.unexpectedStatusCode(status: .notFound, _, _) { return false } } - /// Fetches an unstructured blob of data from the registry. - /// - /// - Parameters: - /// - repository: Name of the repository containing the blob. - /// - digest: Digest of the blob. - /// - Returns: The downloaded data. - /// - Throws: If the blob download fails. - func getBlob(repository: ImageReference.Repository, digest: ImageReference.Digest) async throws -> Data { - try await executeRequestThrowing( - .get(repository, path: "blobs/\(digest)", accepting: ["application/octet-stream"]), - decodingErrors: [.notFound] - ) - .data - } - /// Uploads a blob to the registry. /// /// This function uploads a blob of unstructured data to the registry. @@ -87,10 +81,11 @@ public extension RegistryClient { /// - Returns: An ContentDescriptor object representing the /// uploaded blob. /// - Throws: If the blob cannot be encoded or the upload fails. - func putBlob(repository: ImageReference.Repository, mediaType: String = "application/octet-stream", data: Data) - async throws - -> ContentDescriptor - { + public func putBlob( + repository: ImageReference.Repository, + mediaType: String = "application/octet-stream", + data: Data + ) async throws -> ContentDescriptor { // Ask the server to open a session and tell us where to upload our data let location = try await startBlobUploadSession(repository: repository) @@ -133,14 +128,53 @@ public extension RegistryClient { /// Some JSON objects, such as ImageConfiguration, are stored /// in the registry as plain blobs with MIME type "application/octet-stream". /// This function encodes the data parameter and uploads it as a generic blob. - func putBlob( + public func putBlob( repository: ImageReference.Repository, mediaType: String = "application/octet-stream", data: Body - ) - async throws -> ContentDescriptor - { + ) async throws -> ContentDescriptor { let encoded = try encoder.encode(data) return try await putBlob(repository: repository, mediaType: mediaType, data: encoded) } + + /// Encodes and uploads an image manifest. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - reference: Optional tag to apply to this manifest. + /// - manifest: Manifest to be uploaded. + /// - Returns: An ContentDescriptor object representing the + /// uploaded blob. + /// - Throws: If the blob cannot be encoded or the upload fails. + /// + /// Manifests are not treated as blobs by the distribution specification. + /// They have their own MIME types and are uploaded to different + public func putManifest( + repository: ImageReference.Repository, + reference: (any ImageReference.Reference)? = nil, + manifest: ImageManifest + ) async throws -> ContentDescriptor { + // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests + + let encoded = try encoder.encode(manifest) + let digest = ImageReference.Digest(of: encoded) + let mediaType = manifest.mediaType ?? "application/vnd.oci.image.manifest.v1+json" + + let _ = try await executeRequestThrowing( + .put( + repository, + path: "manifests/\(reference ?? digest)", + contentType: mediaType + ), + uploading: encoded, + expectingStatus: .created, + decodingErrors: [.notFound] + ) + + return ContentDescriptor( + mediaType: mediaType, + digest: "\(digest)", + size: Int64(encoded.count) + ) + } } diff --git a/Sources/ContainerRegistry/Manifests.swift b/Sources/ContainerRegistry/RegistryClient+ImageSource.swift similarity index 53% rename from Sources/ContainerRegistry/Manifests.swift rename to Sources/ContainerRegistry/RegistryClient+ImageSource.swift index a0fbedc..c41f23a 100644 --- a/Sources/ContainerRegistry/Manifests.swift +++ b/Sources/ContainerRegistry/RegistryClient+ImageSource.swift @@ -12,37 +12,35 @@ // //===----------------------------------------------------------------------===// -public extension RegistryClient { - func putManifest( - repository: ImageReference.Repository, - reference: (any ImageReference.Reference)? = nil, - manifest: ImageManifest - ) async throws -> ContentDescriptor { - // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests - - let encoded = try encoder.encode(manifest) - let digest = ImageReference.Digest(of: encoded) - let mediaType = manifest.mediaType ?? "application/vnd.oci.image.manifest.v1+json" +import struct Foundation.Data - let _ = try await executeRequestThrowing( - .put( - repository, - path: "manifests/\(reference ?? digest)", - contentType: mediaType - ), - uploading: encoded, - expectingStatus: .created, +extension RegistryClient: ImageSource { + /// Fetches an unstructured blob of data from the registry. + /// + /// - Parameters: + /// - repository: Name of the repository containing the blob. + /// - digest: Digest of the blob. + /// - Returns: The downloaded data. + /// - Throws: If the blob download fails. + public func getBlob( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Data { + try await executeRequestThrowing( + .get(repository, path: "blobs/\(digest)", accepting: ["application/octet-stream"]), decodingErrors: [.notFound] ) - - return ContentDescriptor( - mediaType: mediaType, - digest: "\(digest)", - size: Int64(encoded.count) - ) + .data } - func getManifest( + /// Fetches an image manifest. + /// + /// - Parameters: + /// - repository: Name of the source repository. + /// - reference: Tag or digest of the manifest to fetch. + /// - Returns: The downloaded manifest. + /// - Throws: If the download fails or the manifest cannot be decoded. + public func getManifest( repository: ImageReference.Repository, reference: any ImageReference.Reference ) async throws -> (ImageManifest, ContentDescriptor) { @@ -68,7 +66,14 @@ public extension RegistryClient { ) } - func getIndex( + /// 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: If the download fails or the index cannot be decoded. + public func getIndex( repository: ImageReference.Repository, reference: any ImageReference.Reference ) async throws -> ImageIndex { @@ -86,4 +91,20 @@ public extension RegistryClient { ) return try decoder.decode(ImageIndex.self, from: data) } + + /// Get an image configuration record from the registry. + /// - Parameters: + /// - image: Reference to the image containing the record. + /// - digest: Digest of the record. + /// - Returns: The image confguration record stored in `repository` with digest `digest`. + /// - Throws: If the blob cannot be decoded as an `ImageConfiguration`. + /// + /// 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 { + let data = try await getBlob(repository: image.repository, digest: digest) + return try decoder.decode(ImageConfiguration.self, from: data) + } } diff --git a/Sources/ContainerRegistry/Tags.swift b/Sources/ContainerRegistry/RegistryClient+Tags.swift similarity index 70% rename from Sources/ContainerRegistry/Tags.swift rename to Sources/ContainerRegistry/RegistryClient+Tags.swift index fe24dc2..6298e8a 100644 --- a/Sources/ContainerRegistry/Tags.swift +++ b/Sources/ContainerRegistry/RegistryClient+Tags.swift @@ -12,8 +12,13 @@ // //===----------------------------------------------------------------------===// -public extension RegistryClient { - func getTags(repository: ImageReference.Repository) async throws -> Tags { +extension RegistryClient { + /// Fetches all tags defined on a particular repository. + /// + /// - Parameter repository: Name of the repository to list. + /// - Returns: a list of tags. + /// - Throws: If the tag request fails or the response cannot be decoded. + public func getTags(repository: ImageReference.Repository) async throws -> Tags { // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags let (data, _) = try await executeRequestThrowing( .get(repository, path: "tags/list"), diff --git a/Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift b/Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift index 9c9770b..f51a19a 100644 --- a/Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift +++ b/Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift @@ -14,7 +14,7 @@ import ContainerRegistry -extension RegistryClient { +extension ImageSource { /// Copies a blob from another registry to this one. /// - Parameters: /// - digest: The digest of the blob to copy. @@ -25,7 +25,7 @@ extension RegistryClient { func copyBlob( digest: ImageReference.Digest, fromRepository sourceRepository: ImageReference.Repository, - toClient destClient: RegistryClient, + toClient destClient: ImageDestination, toRepository destRepository: ImageReference.Repository ) async throws { if try await destClient.blobExists(repository: destRepository, digest: digest) { diff --git a/Sources/containertool/Extensions/RegistryClient+Layers.swift b/Sources/containertool/Extensions/RegistryClient+Layers.swift index 162df12..98fdd28 100644 --- a/Sources/containertool/Extensions/RegistryClient+Layers.swift +++ b/Sources/containertool/Extensions/RegistryClient+Layers.swift @@ -15,10 +15,17 @@ import struct Foundation.Data import ContainerRegistry -extension RegistryClient { - func getImageManifest(forImage image: ImageReference, architecture: String) async throws -> ( - ImageManifest, ContentDescriptor - ) { +extension ImageSource { + // A single-architecture image may begin with a manifest; a multi-architecture + // image begins with an index which points to one or more manifests. The client + // could receive either type of object: + // * if the registry sends a manifest, this function returns it + // * if the registry sends an index, this function chooses the + // appropriate architecture-specific manifest and returns it. + func getImageManifest( + forImage image: ImageReference, + architecture: String + ) async throws -> (ImageManifest, ContentDescriptor) { do { // Try to retrieve a manifest. If the object with this reference is actually an index, the content-type will not match and // an error will be thrown. @@ -37,25 +44,21 @@ extension RegistryClient { ) } } +} - typealias DiffID = ImageReference.Digest - struct ImageLayer { - var descriptor: ContentDescriptor - var diffID: DiffID - } - +extension ImageDestination { // A layer is a tarball, optionally compressed using gzip or zstd // See https://github.com/opencontainers/image-spec/blob/main/media-types.md func uploadLayer( repository: ImageReference.Repository, contents: [UInt8], mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip" - ) async throws -> ImageLayer { + ) async throws -> (descriptor: ContentDescriptor, diffID: ImageReference.Digest) { // The diffID is the hash of the unzipped layer tarball let diffID = ImageReference.Digest(of: contents) // The layer blob is the gzipped tarball; the descriptor is the hash of this gzipped blob let blob = Data(gzip(contents)) let descriptor = try await putBlob(repository: repository, mediaType: mediaType, data: blob) - return ImageLayer(descriptor: descriptor, diffID: diffID) + return (descriptor: descriptor, diffID: diffID) } } diff --git a/Sources/containertool/Extensions/RegistryClient+publish.swift b/Sources/containertool/Extensions/RegistryClient+publish.swift new file mode 100644 index 0000000..3a72351 --- /dev/null +++ b/Sources/containertool/Extensions/RegistryClient+publish.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// +// 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.Date +import struct Foundation.URL + +import ContainerRegistry +import Tar + +func publishContainerImage( + baseImage: ImageReference, + destinationImage: ImageReference, + source: Source?, + destination: Destination, + architecture: String, + os: String, + resources: [String], + tag: String?, + verbose: Bool, + executableURL: URL +) async throws -> ImageReference { + + // 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))") + + 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") } + } + + // MARK: Upload resource layers + + var resourceLayers: [(descriptor: ContentDescriptor, diffID: ImageReference.Digest)] = [] + for resourceDir in resources { + let resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes + let resourceLayer = try await destination.uploadLayer( + repository: destinationImage.repository, + contents: resourceTardiff + ) + + if verbose { + log("resource layer: \(resourceLayer.descriptor.digest) (\(resourceLayer.descriptor.size) bytes)") + } + + resourceLayers.append(resourceLayer) + } + + // MARK: Upload the application layer + + let applicationLayer = try await destination.uploadLayer( + repository: destinationImage.repository, + contents: try Archive().appendingFile(at: executableURL).bytes + ) + if verbose { + log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)") + } + + // MARK: Create the application configuration + + let timestamp = Date(timeIntervalSince1970: 0).ISO8601Format() + + // Inherit the configuration of the base image - UID, GID, environment etc - + // and override the entrypoint. + var inheritedConfiguration = baseImageConfiguration.config ?? .init() + inheritedConfiguration.Entrypoint = ["/\(executableURL.lastPathComponent)"] + inheritedConfiguration.Cmd = [] + inheritedConfiguration.WorkingDir = "/" + + let configuration = ImageConfiguration( + created: timestamp, + architecture: architecture, + os: os, + config: inheritedConfiguration, + rootfs: .init( + _type: "layers", + // The diff_id is the digest of the _uncompressed_ layer archive. + // It is used by the runtime, which might not store the layers in + // the compressed form in which it received them from the registry. + diff_ids: baseImageConfiguration.rootfs.diff_ids + + resourceLayers.map { "\($0.diffID)" } + + ["\(applicationLayer.diffID)"] + ), + history: [.init(created: timestamp, created_by: "containertool")] + ) + + let configurationBlobReference = try await destination.putImageConfiguration( + forImage: destinationImage, + configuration: configuration + ) + + if verbose { + log("image configuration: \(configurationBlobReference.digest) (\(configurationBlobReference.size) bytes)") + } + + // MARK: Create application manifest + + let manifest = ImageManifest( + schemaVersion: 2, + mediaType: "application/vnd.oci.image.manifest.v1+json", + config: configurationBlobReference, + layers: baseImageManifest.layers + + resourceLayers.map { $0.descriptor } + + [applicationLayer.descriptor] + ) + + // MARK: Upload base image + + // 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 + ) + } + } + + // MARK: Upload application manifest + + let manifestDescriptor = try await destination.putManifest( + repository: destinationImage.repository, + reference: destinationImage.reference, + manifest: manifest + ) + + if verbose { + log("manifest: \(manifestDescriptor.digest) (\(manifestDescriptor.size) bytes)") + } + + // Use the manifest's digest if the user did not provide a human-readable tag + // To support multiarch images, we should also create an an index pointing to + // this manifest. + let reference: ImageReference.Reference + if let tag { + reference = try ImageReference.Tag(tag) + } else { + reference = try ImageReference.Digest(manifestDescriptor.digest) + } + + var result = destinationImage + result.reference = reference + return result +} diff --git a/Sources/containertool/containertool.swift b/Sources/containertool/containertool.swift index 28ee2d4..b9041f1 100644 --- a/Sources/containertool/containertool.swift +++ b/Sources/containertool/containertool.swift @@ -12,11 +12,10 @@ // //===----------------------------------------------------------------------===// -import ArgumentParser import Foundation -import ContainerRegistry -import Tar +import ArgumentParser import Basics +import ContainerRegistry extension Swift.String: Swift.Error {} @@ -216,10 +215,11 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti // MARK: Build the image - let finalImage = try await destination.publishContainerImage( + let finalImage = try await publishContainerImage( baseImage: baseImage, destinationImage: destinationImage, source: source, + destination: destination, architecture: architecture, os: os, resources: imageBuildOptions.resources, @@ -231,166 +231,3 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti print(finalImage) } } - -extension RegistryClient { - func publishContainerImage( - baseImage: ImageReference, - destinationImage: ImageReference, - source: RegistryClient?, - architecture: String, - os: String, - resources: [String], - tag: String?, - verbose: Bool, - executableURL: URL - ) async throws -> ImageReference { - - // 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))") - - 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") } - } - - // MARK: Upload resource layers - - var resourceLayers: [RegistryClient.ImageLayer] = [] - for resourceDir in resources { - let resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes - let resourceLayer = try await self.uploadLayer( - repository: destinationImage.repository, - contents: resourceTardiff - ) - - if verbose { - log("resource layer: \(resourceLayer.descriptor.digest) (\(resourceLayer.descriptor.size) bytes)") - } - - resourceLayers.append(resourceLayer) - } - - // MARK: Upload the application layer - - let applicationLayer = try await self.uploadLayer( - repository: destinationImage.repository, - contents: try Archive().appendingFile(at: executableURL).bytes - ) - if verbose { - log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)") - } - - // MARK: Create the application configuration - - let timestamp = Date(timeIntervalSince1970: 0).ISO8601Format() - - // Inherit the configuration of the base image - UID, GID, environment etc - - // and override the entrypoint. - var inheritedConfiguration = baseImageConfiguration.config ?? .init() - inheritedConfiguration.Entrypoint = ["/\(executableURL.lastPathComponent)"] - inheritedConfiguration.Cmd = [] - inheritedConfiguration.WorkingDir = "/" - - let configuration = ImageConfiguration( - created: timestamp, - architecture: architecture, - os: os, - config: inheritedConfiguration, - rootfs: .init( - _type: "layers", - // The diff_id is the digest of the _uncompressed_ layer archive. - // It is used by the runtime, which might not store the layers in - // the compressed form in which it received them from the registry. - diff_ids: baseImageConfiguration.rootfs.diff_ids - + resourceLayers.map { "\($0.diffID)" } - + ["\(applicationLayer.diffID)"] - ), - history: [.init(created: timestamp, created_by: "containertool")] - ) - - let configurationBlobReference = try await self.putImageConfiguration( - forImage: destinationImage, - configuration: configuration - ) - - if verbose { - log("image configuration: \(configurationBlobReference.digest) (\(configurationBlobReference.size) bytes)") - } - - // MARK: Create application manifest - - let manifest = ImageManifest( - schemaVersion: 2, - mediaType: "application/vnd.oci.image.manifest.v1+json", - config: configurationBlobReference, - layers: baseImageManifest.layers - + resourceLayers.map { $0.descriptor } - + [applicationLayer.descriptor] - ) - - // MARK: Upload base image - - // 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: self, - toRepository: destinationImage.repository - ) - } - } - - // MARK: Upload application manifest - - let manifestDescriptor = try await self.putManifest( - repository: destinationImage.repository, - reference: destinationImage.reference, - manifest: manifest - ) - - if verbose { - log("manifest: \(manifestDescriptor.digest) (\(manifestDescriptor.size) bytes)") - } - - // Use the manifest's digest if the user did not provide a human-readable tag - // To support multiarch images, we should also create an an index pointing to - // this manifest. - let reference: ImageReference.Reference - if let tag { - reference = try ImageReference.Tag(tag) - } else { - reference = try ImageReference.Digest(manifestDescriptor.digest) - } - - var result = destinationImage - result.reference = reference - return result - } -}