180 lines
7.7 KiB
Swift
180 lines
7.7 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
|
//
|
|
// 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 ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationIO
|
|
import ContainerizationOCI
|
|
import Crypto
|
|
import Foundation
|
|
|
|
extension ImageStore {
|
|
public struct ExportOperation: Sendable {
|
|
let name: String
|
|
let tag: String
|
|
let contentStore: ContentStore
|
|
let client: ContentClient
|
|
let progress: ProgressHandler?
|
|
|
|
public init(name: String, tag: String, contentStore: ContentStore, client: ContentClient, progress: ProgressHandler? = nil) {
|
|
self.contentStore = contentStore
|
|
self.client = client
|
|
self.progress = progress
|
|
self.name = name
|
|
self.tag = tag
|
|
}
|
|
|
|
@discardableResult
|
|
public func export(index: Descriptor, platforms: (Platform) -> Bool, filter: (Descriptor) -> Bool = { _ in true }) async throws -> Descriptor {
|
|
var pushQueue: [[Descriptor]] = []
|
|
var current: [Descriptor] = [index]
|
|
while !current.isEmpty {
|
|
let children = try await self.getChildren(descs: current)
|
|
let matches = try filterPlatforms(matcher: platforms, children).uniqued { $0.digest }
|
|
pushQueue.append(matches)
|
|
current = matches
|
|
}
|
|
let localIndexData = try await self.createIndex(from: index, matching: platforms)
|
|
|
|
await updatePushProgress(pushQueue: pushQueue, localIndexData: localIndexData)
|
|
|
|
// We need to work bottom up when pushing an image.
|
|
// First, the tar blobs / config layers, then, the manifests and so on...
|
|
// When processing a given "level", the requests maybe made in parallel.
|
|
// We need to ensure that the child level has been uploaded fully
|
|
// before uploading the parent level.
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for layerGroup in pushQueue.reversed() {
|
|
for chunk in layerGroup.chunks(ofCount: 8) {
|
|
for desc in chunk.filter(filter) {
|
|
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
|
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
|
}
|
|
group.addTask {
|
|
let readStream = try ReadStream(url: content.path)
|
|
try await self.pushContent(descriptor: desc, stream: readStream)
|
|
}
|
|
}
|
|
try await group.waitForAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lastly, we need to construct and push a new index, since we may
|
|
// have pushed content only for specific platforms.
|
|
let digest = SHA256.hash(data: localIndexData)
|
|
// The descriptor's mediaType becomes the HTTP Content-Type in
|
|
// RegistryClient.push and must match the mediaType field inside
|
|
// localIndexData. Registries reject mismatches with MANIFEST_INVALID.
|
|
let descriptor = Descriptor(
|
|
mediaType: index.mediaType,
|
|
digest: digest.digestString,
|
|
size: Int64(localIndexData.count))
|
|
let stream = ReadStream(data: localIndexData)
|
|
try await self.pushContent(descriptor: descriptor, stream: stream)
|
|
return descriptor
|
|
}
|
|
|
|
private func updatePushProgress(pushQueue: [[Descriptor]], localIndexData: Data) async {
|
|
for layerGroup in pushQueue {
|
|
for desc in layerGroup {
|
|
await progress?([
|
|
.addTotalSize(desc.size),
|
|
.addTotalItems(1),
|
|
])
|
|
}
|
|
}
|
|
await progress?([
|
|
.addTotalSize(Int64(localIndexData.count)),
|
|
.addTotalItems(1),
|
|
])
|
|
}
|
|
|
|
private func createIndex(from index: Descriptor, matching: (Platform) -> Bool) async throws -> Data {
|
|
guard let content = try await self.contentStore.get(digest: index.digest) else {
|
|
throw ContainerizationError(.notFound, message: "content with digest \(index.digest)")
|
|
}
|
|
var idx: Index = try content.decode()
|
|
let manifests = idx.manifests
|
|
var matchedManifests: [Descriptor] = []
|
|
var skippedPlatforms = false
|
|
for manifest in manifests {
|
|
guard let p = manifest.platform else {
|
|
continue
|
|
}
|
|
if matching(p) {
|
|
matchedManifests.append(manifest)
|
|
} else {
|
|
skippedPlatforms = true
|
|
}
|
|
}
|
|
if !skippedPlatforms {
|
|
return try content.data()
|
|
}
|
|
idx.manifests = matchedManifests
|
|
return try JSONEncoder().encode(idx)
|
|
}
|
|
|
|
private func pushContent(descriptor: Descriptor, stream: ReadStream) async throws {
|
|
do {
|
|
let generator = {
|
|
try stream.reset()
|
|
return stream.stream
|
|
}
|
|
try await client.push(name: name, ref: tag, descriptor: descriptor, streamGenerator: generator, progress: progress)
|
|
await progress?([
|
|
.addSize(descriptor.size),
|
|
.addItems(1),
|
|
])
|
|
} catch let err as ContainerizationError {
|
|
guard err.code != .exists else {
|
|
// We reported the total items and size and have to account for them in existing content.
|
|
await progress?([
|
|
.addSize(descriptor.size),
|
|
.addItems(1),
|
|
])
|
|
return
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
private func getChildren(descs: [Descriptor]) async throws -> [Descriptor] {
|
|
var out: [Descriptor] = []
|
|
for desc in descs {
|
|
let mediaType = desc.mediaType
|
|
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
|
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
|
}
|
|
switch mediaType {
|
|
case MediaTypes.index, MediaTypes.dockerManifestList:
|
|
let index: Index = try content.decode()
|
|
out.append(contentsOf: index.manifests)
|
|
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
|
let manifest: Manifest = try content.decode()
|
|
out.append(manifest.config)
|
|
out.append(contentsOf: manifest.layers)
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
}
|
|
}
|