diff --git a/Sources/CLI/Image/ImageSave.swift b/Sources/CLI/Image/ImageSave.swift index 59971b58..a591f731 100644 --- a/Sources/CLI/Image/ImageSave.swift +++ b/Sources/CLI/Image/ImageSave.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerClient import Containerization +import ContainerizationError import ContainerizationOCI import Foundation import TerminalProgress @@ -54,7 +55,7 @@ extension Application { }) var output: String - @Argument var reference: String + @Argument var references: [String] func run() async throws { var p: Platform? @@ -65,7 +66,7 @@ extension Application { } let progressConfig = try ProgressConfig( - description: "Saving image" + description: "Saving image(s)" ) let progress = ProgressBar(config: progressConfig) defer { @@ -73,11 +74,25 @@ extension Application { } progress.start() - let image = try await ClientImage.get(reference: reference) - try await image.save(out: output, platform: p) + var images: [ImageDescription] = [] + for reference in references { + do { + images.append(try await ClientImage.get(reference: reference).description) + } catch { + print("failed to get image for reference \(reference): \(error)") + } + } + + guard images.count == references.count else { + throw ContainerizationError(.invalidArgument, message: "failed to save image(s)") + + } + try await ClientImage.save(references: references, out: output, platform: p) progress.finish() - print("Image saved") + for reference in references { + print(reference) + } } } } diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift index 7cdf312a..27ad39fe 100644 --- a/Sources/ContainerClient/Core/ClientImage.swift +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -256,6 +256,22 @@ extension ClientImage { let _ = try await client.send(request) } + public static func save(references: [String], out: String, platform: Platform? = nil) async throws { + let (clientImages, errors) = try await get(names: references) + guard errors.isEmpty else { + // TODO: Improve error handling here + throw ContainerizationError(.invalidArgument, message: "one or more image references are invalid: \(errors.joined(separator: ", "))") + } + + let descriptions = clientImages.map { $0.description } + let client = Self.newXPCClient() + let request = Self.newRequest(.imageSave) + try request.set(descriptions: descriptions) + request.set(key: .filePath, value: out) + try request.set(platform: platform) + let _ = try await client.send(request) + } + public static func load(from tarFile: String) async throws -> [ClientImage] { let client = newXPCClient() let request = newRequest(.imageLoad) @@ -334,15 +350,6 @@ extension ClientImage { // MARK: Snapshot Methods - public func save(out: String, platform: Platform? = nil) async throws { - let client = Self.newXPCClient() - let request = Self.newRequest(.imageSave) - try request.set(description: self.description) - request.set(key: .filePath, value: out) - try request.set(platform: platform) - let _ = try await client.send(request) - } - public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imageUnpack) diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift index 5dd71b2a..59e5ef77 100644 --- a/Sources/Services/ContainerImagesService/Server/ImageService.swift +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -92,13 +92,13 @@ public actor ImagesService { try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect) } - public func save(reference: String, out: URL, platform: Platform?) async throws { - self.log.info("ImagesService: \(#function) - reference: \(reference) , platform: \(String(describing: platform))") + public func save(references: [String], out: URL, platform: Platform?) async throws { + self.log.info("ImagesService: \(#function) - references: \(references) , platform: \(String(describing: platform))") let tempDir = FileManager.default.uniqueTemporaryDirectory() defer { try? FileManager.default.removeItem(at: tempDir) } - try await self.imageStore.save(references: [reference], out: tempDir, platform: platform) + try await self.imageStore.save(references: references, out: tempDir, platform: platform) let writer = try ArchiveWriter(format: .pax, filter: .none, file: out) try writer.archiveDirectory(tempDir) try writer.finishEncoding() diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift index 5cac3af8..44d6704f 100644 --- a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -129,14 +129,15 @@ public struct ImagesServiceHarness: Sendable { @Sendable public func save(_ message: XPCMessage) async throws -> XPCMessage { - let data = message.dataNoCopy(key: .imageDescription) + let data = message.dataNoCopy(key: .imageDescriptions) guard let data else { throw ContainerizationError( .invalidArgument, message: "missing image description" ) } - let imageDescription = try JSONDecoder().decode(ImageDescription.self, from: data) + let imageDescriptions = try JSONDecoder().decode([ImageDescription].self, from: data) + let references = imageDescriptions.map { $0.reference } let platformData = message.dataNoCopy(key: .ociPlatform) var platform: Platform? = nil @@ -150,7 +151,7 @@ public struct ImagesServiceHarness: Sendable { message: "missing output file path" ) } - try await service.save(reference: imageDescription.reference, out: URL(filePath: out), platform: platform) + try await service.save(references: references, out: URL(filePath: out), platform: platform) let reply = message.reply() return reply } diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift index 2b2d4424..a0d7a197 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerizationOCI import Foundation import Testing @@ -173,7 +174,8 @@ extension TestCLIImagesCommand { #expect(imagePulled, "expected to see image \(alpine) pulled") // tag image so we can safely remove later - let alpineTagged = "\(alpine.dropLast("3.21".count))testPullRemoveSingle" + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testPullRemoveSingle" try doImageTag(image: alpine, newName: alpineTagged) let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") @@ -190,7 +192,8 @@ extension TestCLIImagesCommand { @Test func testImageTag() throws { do { try doPull(imageName: alpine) - let alpineTagged = "\(alpine.dropLast("3.21".count))testImageTag" + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageTag" try doImageTag(image: alpine, newName: alpineTagged) let imagePresent = try isImagePresent(targetImage: alpineTagged) #expect(imagePresent, "expected to see image \(alpineTagged) tagged") @@ -235,12 +238,20 @@ extension TestCLIImagesCommand { do { // 1. pull image try doPull(imageName: alpine) + try doPull(imageName: busybox) // 2. Tag image so we can safely remove later - let alpineTagged = "\(alpine.dropLast("3.21".count))testImageSaveAndLoad" + let alpineRef: Reference = try Reference.parse(alpine) + let alpineTagged = "\(alpineRef.name):testImageSaveAndLoad" try doImageTag(image: alpine, newName: alpineTagged) - let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") + let alpineTaggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(alpineTaggedImagePresent, "expected to see image \(alpineTagged) tagged") + + let busyboxRef: Reference = try Reference.parse(busybox) + let busyboxTagged = "\(busyboxRef.name):testImageSaveAndLoad" + try doImageTag(image: busybox, newName: busyboxTagged) + let busyboxTaggedImagePresent = try isImagePresent(targetImage: busyboxTagged) + #expect(busyboxTaggedImagePresent, "expected to see image \(busyboxTagged) tagged") // 3. save the image as a tarball let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) @@ -253,6 +264,7 @@ extension TestCLIImagesCommand { "images", "save", alpineTagged, + busyboxTagged, "--output", tempFile.path(), ] @@ -262,11 +274,13 @@ extension TestCLIImagesCommand { } // 4. remove the image through container - try doRemoveImages(images: [alpineTagged]) + try doRemoveImages(images: [alpineTagged, busyboxTagged]) // 5. verify image is no longer present - let imageRemoved = try !isImagePresent(targetImage: alpineTagged) - #expect(imageRemoved, "expected image \(alpineTagged) to be removed") + let alpineImageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(alpineImageRemoved, "expected image \(alpineTagged) to be removed") + let busyboxImageRemoved = try !isImagePresent(targetImage: busyboxTagged) + #expect(busyboxImageRemoved, "expected image \(busyboxTagged) to be removed") // 6. load the tarball let loadArgs = [ @@ -281,8 +295,10 @@ extension TestCLIImagesCommand { } // 7. verify image is in the list again - let imagePresent = try isImagePresent(targetImage: alpineTagged) - #expect(imagePresent, "expected \(alpineTagged) to be present") + let alpineImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(alpineImagePresent, "expected \(alpineTagged) to be present") + let busyboxImagePresent = try isImagePresent(targetImage: busyboxTagged) + #expect(busyboxImagePresent, "expected \(busyboxTagged) to be present") } catch { Issue.record("failed to save and load image \(error)") return