mirror of
https://github.com/apple/container.git
synced 2026-08-28 11:26:32 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user