-
Notifications
You must be signed in to change notification settings - Fork 261
Expand file tree
/
Copy pathRegistryClient+Push.swift
More file actions
181 lines (162 loc) · 7.85 KB
/
RegistryClient+Push.swift
File metadata and controls
181 lines (162 loc) · 7.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
//===----------------------------------------------------------------------===//
// 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]
}
}