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)
This commit is contained in:
J Logan
2025-09-09 16:46:18 -07:00
committed by GitHub
parent be89df833b
commit 98402bf4e6
5 changed files with 69 additions and 30 deletions
+20 -5
View File
@@ -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)
}
}
}
}
+16 -9
View File
@@ -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)
@@ -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()
@@ -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
}
@@ -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