Files
apple--containerization/Sources/ContainerizationOCI/Client/RegistryClient+Push.swift
T
wehub-resource-sync 680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
chore: import upstream snapshot with attribution
2026-07-13 12:25:30 +08:00

182 lines
7.9 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 AsyncHTTPClient
import ContainerizationError
import ContainerizationExtras
import Foundation
import NIO
extension RegistryClient {
/// Pushes the content specified by a descriptor to a remote registry.
/// - Parameters:
/// - name: The namespace which the descriptor should belong under.
/// - tag: The tag or digest for uniquely identifying the manifest.
/// By convention, any portion that may be a partial or whole digest
/// will be proceeded by an `@`. Anything preceding the `@` will be referred
/// to as "tag".
/// This is usually broken down into the following possibilities:
/// 1. <tag>
/// 2. <tag>@<digest>
/// 3. @<digest>
/// The tag is anything except `@` and `:`, and digest is anything after the `@`
/// - descriptor: The OCI descriptor of the content to be pushed.
/// - streamGenerator: A closure that produces an`AsyncStream` of `ByteBuffer`
/// for streaming data to the `HTTPClientRequest.Body`.
/// The caller is responsible for providing the `AsyncStream` where the data may come from
/// a file on disk, data in memory, etc.
/// - progress: The progress handler to invoke as data is sent.
public func push<T: Sendable & AsyncSequence>(
name: String,
ref tag: String,
descriptor: Descriptor,
streamGenerator: () throws -> T,
progress: ProgressHandler?
) async throws where T.Element == ByteBuffer {
var components = base
let mediaType = descriptor.mediaType
if mediaType.isEmpty {
throw ContainerizationError(.invalidArgument, message: "missing media type for descriptor \(descriptor.digest)")
}
var isManifest = false
var existCheck: [String] = []
switch mediaType {
case MediaTypes.dockerManifest, MediaTypes.dockerManifestList, MediaTypes.imageManifest, MediaTypes.index:
isManifest = true
existCheck = self.getManifestPath(tag: tag, digest: descriptor.digest)
default:
existCheck = ["blobs", descriptor.digest]
}
// Check if the content already exists.
components.path = "/v2/\(name)/\(existCheck.joined(separator: "/"))"
let mediaTypes = [
mediaType,
"*/*",
]
var headers = [
("Accept", mediaTypes.joined(separator: ", "))
]
try await request(components: components, method: .HEAD, headers: headers) { response in
if response.status == .ok {
var exists = false
if isManifest && existCheck[1] != descriptor.digest {
if descriptor.digest == response.headers.first(name: "Docker-Content-Digest") {
exists = true
}
} else {
exists = true
}
if exists {
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
}
} else if response.status != .notFound {
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
}
if isManifest {
let path = self.getManifestPath(tag: tag, digest: descriptor.digest)
components.path = "/v2/\(name)/\(path.joined(separator: "/"))"
headers = [
("Content-Type", mediaType)
]
} else {
// Start upload request for blobs.
components.path = "/v2/\(name)/blobs/uploads/"
try await request(components: components, method: .POST) { response in
switch response.status {
case .ok, .accepted, .noContent:
break
case .created:
throw ContainerizationError(.exists, message: "content already exists \(descriptor.digest)")
default:
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
// Get the location to upload the blob.
guard let location = response.headers.first(name: "Location") else {
throw ContainerizationError(.invalidArgument, message: "missing required header Location")
}
guard let urlComponents = URLComponents(string: location) else {
throw ContainerizationError(.invalidArgument, message: "invalid url \(location)")
}
var queryItems = urlComponents.queryItems ?? []
queryItems.append(URLQueryItem(name: "digest", value: descriptor.digest))
components.path = urlComponents.path
components.queryItems = queryItems
headers = [
("Content-Type", "application/octet-stream"),
("Content-Length", String(descriptor.size)),
]
}
}
// We have to pass a body closure rather than a body to reset the stream when retrying.
let bodyClosure = {
let stream = try streamGenerator()
let body = HTTPClientRequest.Body.stream(stream, length: .known(descriptor.size))
return body
}
return try await request(components: components, method: .PUT, bodyClosure: bodyClosure, headers: headers) { response in
switch response.status {
case .ok, .created, .noContent:
break
default:
let url = components.url?.absoluteString ?? "unknown"
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
throw Error.invalidStatus(url: url, response.status, reason: reason)
}
guard descriptor.digest == response.headers.first(name: "Docker-Content-Digest") else {
let required = response.headers.first(name: "Docker-Content-Digest") ?? ""
throw ContainerizationError(.internalError, message: "digest mismatch \(descriptor.digest) != \(required)")
}
}
}
private func getManifestPath(tag: String, digest: String) -> [String] {
var object = tag
if let i = tag.firstIndex(of: "@") {
let index = tag.index(after: i)
if String(tag[index...]) != digest {
object = ""
} else {
object = String(tag[...i])
}
}
if object == "" {
return ["manifests", digest]
}
return ["manifests", object]
}
}