diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index 60e8bf803..9e58b165a 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -17,7 +17,9 @@ import ArgumentParser import ContainerAPIClient import Containerization +import ContainerizationError import ContainerizationOCI +import Foundation import TerminalProgress extension Application { @@ -33,6 +35,9 @@ extension Application { @OptionGroup var progressFlags: Flags.Progress + @OptionGroup + var imageUploadFlags: Flags.ImageUpload + @Option( name: .shortAndLong, help: "Limit the push to the specified architecture" @@ -47,6 +52,9 @@ extension Application { @Option(help: "Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]") var platform: String? + @Flag(name: .long, help: "Push all tags of an image") + var allTags: Bool = false + @OptionGroup public var logOptions: Flags.Logging @@ -54,10 +62,30 @@ extension Application { public init() {} + public func validate() throws { + if allTags { + let ref = try Reference.parse(reference) + if ref.tag != nil { + throw ContainerizationError(.invalidArgument, message: "tag can't be used with --all-tags") + } + if ref.digest != nil { + throw ContainerizationError(.invalidArgument, message: "digest can't be used with --all-tags") + } + } + } + public func run() async throws { let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) - let scheme = try RequestScheme(registry.scheme) + + if allTags { + try await pushAllTags(platform: p, scheme: scheme) + } else { + try await pushSingle(platform: p, scheme: scheme) + } + } + + private func pushSingle(platform: Platform?, scheme: RequestScheme) async throws { let image = try await ClientImage.get(reference: reference) var progressConfig: ProgressConfig @@ -78,8 +106,49 @@ extension Application { progress.finish() } progress.start() - _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) + try await image.push(platform: platform, scheme: scheme, progressUpdate: progress.handler) + progress.finish() + } + + private func pushAllTags(platform: Platform?, scheme: RequestScheme) async throws { + if self.platform != nil || arch != nil || os != nil { + log.warning("--platform/--arch/--os with --all-tags filters each tag push to the specified platform; tags without matching manifests may fail") + } + + let normalized = try ClientImage.normalizeReference(reference) + let displayRepo = try ClientImage.denormalizeReference(normalized) + let displayName = try Reference.parse(displayRepo).name + print("The push refers to repository [\(displayName)]") + + var progressConfig: ProgressConfig + switch self.progressFlags.progress { + case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) + case .ansi: + progressConfig = try ProgressConfig( + description: "Pushing tags", + showPercent: false, + showItems: false, + showSpeed: false, + ignoreSmallSize: true + ) + } + + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + let pushed = try await ClientImage.pushAllTags( + reference: reference, platform: platform, scheme: scheme, + maxConcurrentUploads: imageUploadFlags.maxConcurrentUploads, progressUpdate: progress.handler) progress.finish() + + let formatter = ByteCountFormatter() + for img in pushed { + let tag = (try? Reference.parse(img.reference))?.tag ?? "" + let size = formatter.string(fromByteCount: img.descriptor.size) + print("\(tag): digest: \(img.descriptor.digest) size: \(size)") + } } } } diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift index 42b242c8b..322a93ca3 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -283,6 +283,54 @@ extension ClientImage { return image } + @discardableResult + public static func pushAllTags( + reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, maxConcurrentUploads: Int = 3, progressUpdate: ProgressUpdateHandler? = nil + ) async throws -> [ClientImage] { + guard maxConcurrentUploads > 0 else { + throw ContainerizationError(.invalidArgument, message: "maximum number of concurrent uploads must be greater than 0, got \(maxConcurrentUploads)") + } + + // Normalize the reference, then extract the repository name without the tag. + let normalized = try Self.normalizeReference(reference) + let parsedRef = try Reference.parse(normalized) + + let repositoryName: String + if let resolved = parsedRef.resolvedDomain { + repositoryName = "\(resolved)/\(parsedRef.path)" + } else { + repositoryName = parsedRef.name + } + + guard let host = parsedRef.domain else { + throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(normalized)") + } + + let client = newXPCClient() + let request = newRequest(.imagePush) + + request.set(key: .imageRepository, value: repositoryName) + request.set(key: .allTags, value: true) + try request.set(platform: platform) + + let insecure = try scheme.schemeFor(host: host) == .http + request.set(key: .insecureFlag, value: insecure) + request.set(key: .maxConcurrentUploads, value: Int64(maxConcurrentUploads)) + + var progressUpdateClient: ProgressUpdateClient? + if let progressUpdate { + progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) + } + + let response = try await client.send(request) + await progressUpdateClient?.finish() + + let imageDescriptions = try response.imageDescriptions() + return imageDescriptions.map { desc in + ClientImage(description: desc) + } + } + public static func delete(reference: String, garbageCollect: Bool = false) async throws { let client = newXPCClient() let request = newRequest(.imageDelete) diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 88de209f9..6a7d97960 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -353,4 +353,15 @@ public struct Flags { @Option(name: .long, help: "Maximum number of concurrent downloads (default: 3)") public var maxConcurrentDownloads: Int = 3 } + + public struct ImageUpload: ParsableArguments { + public init() {} + + public init(maxConcurrentUploads: Int) { + self.maxConcurrentUploads = maxConcurrentUploads + } + + @Option(name: .long, help: ArgumentHelp("Maximum number of concurrent uploads with --all-tags", valueName: "count")) + public var maxConcurrentUploads: Int = 3 + } } diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift index c087f99f1..d40402a52 100644 --- a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift @@ -36,6 +36,9 @@ public enum ImagesServiceXPCKeys: String { case insecureFlag case garbageCollect case maxConcurrentDownloads + case maxConcurrentUploads + case allTags + case imageRepository case forceLoad case rejectedMembers diff --git a/Sources/Services/ContainerImagesService/Server/ImagesService.swift b/Sources/Services/ContainerImagesService/Server/ImagesService.swift index 2fb06ed1c..f96309fc4 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesService.swift @@ -136,6 +136,96 @@ public actor ImagesService { } } + public func pushAllTags(repositoryName: String, platform: Platform?, insecure: Bool, maxConcurrentUploads: Int, progressUpdate: ProgressUpdateHandler?) async throws + -> [ImageDescription] + { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "repositoryName": "\(repositoryName)", + "platform": "\(String(describing: platform))", + "insecure": "\(insecure)", + "maxConcurrentUploads": "\(maxConcurrentUploads)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "repositoryName": "\(repositoryName)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let allImages = try await imageStore.list() + let matchingImages = allImages.filter { image in + guard !Utility.isInfraImage(name: image.reference) else { return false } + guard let ref = try? Reference.parse(image.reference) else { return false } + let resolvedName: String + if let resolved = ref.resolvedDomain { + resolvedName = "\(resolved)/\(ref.path)" + } else { + resolvedName = ref.name + } + return resolvedName == repositoryName + } + + guard !matchingImages.isEmpty else { + throw ContainerizationError(.notFound, message: "no tags found for repository \(repositoryName)") + } + + let maxConcurrent = maxConcurrentUploads > 0 ? maxConcurrentUploads : 3 + + try await Self.withAuthentication(ref: repositoryName) { auth in + let progress = ContainerizationProgressAdapter.handler(from: progressUpdate) + var iterator = matchingImages.makeIterator() + var failures: [(reference: String, message: String)] = [] + + await withTaskGroup(of: (String, String?).self) { group in + for _ in 0.. ImageDescription { self.log.debug( "ImagesService: enter", diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index e88b2368f..b7a589934 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -62,22 +62,42 @@ public struct ImagesServiceHarness: Sendable { @Sendable public func push(_ message: XPCMessage) async throws -> XPCMessage { - let ref = message.string(key: .imageReference) - guard let ref else { - throw ContainerizationError( - .invalidArgument, - message: "missing image reference" - ) - } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil if let platformData { platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) } let insecure = message.bool(key: .insecureFlag) + let allTags = message.bool(key: .allTags) let progressUpdateService = ProgressUpdateService(message: message) - try await service.push(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) + if allTags { + let repository = message.string(key: .imageRepository) + guard let repository else { + throw ContainerizationError( + .invalidArgument, + message: "missing image repository" + ) + } + let maxConcurrentUploads = message.int64(key: .maxConcurrentUploads) + let pushed = try await service.pushAllTags( + repositoryName: repository, platform: platform, insecure: insecure, + maxConcurrentUploads: Int(maxConcurrentUploads), progressUpdate: progressUpdateService?.handler) + + let reply = message.reply() + let imageData = try JSONEncoder().encode(pushed) + reply.set(key: .imageDescriptions, value: imageData) + return reply + } else { + let ref = message.string(key: .imageReference) + guard let ref else { + throw ContainerizationError( + .invalidArgument, + message: "missing image reference" + ) + } + try await service.push(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) + } let reply = message.reply() return reply diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index a2b71c20e..8fba3a1be 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -311,6 +311,49 @@ class TestCLIImagesCommand: CLITest { "Expected validation error message in output") } + @Test func testAllTagsRejectsTaggedReference() throws { + let (_, _, error, status) = try run(arguments: [ + "image", + "push", + "--all-tags", + "alpine:latest", + ]) + + #expect(status != 0, "Expected --all-tags with a tag to fail") + #expect( + error.contains("tag can't be used with --all-tags"), + "Expected tag validation error message in output") + } + + @Test func testAllTagsRejectsDigestReference() throws { + let (_, _, error, status) = try run(arguments: [ + "image", + "push", + "--all-tags", + "alpine@sha256:0000000000000000000000000000000000000000000000000000000000000000", + ]) + + #expect(status != 0, "Expected --all-tags with a digest to fail") + #expect( + error.contains("digest can't be used with --all-tags"), + "Expected digest validation error message in output") + } + + @Test func testMaxConcurrentUploadsValidation() throws { + let (_, _, error, status) = try run(arguments: [ + "image", + "push", + "--all-tags", + "--max-concurrent-uploads", "0", + "alpine", + ]) + + #expect(status != 0, "Expected command to fail with maxConcurrentUploads=0") + #expect( + error.contains("maximum number of concurrent uploads must be greater than 0"), + "Expected validation error message in output") + } + @Test func testImageLoadRejectsInvalidMembersWithoutForce() throws { do { // 0. Generate unique malicious filename for this test run diff --git a/docs/command-reference.md b/docs/command-reference.md index 7aea2a329..992a3a98a 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -522,17 +522,19 @@ Pushes an image to a registry. The flags mirror those for `image pull` with the **Usage** ```bash -container image push [--scheme ] [--progress ] [--arch ] [--os ] [--platform ] [--debug] +container image push [--scheme ] [--progress ] [--arch ] [--os ] [--platform ] [--all-tags] [--max-concurrent-uploads ] [--debug] ``` **Arguments** -* ``: Image reference to push +* ``: Image reference to push. When using `--all-tags`, this should be a repository name without a tag. **Options** * `--scheme `: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto) * `--progress `: Progress type (format: none|ansi) (default: ansi) +* `--all-tags`: Push all tags of an image +* `--max-concurrent-uploads `: Maximum number of concurrent uploads with --all-tags (default: 3) * `-a, --arch `: Limit the push to the specified architecture * `--os `: Limit the push to the specified OS * `--platform `: Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch)