Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions Sources/ContainerCommands/Image/ImagePush.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
import ArgumentParser
import ContainerAPIClient
import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation
import TerminalProgress

extension Application {
Expand All @@ -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"
Expand All @@ -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: .long, 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")
}
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
Expand All @@ -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)]")
Comment on lines +118 to +121
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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


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 ?? "<none>"
let size = formatter.string(fromByteCount: img.descriptor.size)
print("\(tag): digest: \(img.descriptor.digest) size: \(size)")
}
}
}
}
48 changes: 48 additions & 0 deletions Sources/Services/ContainerAPIService/Client/ClientImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +298 to +303
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)
}

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)
Expand Down
11 changes: 11 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public enum ImagesServiceXPCKeys: String {
case insecureFlag
case garbageCollect
case maxConcurrentDownloads
case maxConcurrentUploads
case allTags
case imageRepository
case forceLoad
case rejectedMembers

Expand Down
90 changes: 90 additions & 0 deletions Sources/Services/ContainerImagesService/Server/ImagesService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,96 @@ public actor ImagesService {
}
}

public func pushAllTags(repositoryName: String, platform: Platform?, insecure: Bool, maxConcurrentUploads: Int, progressUpdate: ProgressUpdateHandler?) async throws
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we should instead move the logic to ImageStore.ExportOperation to be similar to how ImageStore.pull works. What do you think?

-> [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
}
Comment on lines +165 to +174
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 guard). I would keep the same behavior to be consistent.


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)")
}
}

return matchingImages.map { $0.description.fromCZ }
}

public func tag(old: String, new: String) async throws -> ImageDescription {
self.log.debug(
"ImagesService: enter",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading