From 98402bf4e6c2b4738fe1367078c14c36985833d4 Mon Sep 17 00:00:00 2001 From: J Logan Date: Tue, 9 Sep 2025 16:46:18 -0700 Subject: [PATCH] Adds multiple image save to tarfile. (#560) ## Type of Change - [ ] Bug fix - [x] New feature - [ ] Breaking change - [ ] Documentation update ## Description ```bash % container image save -o container.tar python:alpine alpine:latest Warning! Running debug build. Performance may be degraded. Image(s) saved % tar tf container.tar oci-layout blobs/ blobs/sha256/ blobs/sha256/02f8efbefad605a169e89926147edd0676646263268f303c6fb3cdfdbc4a9612 blobs/sha256/a4bb08daca6b0385b17761b170fc91b20ab2ec072f70f9260149f8d61846ac13 blobs/sha256/588270f913bc82b4dbeee27bc249e4d314894becd18cccdc13645f669972c91e blobs/sha256/692b7bac6678f5809640189eb1d95a3277689ec643201c465ecd44e72db7d029 blobs/sha256/1d24a57b1de9b287d9a9e1e231b71a235b836dc1852155b943b927a411d8c394 blobs/sha256/f9841e55dcbf5a6fcc702b25ce6e411ebdcb30680f94afd8060cb20bb20bd75c blobs/sha256/0b83d017db6efafadf6b3f18d087d2ce1d67d8f0e927dc7254b0ad088074cd3a blobs/sha256/b2236d9e1563c507613962c1ebbd7b3d307969ec2ee355b781b68440f4f0bee3 blobs/sha256/6e174226ea690ced550e5641249a412cdbefd2d09871f3e64ab52137a54ba606 blobs/sha256/c879780ac011609647c8714eef9e6490c42bf20128b32f4b31c4daad1242647b blobs/sha256/26a1da51444d4cbbba3233caa342d0397ac4f93dc8e305e31989fd782b3107da index.json % tar -xOf container.tar index.json | python3 -m json.tool { "schemaVersion": 2, "mediaType": "application/vnd.oci.image.index.v1+json", "manifests": [ { "mediaType": "application/vnd.oci.image.index.v1+json", "annotations": { "org.opencontainers.image.ref.name": "docker.io/library/python:alpine", "io.containerd.image.name": "docker.io/library/python:alpine", "com.apple.containerization.image.name": "docker.io/library/python:alpine" }, "size": 944, "digest": "sha256:1d24a57b1de9b287d9a9e1e231b71a235b836dc1852155b943b927a411d8c394" }, { "mediaType": "application/vnd.oci.image.index.v1+json", "annotations": { "io.containerd.image.name": "docker.io/library/alpine:latest", "org.opencontainers.image.ref.name": "docker.io/library/alpine:latest", "com.apple.containerization.image.name": "docker.io/library/alpine:latest" }, "size": 497, "digest": "sha256:692b7bac6678f5809640189eb1d95a3277689ec643201c465ecd44e72db7d029" } ] } ``` ## Motivation and Context `image load` can read multiple images from a tar file, but `image save` cannot save multiple images today. ## Testing - [x] Tested locally - [ ] Added/updated tests (TODO: roundtrip test pull-save-rm-load) - [ ] Added/updated docs (TODO: check command reference) --- Sources/CLI/Image/ImageSave.swift | 25 ++++++++++--- .../ContainerClient/Core/ClientImage.swift | 25 ++++++++----- .../Server/ImageService.swift | 6 ++-- .../Server/ImagesServiceHarness.swift | 7 ++-- .../Subcommands/Images/TestCLIImages.swift | 36 +++++++++++++------ 5 files changed, 69 insertions(+), 30 deletions(-) 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