-
Notifications
You must be signed in to change notification settings - Fork 717
Add --all-tags flag to image push #1335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,9 @@ | |
| import ArgumentParser | ||
| import ContainerAPIClient | ||
| import Containerization | ||
| import ContainerizationError | ||
| import ContainerizationOCI | ||
| import Foundation | ||
| import TerminalProgress | ||
|
|
||
| extension Application { | ||
|
|
@@ -33,8 +35,11 @@ extension Application { | |
| @OptionGroup | ||
| var progressFlags: Flags.Progress | ||
|
|
||
| @OptionGroup | ||
| var imageUploadFlags: Flags.ImageUpload | ||
|
|
||
| @Option( | ||
| name: .shortAndLong, | ||
| name: .long, | ||
| help: "Limit the push to the specified architecture" | ||
| ) | ||
| var arch: String? | ||
|
|
@@ -47,17 +52,40 @@ 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: .shortAndLong, help: "Push all tags of an image") | ||
| var allTags: Bool = false | ||
|
|
||
| @OptionGroup | ||
| public var logOptions: Flags.Logging | ||
|
|
||
| @Argument var reference: String | ||
|
|
||
| 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/-a") | ||
| } | ||
| if ref.digest != nil { | ||
| throw ContainerizationError(.invalidArgument, message: "digest can't be used with --all-tags/-a") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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,71 @@ 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") | ||
| } | ||
|
|
||
| // Enumerate matching tags for display before pushing. | ||
| let allImages = try await ClientImage.list() | ||
| let normalized = try ClientImage.normalizeReference(reference) | ||
| let parsedRef = try Reference.parse(normalized) | ||
| let repoName: String | ||
| if let resolved = parsedRef.resolvedDomain { | ||
| repoName = "\(resolved)/\(parsedRef.path)" | ||
| } else { | ||
| repoName = parsedRef.name | ||
| } | ||
|
|
||
| let matchingTags = allImages.filter { img in | ||
| guard !Utility.isInfraImage(name: img.reference) else { return false } | ||
| guard let ref = try? Reference.parse(img.reference) else { return false } | ||
| let resolvedName: String | ||
| if let resolved = ref.resolvedDomain { | ||
| resolvedName = "\(resolved)/\(ref.path)" | ||
| } else { | ||
| resolvedName = ref.name | ||
| } | ||
| return resolvedName == repoName | ||
| } | ||
|
|
||
| let displayRepo = try ClientImage.denormalizeReference(normalized) | ||
| let displayName = try Reference.parse(displayRepo).name | ||
| print("The push refers to repository [\(displayName)]") | ||
|
Comment on lines
+118
to
+121
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know if this is super necessary since we get back a list of images we pushed
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. only downside is the header would print after the push instead of before |
||
|
|
||
| var progressConfig: ProgressConfig | ||
| switch self.progressFlags.progress { | ||
| case .none: progressConfig = try ProgressConfig(disableProgressUpdates: true) | ||
| case .ansi: | ||
| progressConfig = try ProgressConfig( | ||
| description: "Pushing \(matchingTags.count) tags", | ||
| showPercent: false, | ||
| showItems: false, | ||
| showSpeed: false, | ||
| ignoreSmallSize: true | ||
| ) | ||
| } | ||
|
|
||
| let progress = ProgressBar(config: progressConfig) | ||
| defer { | ||
| progress.finish() | ||
| } | ||
| progress.start() | ||
| try await ClientImage.pushAllTags( | ||
| reference: reference, platform: platform, scheme: scheme, | ||
| maxConcurrentUploads: imageUploadFlags.maxConcurrentUploads, progressUpdate: progress.handler) | ||
| progress.finish() | ||
|
|
||
| let formatter = ByteCountFormatter() | ||
| for img in matchingTags { | ||
| let tag = (try? Reference.parse(img.reference))?.tag ?? "<none>" | ||
| let size = formatter.string(fromByteCount: img.descriptor.size) | ||
| print("\(tag): digest: \(img.descriptor.digest) size: \(size)") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -283,6 +283,48 @@ extension ClientImage { | |
| return image | ||
| } | ||
|
|
||
| public static func pushAllTags( | ||
| reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, maxConcurrentUploads: Int = 3, progressUpdate: ProgressUpdateHandler? = nil | ||
| ) async throws { | ||
| 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 | ||
| } | ||
|
Comment on lines
+298
to
+303
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Looks like this is being repeated at couple of places. I think moving this to a package level function inside Utility would make sense. |
||
|
|
||
| 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) | ||
| } | ||
|
|
||
| _ = try await client.send(request) | ||
| await progressUpdateClient?.finish() | ||
| } | ||
|
|
||
| public static func delete(reference: String, garbageCollect: Bool = false) async throws { | ||
| let client = newXPCClient() | ||
| let request = newRequest(.imageDelete) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,6 +136,92 @@ public actor ImagesService { | |
| } | ||
| } | ||
|
|
||
| public func pushAllTags(repositoryName: String, platform: Platform?, insecure: Bool, maxConcurrentUploads: Int, progressUpdate: ProgressUpdateHandler?) async throws { | ||
realrajaryan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| 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 | ||
| } | ||
|
Comment on lines
+165
to
+174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This would be a good candidate which can be moved to Utility. |
||
|
|
||
| guard !matchingImages.isEmpty else { | ||
| throw ContainerizationError(.notFound, message: "no tags found for repository \(repositoryName)") | ||
| } | ||
|
|
||
| let maxConcurrent = maxConcurrentUploads > 0 ? maxConcurrentUploads : 3 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This silent fallback behavior is different than what's in ClientImage (which has a |
||
|
|
||
| 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..<maxConcurrent { | ||
| guard let image = iterator.next() else { break } | ||
| let ref = image.reference | ||
| group.addTask { | ||
| do { | ||
| try await self.imageStore.push( | ||
| reference: ref, platform: platform, insecure: insecure, auth: auth, progress: progress) | ||
| return (ref, nil) | ||
| } catch { | ||
| return (ref, String(describing: error)) | ||
| } | ||
| } | ||
| } | ||
| for await (ref, error) in group { | ||
| if let error { | ||
| failures.append((ref, error)) | ||
| } | ||
| if let image = iterator.next() { | ||
| let nextRef = image.reference | ||
| group.addTask { | ||
| do { | ||
| try await self.imageStore.push( | ||
| reference: nextRef, platform: platform, insecure: insecure, auth: auth, progress: progress) | ||
| return (nextRef, nil) | ||
| } catch { | ||
| return (nextRef, String(describing: error)) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if !failures.isEmpty { | ||
| let details = failures.map { "\($0.reference): \($0.message)" }.joined(separator: "\n") | ||
| throw ContainerizationError(.internalError, message: "failed to push one or more tags:\n\(details)") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public func tag(old: String, new: String) async throws -> ImageDescription { | ||
| self.log.debug( | ||
| "ImagesService: enter", | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.