diff --git a/Sources/ContainerRegistry/TarImageDestination.swift b/Sources/ContainerRegistry/TarImageDestination.swift new file mode 100644 index 0000000..294680f --- /dev/null +++ b/Sources/ContainerRegistry/TarImageDestination.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// 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.OutputStream +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder +import Tar + +/// A tar file on disk, to which a container image can be saved. +public class TarImageDestination { + public var decoder: JSONDecoder + var encoder: JSONEncoder + + var archive: Archive + + /// Creates a new TarImageDestination + /// - Parameter stream: OutputStream to which the archive should be written + /// - Throws: If an error occurs when serializing archive. + public init(toStream stream: OutputStream) throws { + self.archive = Archive(toStream: stream) + self.decoder = JSONDecoder() + self.encoder = containerJSONEncoder() + + try archive.appendFile(name: "oci-layout", data: [UInt8](encoder.encode(ImageLayoutHeader()))) + try archive.appendDirectory(name: "blobs") + try archive.appendDirectory(name: "blobs/sha256") + } +} + +extension TarImageDestination: ImageDestination { + /// Saves a blob of unstructured data to the destination. + /// - Parameters: + /// - repository: Name of the destination repository. + /// - mediaType: mediaType field for returned ContentDescriptor. + /// - data: Object to be saved. + /// - Returns: An ContentDescriptor object representing the + /// saved blob. + /// - Throws: If the blob cannot be encoded or the save fails. + public func putBlob( + repository: ImageReference.Repository, + mediaType: String, + data: Data + ) async throws -> ContentDescriptor { + let digest = ImageReference.Digest(of: Data(data)) + try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](data)) + return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(data.count)) + } + + /// Saves a JSON object to the destination, serialized as an unstructured blob. + /// - Parameters: + /// - repository: Name of the destination repository. + /// - mediaType: mediaType field for returned ContentDescriptor. + /// - data: Object to be saved. + /// - Returns: An ContentDescriptor object representing the + /// saved blob. + /// - Throws: If the blob cannot be encoded or the save fails. + public func putBlob( + repository: ImageReference.Repository, + mediaType: String, + data: Body + ) async throws -> ContentDescriptor { + let encoded = try encoder.encode(data) + return try await putBlob(repository: repository, mediaType: mediaType, data: encoded) + } + + /// Checks whether a blob exists. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - digest: Digest of the requested blob. + /// - Returns: Always returns False. + /// - Throws: If the destination encounters an error. + public func blobExists( + repository: ImageReference.Repository, + digest: ImageReference.Digest + ) async throws -> Bool { + false + } + + /// Encodes and saves an image manifest. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - reference: Optional tag to apply to this manifest. + /// - manifest: Manifest to be saved. + /// - Returns: An ContentDescriptor object representing the + /// saved blob. + /// - Throws: If the blob cannot be encoded or saved. + public func putManifest( + repository: ImageReference.Repository, + reference: (any ImageReference.Reference)?, + manifest: ImageManifest + ) async throws -> ContentDescriptor { + // Manifests are not special in the on-disk representation - they are just stored as blobs + try await self.putBlob( + repository: repository, + mediaType: "application/vnd.oci.image.manifest.v1+json", + data: manifest + ) + } + + /// Encodes and saves an image index. + /// + /// - Parameters: + /// - repository: Name of the destination repository. + /// - reference: Optional tag to apply to this index. + /// - index: Index to be saved. + /// - Returns: An ContentDescriptor object representing the + /// saved index. + /// - Throws: If the index cannot be encoded or saving fails. + public func putIndex( + repository: ImageReference.Repository, + reference: (any ImageReference.Reference)?, + index: ImageIndex + ) async throws -> ContentDescriptor { + // Unlike Manifest, Index is not written as a blob + let encoded = try encoder.encode(index) + let digest = ImageReference.Digest(of: encoded) + let mediaType = index.mediaType ?? "application/vnd.oci.image.index.v1+json" + + try archive.appendFile(name: "index.json", data: [UInt8](encoded)) + + try archive.appendFile(name: "\(digest.value)", prefix: "blobs/\(digest.algorithm)", data: [UInt8](encoded)) + return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(encoded.count)) + } +} + +struct ImageLayoutHeader: Codable { + var imageLayoutVersion: String = "1.0.0" +} diff --git a/Sources/Tar/tar.swift b/Sources/Tar/tar.swift index 4dcf459..65632c6 100644 --- a/Sources/Tar/tar.swift +++ b/Sources/Tar/tar.swift @@ -12,6 +12,8 @@ // //===----------------------------------------------------------------------===// +import Foundation + // This file defines a basic tar writer which produces POSIX tar files. // This avoids the need to depend on a system-provided tar binary. // @@ -342,44 +344,40 @@ public func tar(_ bytes: [UInt8], filename: String = "app") throws -> [UInt8] { } /// Represents a tar archive -public struct Archive { +public struct Archive: ~Copyable { /// The files, directories and other members of the archive - var members: [ArchiveMember] + var output: OutputStream /// Creates an empty Archive - public init() { - members = [] + public init(toStream: OutputStream = .toMemory()) { + output = toStream + output.open() + output.schedule(in: .current, forMode: .default) // is this needed? } - /// Appends a member to the archive - /// Parameters: - /// - member: The member to append - public mutating func append(_ member: ArchiveMember) { - self.members.append(member) + deinit { + output.close() } - /// Returns a new archive made by appending a member to the receiver + /// Appends a member to the archive /// Parameters: /// - member: The member to append - /// Returns: A new archive made by appending `member` to the receiver. - public func appending(_ member: ArchiveMember) -> Self { - var ret = self - ret.members += [member] - return ret + public func append(_ member: ArchiveMember) { + let written = output.write(member.bytes, maxLength: member.bytes.count) + if written != member.bytes.count { + fatalError("count: \(member.bytes.count), written: \(written)") + } } /// The serialized byte representation of the archive, including padding and end-of-archive marker. public var bytes: [UInt8] { - var ret: [UInt8] = [] - for member in members { - ret.append(contentsOf: member.bytes) + guard let data = output.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else { + fatalError("retrieving memory stream contents") } // Append the end of file marker let marker = [UInt8](repeating: 0, count: 2 * blockSize) - ret.append(contentsOf: marker) - - return ret + return [UInt8](data) + marker } } @@ -416,32 +414,15 @@ extension Archive { /// - name: File name /// - prefix: Path prefix /// - data: File contents - public mutating func appendFile(name: String, prefix: String = "", data: [UInt8]) throws { - try append(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data)) - } - - /// Adds a new file member at the end of the archive - /// parameters: - /// - name: File name - /// - prefix: Path prefix - /// - data: File contents - public func appendingFile(name: String, prefix: String = "", data: [UInt8]) throws -> Self { - try appending(.init(header: .init(name: name, size: data.count, prefix: prefix), data: data)) - } - - /// Adds a new directory member at the end of the archive - /// parameters: - /// - name: Directory name - /// - prefix: Path prefix - public mutating func appendDirectory(name: String, prefix: String = "") throws { - try append(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix))) + public func appendFile(name: String, prefix: String = "", data: [UInt8]) throws { + try append(.init(header: .init(name: name, mode: 0o755, size: data.count, prefix: prefix), data: data)) } /// Adds a new directory member at the end of the archive /// parameters: /// - name: Directory name /// - prefix: Path prefix - public func appendingDirectory(name: String, prefix: String = "") throws -> Self { - try self.appending(.init(header: .init(name: name, typeflag: .DIRTYPE, prefix: prefix))) + public func appendDirectory(name: String, prefix: String = "") throws { + try append(.init(header: .init(name: name, mode: 0o755, typeflag: .DIRTYPE, prefix: prefix))) } } diff --git a/Sources/containertool/Extensions/Archive+appending.swift b/Sources/containertool/Extensions/Archive+appending.swift index 76d7480..6731b52 100644 --- a/Sources/containertool/Extensions/Archive+appending.swift +++ b/Sources/containertool/Extensions/Archive+appending.swift @@ -30,12 +30,12 @@ extension Archive { /// Parameters: /// - root: The path to the file or directory to add. /// Returns: A new archive made by appending `root` to the receiver. - public func appendingRecursively(atPath root: String) throws -> Self { + public func appendRecursively(atPath root: String) throws { let url = URL(fileURLWithPath: root) if url.isDirectory { - return try self.appendingDirectoryTree(at: url) + try self.appendDirectoryTree(at: url) } else { - return try self.appendingFile(at: url) + try self.appendFile(at: url) } } @@ -43,17 +43,15 @@ extension Archive { /// Parameters: /// - path: The path to the file to add. /// Returns: A new archive made by appending `path` to the receiver. - func appendingFile(at path: URL) throws -> Self { - try self.appendingFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path))) + func appendFile(at path: URL) throws { + try self.appendFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path))) } /// Recursively append a single directory tree to the archive. /// Parameters: /// - root: The path to the directory to add. /// Returns: A new archive made by appending `root` to the receiver. - func appendingDirectoryTree(at root: URL) throws -> Self { - var ret = self - + func appendDirectoryTree(at root: URL) throws { guard let enumerator = FileManager.default.enumerator(atPath: root.path) else { throw ("Unable to read \(root.path)") } @@ -69,16 +67,14 @@ extension Archive { switch filetype { case .typeRegular: let resource = try [UInt8](Data(contentsOf: root.appending(path: subpath))) - try ret.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource) + try self.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource) case .typeDirectory: - try ret.appendDirectory(name: subpath, prefix: root.lastPathComponent) + try self.appendDirectory(name: subpath, prefix: root.lastPathComponent) default: throw "Resource file \(subpath) of type \(filetype) is not supported" } } - - return ret } } diff --git a/Sources/containertool/Extensions/RegistryClient+publish.swift b/Sources/containertool/Extensions/RegistryClient+publish.swift index 552ee2b..c393f48 100644 --- a/Sources/containertool/Extensions/RegistryClient+publish.swift +++ b/Sources/containertool/Extensions/RegistryClient+publish.swift @@ -49,10 +49,11 @@ func publishContainerImage( var resourceLayers: [(descriptor: ContentDescriptor, diffID: ImageReference.Digest)] = [] for resourceDir in resources { - let resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes + let resourceTardiff = Archive() + try resourceTardiff.appendRecursively(atPath: resourceDir) let resourceLayer = try await destination.uploadLayer( repository: destinationImage.repository, - contents: resourceTardiff + contents: resourceTardiff.bytes ) if verbose { @@ -64,9 +65,12 @@ func publishContainerImage( // MARK: Upload the application layer + let applicationTardiff = Archive() + try applicationTardiff.appendFile(at: executableURL) + let applicationLayer = try await destination.uploadLayer( repository: destinationImage.repository, - contents: try Archive().appendingFile(at: executableURL).bytes + contents: applicationTardiff.bytes ) if verbose { log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)") @@ -156,7 +160,10 @@ func publishContainerImage( mediaType: manifestDescriptor.mediaType, digest: manifestDescriptor.digest, size: Int64(manifestDescriptor.size), - platform: .init(architecture: architecture, os: os) + platform: .init(architecture: architecture, os: os), + annotations: [ + "org.opencontainers.image.ref.name": "\(destinationImage)" + ] ) ] ) diff --git a/Sources/containertool/containertool.swift b/Sources/containertool/containertool.swift index 46d77ce..8cb7c2f 100644 --- a/Sources/containertool/containertool.swift +++ b/Sources/containertool/containertool.swift @@ -19,34 +19,47 @@ import ContainerRegistry extension Swift.String: Swift.Error {} -enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, destination, both } +enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { + case source + case destination + case both +} -@main struct ContainerTool: AsyncParsableCommand { +@main +struct ContainerTool: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "containertool", - abstract: "Build and publish a container image" + abstract: "Build and publish a container image", + subcommands: [ + Publish.self, + Save.self, + ], + defaultSubcommand: Publish.self ) - @Argument(help: "Executable to package") - private var executable: String - /// Options controlling the locations of the source and destination images - struct RepositoryOptions: ParsableArguments { + struct RegistryOptions: ParsableArguments { @Option(help: "The default container registry to use when the image reference doesn't specify one") var defaultRegistry: String? + } + + struct SourceImageOptions: ParsableArguments { + @Option(help: "The base container image name and optional tag") + var from: String? + } + struct DestinationImageOptions: ParsableArguments { @Option(help: "The name and optional tag for the generated container image") var repository: String? @Option(help: "The tag for the generated container image") var tag: String? - - @Option(help: "The base container image name and optional tag") - var from: String? } - @OptionGroup(title: "Source and destination repository options") - var repositoryOptions: RepositoryOptions + struct DestinationArchiveOptions: ParsableArguments { + @Option(name: [.long, .short], help: "File in which the container image should be saved") + var output: URL + } /// Options controlling how the destination image is built struct ImageBuildOptions: ParsableArguments { @@ -54,9 +67,6 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti var resources: [String] = [] } - @OptionGroup(title: "Image build options") - var imageBuildOptions: ImageBuildOptions - // Options controlling the destination image's runtime configuration struct ImageConfigurationOptions: ParsableArguments { @Option(help: "CPU architecture") @@ -66,9 +76,6 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti var os: String? } - @OptionGroup(title: "Image configuration options") - var imageConfigurationOptions: ImageConfigurationOptions - /// Options controlling how containertool authenticates to registries struct AuthenticationOptions: ParsableArguments { @Option( @@ -130,104 +137,265 @@ enum AllowHTTP: String, ExpressibleByArgument, CaseIterable { case source, desti } } - @OptionGroup(title: "Authentication options") - var authenticationOptions: AuthenticationOptions + /// Publish an image to a container registry + struct Publish: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Publish a container image to a registry" + ) + + @Argument(help: "Executable to package") + private var executable: String - // General options + @OptionGroup(title: "Registry options") + var registryOptions: RegistryOptions - @Flag(name: .shortAndLong, help: "Verbose output") - private var verbose: Bool = false + @OptionGroup(title: "Source image options") + var sourceImageOptions: SourceImageOptions - func run() async throws { - // MARK: Apply defaults for unspecified configuration flags + @OptionGroup(title: "Destination image options") + var destinationImageOptions: DestinationImageOptions - let env = ProcessInfo.processInfo.environment + @OptionGroup(title: "Image build options") + var imageBuildOptions: ImageBuildOptions - let defaultRegistry = repositoryOptions.defaultRegistry ?? env["CONTAINERTOOL_DEFAULT_REGISTRY"] ?? "docker.io" - guard let repository = repositoryOptions.repository ?? env["CONTAINERTOOL_REPOSITORY"] else { - throw ValidationError( - "Please specify the destination repository using --repository or CONTAINERTOOL_REPOSITORY" - ) - } + @OptionGroup(title: "Image configuration options") + var imageConfigurationOptions: ImageConfigurationOptions + + @OptionGroup(title: "Authentication options") + var authenticationOptions: AuthenticationOptions + + // General options + + @Flag(name: .shortAndLong, help: "Verbose output") + private var verbose: Bool = false + + func run() async throws { + // MARK: Apply defaults for unspecified configuration flags - let username = authenticationOptions.defaultUsername ?? env["CONTAINERTOOL_DEFAULT_USERNAME"] - let password = authenticationOptions.defaultPassword ?? env["CONTAINERTOOL_DEFAULT_PASSWORD"] - let from = repositoryOptions.from ?? env["CONTAINERTOOL_BASE_IMAGE"] ?? "swift:slim" - let os = imageConfigurationOptions.os ?? env["CONTAINERTOOL_OS"] ?? "linux" - - // Try to detect the architecture of the application executable so a suitable base image can be selected. - // This reduces the risk of accidentally creating an image which stacks an aarch64 executable on top of an x86_64 base image. - let executableURL = URL(fileURLWithPath: executable) - let elfheader = try ELF.read(at: executableURL) - - let architecture = - imageConfigurationOptions.architecture - ?? env["CONTAINERTOOL_ARCHITECTURE"] - ?? elfheader?.ISA.containerArchitecture - ?? "amd64" - if verbose { log("Base image architecture: \(architecture)") } - - // MARK: Load netrc - - let authProvider: AuthorizationProvider? - if !authenticationOptions.netrc { - authProvider = nil - } else if let netrcFile = authenticationOptions.netrcFile { - guard FileManager.default.fileExists(atPath: netrcFile) else { - throw "\(netrcFile) not found" + let env = ProcessInfo.processInfo.environment + + let defaultRegistry = + registryOptions.defaultRegistry ?? env["CONTAINERTOOL_DEFAULT_REGISTRY"] ?? "docker.io" + guard let repository = destinationImageOptions.repository ?? env["CONTAINERTOOL_REPOSITORY"] else { + throw ValidationError( + "Please specify the destination repository using --repository or CONTAINERTOOL_REPOSITORY" + ) } - let customNetrc = URL(fileURLWithPath: netrcFile) - authProvider = try NetrcAuthorizationProvider(customNetrc) - } else { - let defaultNetrc = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".netrc") - authProvider = try NetrcAuthorizationProvider(defaultNetrc) - } - // MARK: Create registry clients + let username = authenticationOptions.defaultUsername ?? env["CONTAINERTOOL_DEFAULT_USERNAME"] + let password = authenticationOptions.defaultPassword ?? env["CONTAINERTOOL_DEFAULT_PASSWORD"] + let from = sourceImageOptions.from ?? env["CONTAINERTOOL_BASE_IMAGE"] ?? "swift:slim" + let os = imageConfigurationOptions.os ?? env["CONTAINERTOOL_OS"] ?? "linux" + + // Try to detect the architecture of the application executable so a suitable base image can be selected. + // This reduces the risk of accidentally creating an image which stacks an aarch64 executable on top of an x86_64 base image. + let executableURL = URL(fileURLWithPath: executable) + let elfheader = try ELF.read(at: executableURL) + + let architecture = + imageConfigurationOptions.architecture + ?? env["CONTAINERTOOL_ARCHITECTURE"] + ?? elfheader?.ISA.containerArchitecture + ?? "amd64" + if verbose { log("Base image architecture: \(architecture)") } + + // MARK: Load netrc + + let authProvider: AuthorizationProvider? + if !authenticationOptions.netrc { + authProvider = nil + } else if let netrcFile = authenticationOptions.netrcFile { + guard FileManager.default.fileExists(atPath: netrcFile) else { + throw "\(netrcFile) not found" + } + let customNetrc = URL(fileURLWithPath: netrcFile) + authProvider = try NetrcAuthorizationProvider(customNetrc) + } else { + let defaultNetrc = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".netrc") + authProvider = try NetrcAuthorizationProvider(defaultNetrc) + } - let baseImage = try ImageReference(fromString: from, defaultRegistry: defaultRegistry) - let destinationImage = try ImageReference(fromString: repository, defaultRegistry: defaultRegistry) + // MARK: Create registry clients + + let baseImage = try ImageReference(fromString: from, defaultRegistry: defaultRegistry) + let destinationImage = try ImageReference(fromString: repository, defaultRegistry: defaultRegistry) + + // 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: ImageSource + if from == "scratch" { + source = ScratchImage(architecture: architecture, os: os) + } else { + source = try await RegistryClient( + registry: baseImage.registry, + insecure: authenticationOptions.allowInsecureHttp == .source + || authenticationOptions.allowInsecureHttp == .both, + auth: .init(username: username, password: password, auth: authProvider) + ) + if verbose { log("Connected to source registry: \(baseImage.registry)") } + } - // 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: ImageSource - if from == "scratch" { - source = ScratchImage(architecture: architecture, os: os) - } else { - source = try await RegistryClient( - registry: baseImage.registry, - insecure: authenticationOptions.allowInsecureHttp == .source + let destination = try await RegistryClient( + registry: destinationImage.registry, + insecure: authenticationOptions.allowInsecureHttp == .destination || authenticationOptions.allowInsecureHttp == .both, auth: .init(username: username, password: password, auth: authProvider) ) - if verbose { log("Connected to source registry: \(baseImage.registry)") } + + if verbose { log("Connected to destination registry: \(destinationImage.registry)") } + if verbose { log("Using base image: \(baseImage)") } + + // MARK: Build the image + + let finalImage = try await publishContainerImage( + baseImage: baseImage, + destinationImage: destinationImage, + source: source, + destination: destination, + architecture: architecture, + os: os, + resources: imageBuildOptions.resources, + tag: destinationImageOptions.tag, + verbose: verbose, + executableURL: executableURL + ) + + print(finalImage) } + } - let destination = try await RegistryClient( - registry: destinationImage.registry, - insecure: authenticationOptions.allowInsecureHttp == .destination - || authenticationOptions.allowInsecureHttp == .both, - auth: .init(username: username, password: password, auth: authProvider) + /// Save an image to an archive file + struct Save: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Save a container image to an archive file" ) - if verbose { log("Connected to destination registry: \(destinationImage.registry)") } - if verbose { log("Using base image: \(baseImage)") } - - // MARK: Build the image - - let finalImage = try await publishContainerImage( - baseImage: baseImage, - destinationImage: destinationImage, - source: source, - destination: destination, - architecture: architecture, - os: os, - resources: imageBuildOptions.resources, - tag: repositoryOptions.tag, - verbose: verbose, - executableURL: executableURL - ) + @Argument(help: "Executable to package") + private var executable: String + + @OptionGroup(title: "Registry options") + var registryOptions: RegistryOptions + + @OptionGroup(title: "Source image options") + var sourceImageOptions: SourceImageOptions + + @OptionGroup(title: "Destination image options") + var destinationImageOptions: DestinationImageOptions + + @OptionGroup(title: "Destination archive options") + var destinationArchiveOptions: DestinationArchiveOptions + + @OptionGroup(title: "Image build options") + var imageBuildOptions: ImageBuildOptions + + @OptionGroup(title: "Image configuration options") + var imageConfigurationOptions: ImageConfigurationOptions + + @OptionGroup(title: "Authentication options") + var authenticationOptions: AuthenticationOptions + + // General options + + @Flag(name: .shortAndLong, help: "Verbose output") + private var verbose: Bool = false + + func run() async throws { + // MARK: Apply defaults for unspecified configuration flags + + let env = ProcessInfo.processInfo.environment + + let defaultRegistry = + registryOptions.defaultRegistry ?? env["CONTAINERTOOL_DEFAULT_REGISTRY"] ?? "docker.io" + guard let repository = destinationImageOptions.repository ?? env["CONTAINERTOOL_REPOSITORY"] else { + throw ValidationError( + "Please specify the destination repository using --repository or CONTAINERTOOL_REPOSITORY" + ) + } + + let username = authenticationOptions.defaultUsername ?? env["CONTAINERTOOL_DEFAULT_USERNAME"] + let password = authenticationOptions.defaultPassword ?? env["CONTAINERTOOL_DEFAULT_PASSWORD"] + let from = sourceImageOptions.from ?? env["CONTAINERTOOL_BASE_IMAGE"] ?? "swift:slim" + let os = imageConfigurationOptions.os ?? env["CONTAINERTOOL_OS"] ?? "linux" + + // Try to detect the architecture of the application executable so a suitable base image can be selected. + // This reduces the risk of accidentally creating an image which stacks an aarch64 executable on top of an x86_64 base image. + let executableURL = URL(fileURLWithPath: executable) + let elfheader = try ELF.read(at: executableURL) + + let architecture = + imageConfigurationOptions.architecture + ?? env["CONTAINERTOOL_ARCHITECTURE"] + ?? elfheader?.ISA.containerArchitecture + ?? "amd64" + if verbose { log("Base image architecture: \(architecture)") } + + // MARK: Load netrc + + let authProvider: AuthorizationProvider? + if !authenticationOptions.netrc { + authProvider = nil + } else if let netrcFile = authenticationOptions.netrcFile { + guard FileManager.default.fileExists(atPath: netrcFile) else { + throw "\(netrcFile) not found" + } + let customNetrc = URL(fileURLWithPath: netrcFile) + authProvider = try NetrcAuthorizationProvider(customNetrc) + } else { + let defaultNetrc = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".netrc") + authProvider = try NetrcAuthorizationProvider(defaultNetrc) + } + + // MARK: Create registry clients + + let baseImage = try ImageReference(fromString: from, defaultRegistry: defaultRegistry) + let destinationImage = try ImageReference(fromString: repository, defaultRegistry: defaultRegistry) + + // 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: ImageSource + if from == "scratch" { + source = ScratchImage(architecture: architecture, os: os) + } else { + source = try await RegistryClient( + registry: baseImage.registry, + insecure: authenticationOptions.allowInsecureHttp == .source + || authenticationOptions.allowInsecureHttp == .both, + auth: .init(username: username, password: password, auth: authProvider) + ) + if verbose { log("Connected to source registry: \(baseImage.registry)") } + } + + guard let saveStream = OutputStream(url: destinationArchiveOptions.output, append: false) else { + fatalError("failed to create tarball") + } + let destination = try TarImageDestination(toStream: saveStream) + + if verbose { log("Using base image: \(baseImage)") } + + // MARK: Build the image + + let finalImage = try await publishContainerImage( + baseImage: baseImage, + destinationImage: destinationImage, + source: source, + destination: destination, + architecture: architecture, + os: os, + resources: imageBuildOptions.resources, + tag: nil, + verbose: verbose, + executableURL: executableURL + ) + + print(finalImage) + } + } +} - print(finalImage) +// Parse URL path arguments +extension Foundation.URL: ArgumentParser.ExpressibleByArgument { + /// Construct a URL from an argument string + public init?(argument: String) { + self.init(fileURLWithPath: argument) } } diff --git a/Tests/TarTests/TarUnitTests.swift b/Tests/TarTests/TarUnitTests.swift index 531a3aa..95ad523 100644 --- a/Tests/TarTests/TarUnitTests.swift +++ b/Tests/TarTests/TarUnitTests.swift @@ -277,7 +277,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 0, 0, 0, 0, // mode: 8 bytes - 48, 48, 48, 53, 53, 53, 32, 0, + 48, 48, 48, 55, 53, 53, 32, 0, // uid: 8 bytes 48, 48, 48, 48, 48, 48, 32, 0, @@ -292,7 +292,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 32, // chksum: 8 bytes - 48, 49, 49, 48, 55, 53, 0, 32, + 48, 49, 49, 48, 55, 55, 0, 32, // typeflag: 1 byte 48, @@ -339,12 +339,13 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ] - @Test func testAppendingEmptyFile() async throws { - let archive = try Archive().appendingFile(name: "emptyfile", data: []).bytes + @Test func testAppendEmptyFile() async throws { + let archive = Archive() + try archive.appendFile(name: "emptyfile", data: []) // Expecting: member header, no file content, 2-block end of archive marker - #expect(archive.count == headerSize + trailerSize) - #expect(archive == emptyFile + trailer) + #expect(archive.bytes.count == headerSize + trailerSize) + #expect(archive.bytes == emptyFile + trailer) } let helloFile: [UInt8] = @@ -359,7 +360,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 0, 0, 0, 0, // mode: 8 bytes - 48, 48, 48, 53, 53, 53, 32, 0, + 48, 48, 48, 55, 53, 53, 32, 0, // uid: 8 bytes 48, 48, 48, 48, 48, 48, 32, 0, @@ -374,7 +375,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 32, // chksum: 8 bytes - 48, 49, 49, 48, 52, 55, 0, 32, + 48, 49, 49, 48, 53, 49, 0, 32, // typeflag: 1 byte 48, @@ -456,21 +457,12 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) ] @Test func testAppendFile() async throws { - var archive = Archive() + let archive = Archive() try archive.appendFile(name: "hellofile", data: [UInt8]("hello".utf8)) - let output = archive.bytes // Expecting: member header, file content, 2-block end of archive marker - #expect(output.count == headerSize + blockSize + trailerSize) - #expect(output == helloFile + trailer) - } - - @Test func testAppendingFile() async throws { - let archive = try Archive().appendingFile(name: "hellofile", data: [UInt8]("hello".utf8)).bytes - - // Expecting: member header, file content, 2-block end of archive marker - #expect(archive.count == headerSize + blockSize + trailerSize) - #expect(archive == helloFile + trailer) + #expect(archive.bytes.count == headerSize + blockSize + trailerSize) + #expect(archive.bytes == helloFile + trailer) } let directoryWithPrefix: [UInt8] = [ @@ -484,7 +476,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 0, 0, 0, 0, // mode: 8 bytes - 48, 48, 48, 53, 53, 53, 32, 0, + 48, 48, 48, 55, 53, 53, 32, 0, // uid: 8 bytes 48, 48, 48, 48, 48, 48, 32, 0, @@ -499,7 +491,7 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 48, 32, // chksum: 8 bytes - 48, 49, 50, 51, 50, 54, 0, 32, + 48, 49, 50, 51, 51, 48, 0, 32, // typeflag: 1 byte 53, @@ -547,47 +539,35 @@ let trailer = [UInt8](repeating: 0, count: trailerSize) ] @Test func testAppendDirectory() async throws { - var archive = Archive() + let archive = Archive() try archive.appendDirectory(name: "directory", prefix: "prefix") - let output = archive.bytes - - // Expecting: member header, no content, 2-block end of archive marker - #expect(output.count == headerSize + trailerSize) - #expect(output == directoryWithPrefix + trailer) - } - - @Test func testAppendingDirectory() async throws { - let archive = try Archive().appendingDirectory(name: "directory", prefix: "prefix").bytes // Expecting: member header, no content, 2-block end of archive marker - #expect(archive.count == headerSize + trailerSize) - #expect(archive == directoryWithPrefix + trailer) + #expect(archive.bytes.count == headerSize + trailerSize) + #expect(archive.bytes == directoryWithPrefix + trailer) } @Test func testAppendFilesAndDirectories() async throws { - var archive = Archive() + let archive = Archive() try archive.appendFile(name: "hellofile", data: [UInt8]("hello".utf8)) try archive.appendFile(name: "emptyfile", data: [UInt8]()) try archive.appendDirectory(name: "directory", prefix: "prefix") - let output = archive.bytes - // Expecting: file member header, file content, file member header, no file content, // directory member header, 2-block end of archive marker - #expect(output.count == headerSize + blockSize + headerSize + headerSize + trailerSize) - #expect(output == helloFile + emptyFile + directoryWithPrefix + trailer) + #expect(archive.bytes.count == headerSize + blockSize + headerSize + headerSize + trailerSize) + #expect(archive.bytes == helloFile + emptyFile + directoryWithPrefix + trailer) } @Test func testAppendingFilesAndDirectories() async throws { - let archive = try Archive() - .appendingFile(name: "hellofile", data: [UInt8]("hello".utf8)) - .appendingFile(name: "emptyfile", data: [UInt8]()) - .appendingDirectory(name: "directory", prefix: "prefix") - .bytes + let archive = Archive() + try archive.appendFile(name: "hellofile", data: [UInt8]("hello".utf8)) + try archive.appendFile(name: "emptyfile", data: [UInt8]()) + try archive.appendDirectory(name: "directory", prefix: "prefix") // Expecting: file member header, file content, file member header, no file content, // directory member header, 2-block end of archive marker - #expect(archive.count == headerSize + blockSize + headerSize + headerSize + trailerSize) - #expect(archive == helloFile + emptyFile + directoryWithPrefix + trailer) + #expect(archive.bytes.count == headerSize + blockSize + headerSize + headerSize + trailerSize) + #expect(archive.bytes == helloFile + emptyFile + directoryWithPrefix + trailer) } }