Files
J Logan 98402bf4e6 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)
2025-09-09 16:46:18 -07:00

218 lines
9.4 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerClient
import ContainerImagesServiceClient
import Containerization
import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import Logging
import TerminalProgress
public actor ImagesService {
public static let keychainID = "com.apple.container"
private let log: Logger
private let contentStore: ContentStore
private let imageStore: ImageStore
private let snapshotStore: SnapshotStore
public init(contentStore: ContentStore, imageStore: ImageStore, snapshotStore: SnapshotStore, log: Logger) throws {
self.contentStore = contentStore
self.imageStore = imageStore
self.snapshotStore = snapshotStore
self.log = log
}
private func _list() async throws -> [Containerization.Image] {
try await imageStore.list()
}
private func _get(_ reference: String) async throws -> Containerization.Image {
try await imageStore.get(reference: reference)
}
private func _get(_ description: ImageDescription) async throws -> Containerization.Image {
let exists = try await self._get(description.reference)
guard exists.descriptor == description.descriptor else {
throw ContainerizationError(.invalidState, message: "Descriptor mismatch. Expected \(description.descriptor), got \(exists.descriptor)")
}
return exists
}
public func list() async throws -> [ImageDescription] {
self.log.info("ImagesService: \(#function)")
return try await imageStore.list().map { $0.description.fromCZ }
}
public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws -> ImageDescription {
self.log.info("ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure)")
let img = try await Self.withAuthentication(ref: reference) { auth in
try await self.imageStore.pull(
reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate))
}
guard let img else {
throw ContainerizationError(.internalError, message: "Failed to pull image \(reference)")
}
return img.description.fromCZ
}
public func push(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws {
self.log.info("ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure)")
try await Self.withAuthentication(ref: reference) { auth in
try await self.imageStore.push(
reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate))
}
}
public func tag(old: String, new: String) async throws -> ImageDescription {
self.log.info("ImagesService: \(#function) - old: \(old), new: \(new)")
let img = try await self.imageStore.tag(existing: old, new: new)
return img.description.fromCZ
}
public func delete(reference: String, garbageCollect: Bool) async throws {
self.log.info("ImagesService: \(#function) - ref: \(reference)")
try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect)
}
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: references, out: tempDir, platform: platform)
let writer = try ArchiveWriter(format: .pax, filter: .none, file: out)
try writer.archiveDirectory(tempDir)
try writer.finishEncoding()
}
public func load(from tarFile: URL) async throws -> [ImageDescription] {
self.log.info("ImagesService: \(#function) from: \(tarFile.absolutePath())")
let reader = try ArchiveReader(file: tarFile)
let tempDir = FileManager.default.uniqueTemporaryDirectory()
defer {
try? FileManager.default.removeItem(at: tempDir)
}
try reader.extractContents(to: tempDir)
let loaded = try await self.imageStore.load(from: tempDir)
var images: [ImageDescription] = []
for image in loaded {
images.append(image.description.fromCZ)
}
return images
}
public func prune() async throws -> ([String], UInt64) {
let images = try await self._list()
let freedSnapshotBytes = try await self.snapshotStore.clean(keepingSnapshotsFor: images)
let (deleted, freedContentBytes) = try await self.imageStore.prune()
return (deleted, freedContentBytes + freedSnapshotBytes)
}
}
// MARK: Image Snapshot Methods
extension ImagesService {
public func unpack(description: ImageDescription, platform: Platform?, progressUpdate: ProgressUpdateHandler?) async throws {
self.log.info("ImagesService: \(#function) - description: \(description), platform: \(String(describing: platform))")
let img = try await self._get(description)
try await self.snapshotStore.unpack(image: img, platform: platform, progressUpdate: progressUpdate)
}
public func deleteImageSnapshot(description: ImageDescription, platform: Platform?) async throws {
self.log.info("ImagesService: \(#function) - description: \(description), platform: \(String(describing: platform))")
let img = try await self._get(description)
try await self.snapshotStore.delete(for: img, platform: platform)
}
public func getImageSnapshot(description: ImageDescription, platform: Platform) async throws -> Filesystem {
self.log.info("ImagesService: \(#function) - description: \(description), platform: \(String(describing: platform))")
let img = try await self._get(description)
return try await self.snapshotStore.get(for: img, platform: platform)
}
}
// MARK: Static Methods
extension ImagesService {
private static func withAuthentication<T>(
ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T?
) async throws -> T? {
var authentication: Authentication?
let ref = try Reference.parse(ref)
guard let host = ref.resolvedDomain else {
throw ContainerizationError(.invalidArgument, message: "No host specified in image reference: \(ref)")
}
authentication = Self.authenticationFromEnv(host: host)
if let authentication {
return try await body(authentication)
}
let keychain = KeychainHelper(id: Self.keychainID)
do {
authentication = try keychain.lookup(domain: host)
} catch let err as KeychainHelper.Error {
guard case .keyNotFound = err else {
throw ContainerizationError(.internalError, message: "Error querying keychain for \(host)", cause: err)
}
}
do {
return try await body(authentication)
} catch let err as RegistryClient.Error {
guard case .invalidStatus(_, let status, _) = err else {
throw err
}
guard status == .unauthorized || status == .forbidden else {
throw err
}
guard authentication != nil else {
throw ContainerizationError(.internalError, message: "\(String(describing: err)). No credentials found for host \(host)")
}
throw err
}
}
private static func authenticationFromEnv(host: String) -> Authentication? {
let env = ProcessInfo.processInfo.environment
guard env["CONTAINER_REGISTRY_HOST"] == host else {
return nil
}
guard let user = env["CONTAINER_REGISTRY_USER"], let password = env["CONTAINER_REGISTRY_TOKEN"] else {
return nil
}
return BasicAuthentication(username: user, password: password)
}
}
extension ImageDescription {
public var toCZ: Containerization.Image.Description {
.init(reference: self.reference, descriptor: self.descriptor)
}
}
extension Containerization.Image.Description {
public var fromCZ: ImageDescription {
.init(
reference: self.reference,
descriptor: self.descriptor
)
}
}