chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct AuthChallengeTests {
|
||||
internal struct TestCase: Sendable {
|
||||
let input: String
|
||||
let expected: AuthenticateChallenge
|
||||
}
|
||||
|
||||
private static let testCases: [TestCase] = [
|
||||
.init(
|
||||
input: """
|
||||
Bearer realm="https://domain.io/token",service="domain.io",scope="repository:user/image:pull"
|
||||
""",
|
||||
expected: .init(type: "Bearer", realm: "https://domain.io/token", service: "domain.io", scope: "repository:user/image:pull", error: nil)),
|
||||
.init(
|
||||
input: """
|
||||
Bearer realm="https://foo-bar-registry.com/auth",service="Awesome Registry"
|
||||
""",
|
||||
expected: .init(type: "Bearer", realm: "https://foo-bar-registry.com/auth", service: "Awesome Registry", scope: nil, error: nil)),
|
||||
.init(
|
||||
input: """
|
||||
Bearer realm="users.example.com", scope="create delete"
|
||||
""",
|
||||
expected: .init(type: "Bearer", realm: "users.example.com", service: nil, scope: "create delete", error: nil)),
|
||||
.init(
|
||||
input: """
|
||||
Bearer realm="https://auth.server.io/token",service="registry.server.io"
|
||||
""",
|
||||
expected: .init(type: "Bearer", realm: "https://auth.server.io/token", service: "registry.server.io", scope: nil, error: nil)),
|
||||
|
||||
]
|
||||
|
||||
@Test(arguments: testCases)
|
||||
func parseAuthHeader(testCase: TestCase) throws {
|
||||
let challenges = RegistryClient.parseWWWAuthenticateHeaders(headers: [testCase.input])
|
||||
#expect(challenges.count == 1)
|
||||
#expect(challenges[0] == testCase.expected)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 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 Crypto
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
@Suite("ContentWriter Tests")
|
||||
struct ContentWriterTests {
|
||||
private func withTempDirectory(_ body: (URL) throws -> Void) throws {
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
try body(dir)
|
||||
}
|
||||
|
||||
private func makeTempFile(in dir: URL, data: Data) throws -> URL {
|
||||
let url = dir.appendingPathComponent(UUID().uuidString)
|
||||
try data.write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
@Test func testWriteReturnsCorrectSizeAndDigest() throws {
|
||||
try withTempDirectory { dir in
|
||||
let writer = try ContentWriter(for: dir)
|
||||
let data = Data("test content".utf8)
|
||||
let (size, digest) = try writer.write(data)
|
||||
let expected = SHA256.hash(data: data)
|
||||
let destination = dir.appendingPathComponent(digest.encoded)
|
||||
#expect(size == Int64(data.count))
|
||||
#expect(digest == expected)
|
||||
#expect(FileManager.default.fileExists(atPath: destination.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testWriteDuplicateData() throws {
|
||||
try withTempDirectory { dir in
|
||||
let writer = try ContentWriter(for: dir)
|
||||
let data = Data("duplicate".utf8)
|
||||
#expect(throws: Never.self) {
|
||||
try writer.write(data)
|
||||
try writer.write(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCreateFromFileSmallFile() throws {
|
||||
try withTempDirectory { base in
|
||||
try withTempDirectory { src in
|
||||
let data = Data("small file contents".utf8)
|
||||
let sourceURL = try makeTempFile(in: src, data: data)
|
||||
let writer = try ContentWriter(for: base)
|
||||
let (size, digest) = try writer.create(from: sourceURL)
|
||||
let destination = base.appendingPathComponent(digest.encoded)
|
||||
let written = try Data(contentsOf: destination)
|
||||
#expect(size == Int64(data.count))
|
||||
#expect(digest == SHA256.hash(data: data))
|
||||
#expect(FileManager.default.fileExists(atPath: destination.path))
|
||||
#expect(written == data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test func testCreateFromFileLargeFileDuplicates() throws {
|
||||
try withTempDirectory { base in
|
||||
try withTempDirectory { src in
|
||||
let count = 3 * 1024 * 1024 + 100
|
||||
var bytes = [UInt8](repeating: 0, count: count)
|
||||
arc4random_buf(&bytes, count)
|
||||
let data = Data(bytes)
|
||||
let sourceURL = try makeTempFile(in: src, data: data)
|
||||
let writer = try ContentWriter(for: base)
|
||||
let (size, digest) = try writer.create(from: sourceURL)
|
||||
let destination = base.appendingPathComponent(digest.encoded)
|
||||
#expect(size == Int64(data.count))
|
||||
#expect(digest == SHA256.hash(data: data))
|
||||
#expect(throws: Never.self) {
|
||||
try writer.create(from: sourceURL)
|
||||
}
|
||||
#expect(FileManager.default.fileExists(atPath: destination.path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SamplePayload: Codable, Equatable {
|
||||
let name: String
|
||||
let value: Int
|
||||
}
|
||||
|
||||
@Test func testCreateFromEncodableReturnsCorrectDigest() throws {
|
||||
try withTempDirectory { base in
|
||||
let writer = try ContentWriter(for: base)
|
||||
let payload = SamplePayload(name: "digest-check", value: 99)
|
||||
let (size, digest) = try writer.create(from: payload)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let expected = try encoder.encode(payload)
|
||||
#expect(size == Int64(expected.count))
|
||||
#expect(digest == SHA256.hash(data: expected))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 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 Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
@Suite
|
||||
struct LocalContentStoreTests {
|
||||
private static let digestA = String(repeating: "a", count: 64)
|
||||
private static let digestB = String(repeating: "b", count: 64)
|
||||
|
||||
@Test func totalAllocatedSizeReportsZeroForEmptyStore() async throws {
|
||||
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let store = try LocalContentStore(path: dir)
|
||||
let size = try await store.totalAllocatedSize()
|
||||
#expect(size == 0)
|
||||
}
|
||||
|
||||
@Test func totalAllocatedSizeReflectsCommittedBlobs() async throws {
|
||||
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let store = try LocalContentStore(path: dir)
|
||||
let payload = Data(repeating: 0xAB, count: 64 * 1024)
|
||||
|
||||
try await store.ingest { tempDir in
|
||||
try payload.write(to: tempDir.appendingPathComponent(Self.digestA))
|
||||
}
|
||||
|
||||
let size = try await store.totalAllocatedSize()
|
||||
#expect(size >= UInt64(payload.count))
|
||||
}
|
||||
|
||||
@Test func totalAllocatedSizeIncludesInFlightIngest() async throws {
|
||||
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
|
||||
let store = try LocalContentStore(path: dir)
|
||||
let payload = Data(repeating: 0xCD, count: 32 * 1024)
|
||||
|
||||
let session = try await store.newIngestSession()
|
||||
try payload.write(to: session.ingestDir.appendingPathComponent(Self.digestB))
|
||||
|
||||
let size = try await store.totalAllocatedSize()
|
||||
#expect(size >= UInt64(payload.count))
|
||||
|
||||
try await store.cancelIngestSession(session.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct OCITests {
|
||||
@Test func config() {
|
||||
let config = ContainerizationOCI.ImageConfig()
|
||||
let rootfs = ContainerizationOCI.Rootfs(type: "foo", diffIDs: ["diff1", "diff2"])
|
||||
let history = ContainerizationOCI.History()
|
||||
|
||||
let image = ContainerizationOCI.Image(architecture: "arm64", os: "linux", config: config, rootfs: rootfs, history: [history])
|
||||
#expect(image.rootfs.type == "foo")
|
||||
}
|
||||
|
||||
@Test func descriptor() {
|
||||
let platform = ContainerizationOCI.Platform(arch: "arm64", os: "linux")
|
||||
let descriptor = ContainerizationOCI.Descriptor(mediaType: MediaTypes.descriptor, digest: "123", size: 0, platform: platform)
|
||||
|
||||
#expect(descriptor.platform?.architecture == "arm64")
|
||||
#expect(descriptor.platform?.os == "linux")
|
||||
#expect(descriptor.artifactType == nil)
|
||||
}
|
||||
|
||||
@Test func descriptorWithArtifactType() throws {
|
||||
let testArtifactType = "application/vnd.example.test.v1+json"
|
||||
let descriptor = ContainerizationOCI.Descriptor(
|
||||
mediaType: MediaTypes.imageManifest,
|
||||
digest: "sha256:abc123",
|
||||
size: 1234,
|
||||
artifactType: testArtifactType
|
||||
)
|
||||
#expect(descriptor.artifactType == testArtifactType)
|
||||
|
||||
let data = try JSONEncoder().encode(descriptor)
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: data)
|
||||
#expect(decoded.artifactType == testArtifactType)
|
||||
}
|
||||
|
||||
@Test func descriptorWithoutArtifactTypeDecodesAsNil() throws {
|
||||
let json = """
|
||||
{"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":0}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: json.data(using: .utf8)!)
|
||||
#expect(decoded.artifactType == nil)
|
||||
}
|
||||
|
||||
@Test func index() {
|
||||
var descriptors: [ContainerizationOCI.Descriptor] = []
|
||||
for i in 0..<5 {
|
||||
let descriptor = ContainerizationOCI.Descriptor(mediaType: MediaTypes.descriptor, digest: "\(i)", size: Int64(i))
|
||||
descriptors.append(descriptor)
|
||||
}
|
||||
|
||||
let index = ContainerizationOCI.Index(schemaVersion: 1, manifests: descriptors)
|
||||
#expect(index.manifests.count == 5)
|
||||
#expect(index.subject == nil)
|
||||
#expect(index.artifactType == nil)
|
||||
}
|
||||
|
||||
@Test func indexWithSubjectAndArtifactType() throws {
|
||||
let testArtifactType = "application/vnd.example.test.v1+json"
|
||||
let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:subject", size: 512)
|
||||
let index = ContainerizationOCI.Index(
|
||||
schemaVersion: 2,
|
||||
manifests: [],
|
||||
subject: subject,
|
||||
artifactType: testArtifactType
|
||||
)
|
||||
#expect(index.subject?.digest == "sha256:subject")
|
||||
#expect(index.artifactType == testArtifactType)
|
||||
|
||||
let data = try JSONEncoder().encode(index)
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: data)
|
||||
#expect(decoded.subject?.digest == "sha256:subject")
|
||||
#expect(decoded.artifactType == testArtifactType)
|
||||
}
|
||||
|
||||
@Test func indexDecodesWithoutNewFields() throws {
|
||||
let json = """
|
||||
{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":10}]}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: json.data(using: .utf8)!)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.manifests.count == 1)
|
||||
#expect(decoded.subject == nil)
|
||||
#expect(decoded.artifactType == nil)
|
||||
}
|
||||
|
||||
@Test func manifests() {
|
||||
var descriptors: [ContainerizationOCI.Descriptor] = []
|
||||
for i in 0..<5 {
|
||||
let descriptor = ContainerizationOCI.Descriptor(mediaType: MediaTypes.descriptor, digest: "\(i)", size: Int64(i))
|
||||
descriptors.append(descriptor)
|
||||
}
|
||||
|
||||
let config = ContainerizationOCI.Descriptor(mediaType: MediaTypes.descriptor, digest: "123", size: 0)
|
||||
|
||||
let manifest = ContainerizationOCI.Manifest(schemaVersion: 1, config: config, layers: descriptors)
|
||||
#expect(manifest.config.digest == "123")
|
||||
#expect(manifest.layers.count == 5)
|
||||
#expect(manifest.subject == nil)
|
||||
#expect(manifest.artifactType == nil)
|
||||
}
|
||||
|
||||
@Test func manifestWithSubjectAndArtifactType() throws {
|
||||
let testArtifactType = "application/vnd.example.test.v1+json"
|
||||
let config = ContainerizationOCI.Descriptor(mediaType: MediaTypes.emptyJSON, digest: "sha256:empty", size: 2)
|
||||
let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:target", size: 1234)
|
||||
let layer = ContainerizationOCI.Descriptor(
|
||||
mediaType: testArtifactType,
|
||||
digest: "sha256:meta",
|
||||
size: 89,
|
||||
annotations: ["org.opencontainers.image.title": "metadata.json"]
|
||||
)
|
||||
|
||||
let manifest = ContainerizationOCI.Manifest(
|
||||
config: config,
|
||||
layers: [layer],
|
||||
subject: subject,
|
||||
artifactType: testArtifactType
|
||||
)
|
||||
#expect(manifest.subject?.digest == "sha256:target")
|
||||
#expect(manifest.artifactType == testArtifactType)
|
||||
#expect(manifest.layers[0].annotations?["org.opencontainers.image.title"] == "metadata.json")
|
||||
|
||||
let data = try JSONEncoder().encode(manifest)
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: data)
|
||||
#expect(decoded.subject?.digest == "sha256:target")
|
||||
#expect(decoded.artifactType == testArtifactType)
|
||||
}
|
||||
|
||||
@Test func manifestDecodesWithoutNewFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"config": {"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:abc","size":2},
|
||||
"layers": []
|
||||
}
|
||||
"""
|
||||
let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: json.data(using: .utf8)!)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.subject == nil)
|
||||
#expect(decoded.artifactType == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct OCIPlatformTests {
|
||||
@Test func identicalPlatforms() {
|
||||
let amd64lhs = Platform(arch: "amd64", os: "linux")
|
||||
let amd64rhs = Platform(arch: "amd64", os: "linux")
|
||||
#expect(amd64lhs == amd64rhs, "amd64 platforms should be equal")
|
||||
|
||||
let arm64lhs = Platform(arch: "arm64", os: "linux")
|
||||
let arm64rhs = Platform(arch: "arm64", os: "linux")
|
||||
#expect(arm64lhs == arm64rhs, "arm64 platforms should be equal")
|
||||
}
|
||||
|
||||
@Test func differentOS() {
|
||||
let lhs = Platform(arch: "arm64", os: "linux")
|
||||
let rhs = Platform(arch: "arm64", os: "darwin")
|
||||
#expect(lhs != rhs, "Different OS should not be equal")
|
||||
}
|
||||
|
||||
@Test func differentArch() {
|
||||
let lhs = Platform(arch: "amd64", os: "linux")
|
||||
let rhs = Platform(arch: "arm64", os: "linux")
|
||||
#expect(lhs != rhs, "Different arch should not be equal")
|
||||
}
|
||||
|
||||
@Test func arm64_sameVariant() {
|
||||
let lhs = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
let rhs = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
#expect(lhs == rhs, "Both OS arm64, same arch, same variant => equal")
|
||||
}
|
||||
|
||||
@Test func arm64_nilAndV8() {
|
||||
let lhs = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
let rhs = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
#expect(lhs == rhs, "One variant nil and other v8 => equal under special arm64 rule")
|
||||
}
|
||||
|
||||
@Test func arm64_nilAndV7() {
|
||||
let lhs = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
let rhs = Platform(arch: "arm64", os: "linux", variant: "v7")
|
||||
#expect(lhs != rhs, "nil vs v7 is not covered by the special rule => not equal")
|
||||
}
|
||||
|
||||
@Test func arm64_bothNil() {
|
||||
let lhs = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
let rhs = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
#expect(lhs == rhs, "Both nil variants => variantEqual is true => overall equal")
|
||||
}
|
||||
|
||||
@Test func arm64_nilAndV8_sameHashValue() {
|
||||
let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
// Equal platforms must produce the same hash — violating this breaks Set/Dictionary lookups
|
||||
#expect(withoutVariant.hashValue == withV8.hashValue, "arm64 nil variant and v8 must hash identically")
|
||||
}
|
||||
|
||||
@Test func arm64_nilAndV8_setLookup() {
|
||||
let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil)
|
||||
let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
var set = Set<Platform>()
|
||||
set.insert(withoutVariant)
|
||||
#expect(set.contains(withV8), "arm64/v8 must be found in a Set that contains arm64 with nil variant")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct OCISpecTests {
|
||||
@Test func minimalSpecDecode() throws {
|
||||
let version = "1.2.3"
|
||||
let minSpec =
|
||||
"""
|
||||
{
|
||||
"ociVersion": "\(version)"
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minSpec.data(using: .utf8) else {
|
||||
Issue.record("test spec is not valid: \(minSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Spec.self, from: data)
|
||||
#expect(decodedSpec.version == version)
|
||||
#expect(decodedSpec.hooks == nil)
|
||||
#expect(decodedSpec.process == nil)
|
||||
#expect(decodedSpec.hostname == "")
|
||||
#expect(decodedSpec.domainname == "")
|
||||
#expect(decodedSpec.mounts.isEmpty)
|
||||
#expect(decodedSpec.annotations == nil)
|
||||
#expect(decodedSpec.root == nil)
|
||||
#expect(decodedSpec.linux == nil)
|
||||
}
|
||||
|
||||
@Test func minimalProcessSpecDecode() throws {
|
||||
let cwd = "/test"
|
||||
let minProcessSpec =
|
||||
"""
|
||||
{
|
||||
"cwd": "\(cwd)",
|
||||
"user": {
|
||||
"uid": 10,
|
||||
"gid": 11
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minProcessSpec.data(using: .utf8) else {
|
||||
Issue.record("test process spec is not valid: \(minProcessSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Process.self, from: data)
|
||||
#expect(decodedSpec.cwd == cwd)
|
||||
#expect(decodedSpec.env.isEmpty)
|
||||
#expect(decodedSpec.consoleSize == nil)
|
||||
#expect(decodedSpec.selinuxLabel == "")
|
||||
#expect(decodedSpec.noNewPrivileges == false)
|
||||
#expect(decodedSpec.commandLine == "")
|
||||
#expect(decodedSpec.oomScoreAdj == nil)
|
||||
#expect(decodedSpec.capabilities == nil)
|
||||
#expect(decodedSpec.apparmorProfile == "")
|
||||
#expect(decodedSpec.user.uid == 10)
|
||||
#expect(decodedSpec.user.gid == 11)
|
||||
#expect(decodedSpec.rlimits.isEmpty)
|
||||
#expect(decodedSpec.terminal == false)
|
||||
}
|
||||
|
||||
@Test func minimalUserSpecDecode() throws {
|
||||
let minUserSpec =
|
||||
"""
|
||||
{
|
||||
"uid": 10,
|
||||
"gid": 11
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minUserSpec.data(using: .utf8) else {
|
||||
Issue.record("test user spec is not valid: \(minUserSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.User.self, from: data)
|
||||
#expect(decodedSpec.uid == 10)
|
||||
#expect(decodedSpec.gid == 11)
|
||||
#expect(decodedSpec.umask == nil)
|
||||
#expect(decodedSpec.additionalGids.isEmpty)
|
||||
#expect(decodedSpec.username == "")
|
||||
}
|
||||
|
||||
@Test func minimalRootSpecDecode() throws {
|
||||
let path = "/testpath"
|
||||
let minRootSpec =
|
||||
"""
|
||||
{
|
||||
"path": "\(path)"
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minRootSpec.data(using: .utf8) else {
|
||||
Issue.record("test root spec is not valid: \(minRootSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Root.self, from: data)
|
||||
#expect(decodedSpec.path == path)
|
||||
#expect(decodedSpec.readonly == false)
|
||||
}
|
||||
|
||||
@Test func minimalMountSpecDecode() throws {
|
||||
let destination = "/testdest"
|
||||
let minMountSpec =
|
||||
"""
|
||||
{
|
||||
"destination": "\(destination)"
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minMountSpec.data(using: .utf8) else {
|
||||
Issue.record("test mount spec is not valid: \(minMountSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let decodedSpec = try JSONDecoder().decode(ContainerizationOCI.Mount.self, from: data)
|
||||
#expect(decodedSpec.type == "")
|
||||
#expect(decodedSpec.source == "")
|
||||
#expect(decodedSpec.destination == destination)
|
||||
#expect(decodedSpec.options.isEmpty)
|
||||
#expect(decodedSpec.uidMappings == nil)
|
||||
#expect(decodedSpec.gidMappings == nil)
|
||||
}
|
||||
|
||||
@Test func minimalCapabilitiesDecode() throws {
|
||||
let minCapabilitiesSpec =
|
||||
"""
|
||||
{
|
||||
"ociVersion": "1.1.0",
|
||||
"capabilities": {
|
||||
"permitted": [
|
||||
"CAP_SYS_ADMIN"
|
||||
]
|
||||
},
|
||||
"linux": {}
|
||||
}
|
||||
"""
|
||||
|
||||
guard let data = minCapabilitiesSpec.data(using: .utf8) else {
|
||||
Issue.record("test capabilities spec is not valid: \(minCapabilitiesSpec)")
|
||||
return
|
||||
}
|
||||
|
||||
let _ = try JSONDecoder().decode(ContainerizationOCI.Spec.self, from: data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
// swiftlint:disable force_cast large_tuple
|
||||
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
@Suite("Reference Parse Tests")
|
||||
struct ReferenceParseTests {
|
||||
internal struct ReferenceParseTestCase: Sendable {
|
||||
let input: String
|
||||
let domain: String?
|
||||
let path: String
|
||||
let tag: String?
|
||||
let digest: String?
|
||||
init(input: String, domain: String? = nil, path: String, tag: String? = nil, digest: String? = nil) {
|
||||
self.input = input
|
||||
self.domain = domain
|
||||
self.path = path
|
||||
self.tag = tag
|
||||
self.digest = digest
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
ReferenceParseTestCase(input: "tensorflow/tensorflow", path: "tensorflow/tensorflow"),
|
||||
ReferenceParseTestCase(input: "debian", path: "debian"),
|
||||
ReferenceParseTestCase(input: "repo_with_underscore", path: "repo_with_underscore"),
|
||||
ReferenceParseTestCase(input: "swift5.10:alpine", path: "swift5.10", tag: "alpine"),
|
||||
ReferenceParseTestCase(input: "registry.com.with.port:5000/no_tag", domain: "registry.com.with.port:5000", path: "no_tag"),
|
||||
ReferenceParseTestCase(input: "registry.com.with.port:5000/name/foo/bar:tag23", domain: "registry.com.with.port:5000", path: "name/foo/bar", tag: "tag23"),
|
||||
ReferenceParseTestCase(input: "some-repo-with-dashes/name", path: "some-repo-with-dashes/name"),
|
||||
ReferenceParseTestCase(input: "domain.with-dashes/cool-image:foo", domain: "domain.with-dashes", path: "cool-image", tag: "foo"),
|
||||
ReferenceParseTestCase(input: "localhost:8080/123:latest", domain: "localhost:8080", path: "123", tag: "latest"),
|
||||
ReferenceParseTestCase(
|
||||
input: "localhost/123@sha256:\(String(repeating: "a", count: 64))", domain: "localhost", path: "123", digest: "sha256:\(String(repeating: "a", count: 64))"),
|
||||
ReferenceParseTestCase(
|
||||
input: "registry.com.with.port:1254/foo/bar/baz@sha256:\(String(repeating: "abcd", count: 16))", domain: "registry.com.with.port:1254", path: "foo/bar/baz",
|
||||
digest: "sha256:\(String(repeating: "abcd", count: 16))"),
|
||||
ReferenceParseTestCase(input: "192.168.1.1:5544/local/swift:6.0", domain: "192.168.1.1:5544", path: "local/swift", tag: "6.0"),
|
||||
ReferenceParseTestCase(input: "[abc12::4]:5683/swift", domain: "[abc12::4]:5683", path: "swift"),
|
||||
// Verify names longer than 127 characters are accepted (OCI spec allows up to 255).
|
||||
ReferenceParseTestCase(
|
||||
input: "reg.io/\(String(repeating: "a", count: 121))",
|
||||
domain: "reg.io",
|
||||
path: String(repeating: "a", count: 121)),
|
||||
// Verify a name of exactly 255 characters (the OCI spec maximum) is accepted.
|
||||
ReferenceParseTestCase(
|
||||
input: "registry.example.com/\(String(repeating: "a", count: 234))",
|
||||
domain: "registry.example.com",
|
||||
path: String(repeating: "a", count: 234)),
|
||||
])
|
||||
func validReferenceParse(testCase: ReferenceParseTestCase) async throws {
|
||||
#expect(throws: Never.self) {
|
||||
let parsed = try Reference.parse(testCase.input)
|
||||
#expect(parsed.path == testCase.path)
|
||||
#expect(parsed.domain == testCase.domain)
|
||||
#expect(parsed.digest == testCase.digest)
|
||||
#expect(parsed.tag == testCase.tag)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"localhost:8080",
|
||||
"localhost/123@sha256:\(String(repeating: "a", count: 200))",
|
||||
"https://github.com/apple/containerization",
|
||||
"",
|
||||
"-testString",
|
||||
"-testString/image",
|
||||
"-testString.com/image/release",
|
||||
"foo///bar",
|
||||
"mostly.valid/image/but/Caps",
|
||||
"[abc12::4]",
|
||||
"[abc12::4]:abc12::4",
|
||||
"[2001:db8:3:4::192.0.2.33]:5000/debian",
|
||||
"1a3f5e7d9c1b3a5f7e9d1c3b5a7f9e1d3c5b7a9f1e3d5d7c9b1a3f5e7d9c1b3a",
|
||||
// A name of 256 characters exceeds the OCI spec limit of 255 and must be rejected.
|
||||
"registry.example.com/\(String(repeating: "a", count: 235))",
|
||||
])
|
||||
func invalidReferenceParse(input: String) async throws {
|
||||
#expect(throws: ContainerizationError.self) {
|
||||
try Reference.parse(input)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
ReferenceParseTestCase(input: "only_name", path: "only_name", tag: "latest"),
|
||||
ReferenceParseTestCase(input: "docker.io/alpine", domain: "docker.io", path: "library/alpine", tag: "latest"),
|
||||
ReferenceParseTestCase(input: "ghcr.io/myrepo/alpine", domain: "ghcr.io", path: "myrepo/alpine", tag: "latest"),
|
||||
ReferenceParseTestCase(input: "name@sha256:" + String(repeating: "1", count: 64), path: "name", digest: "sha256:" + String(repeating: "1", count: 64)),
|
||||
ReferenceParseTestCase(input: "registry-1.docker.io/testrepo/myname:v2", domain: "registry-1.docker.io", path: "testrepo/myname", tag: "v2"),
|
||||
])
|
||||
func testNormalize(testCase: ReferenceParseTestCase) throws {
|
||||
let parsed = try Reference.parse(testCase.input)
|
||||
parsed.normalize()
|
||||
#expect(parsed.path == testCase.path)
|
||||
#expect(parsed.domain == testCase.domain)
|
||||
#expect(parsed.digest == testCase.digest)
|
||||
#expect(parsed.tag == testCase.tag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationIO
|
||||
import Crypto
|
||||
import Foundation
|
||||
import NIO
|
||||
import Synchronization
|
||||
import Testing
|
||||
|
||||
@testable import ContainerizationOCI
|
||||
|
||||
struct OCIClientTests: ~Copyable {
|
||||
private var contentPath: URL
|
||||
private let fileManager = FileManager.default
|
||||
private var encoder = JSONEncoder()
|
||||
|
||||
init() async throws {
|
||||
let testDir = fileManager.uniqueTemporaryDirectory()
|
||||
let contentPath = testDir.appendingPathComponent("content")
|
||||
try fileManager.createDirectory(at: contentPath, withIntermediateDirectories: true)
|
||||
self.contentPath = contentPath
|
||||
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
}
|
||||
|
||||
deinit {
|
||||
try? fileManager.removeItem(at: contentPath)
|
||||
}
|
||||
|
||||
private static var arch: String? {
|
||||
var uts = utsname()
|
||||
let result = uname(&uts)
|
||||
guard result == EXIT_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let machine = Data(bytes: &uts.machine, count: 256)
|
||||
guard let arch = String(bytes: machine, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch arch.lowercased().trimmingCharacters(in: .controlCharacters) {
|
||||
case "arm64":
|
||||
return "arm64"
|
||||
default:
|
||||
return "amd64"
|
||||
}
|
||||
}
|
||||
|
||||
@Test(.enabled(if: hasRegistryCredentials))
|
||||
func fetchToken() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
let request = TokenRequest(realm: "https://ghcr.io/token", service: "ghcr.io", clientId: "tests", scope: nil)
|
||||
let response = try await client.fetchToken(request: request)
|
||||
#expect(response.getToken() != nil)
|
||||
}
|
||||
|
||||
@Test(arguments: [
|
||||
"registry-1.docker.io",
|
||||
"public.ecr.aws",
|
||||
"registry.k8s.io",
|
||||
"mcr.microsoft.com",
|
||||
])
|
||||
func ping(host: String) async throws {
|
||||
let client = RegistryClient(host: host)
|
||||
try await client.ping()
|
||||
}
|
||||
|
||||
@Test func pingWithInvalidCredentials() async throws {
|
||||
let authentication = BasicAuthentication(username: "foo", password: "bar")
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: authentication)
|
||||
let error = await #expect(throws: RegistryClient.Error.self) { try await client.ping() }
|
||||
guard case .invalidStatus(_, let status, let reason) = error else {
|
||||
throw error!
|
||||
}
|
||||
#expect(status == .unauthorized)
|
||||
#expect(reason == "access denied or wrong credentials")
|
||||
}
|
||||
|
||||
@Test(.enabled(if: hasRegistryCredentials))
|
||||
func pingWithCredentials() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
try await client.ping()
|
||||
}
|
||||
|
||||
@Test func resolve() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
#expect(descriptor.mediaType == MediaTypes.dockerManifest)
|
||||
#expect(descriptor.size != 0)
|
||||
#expect(!descriptor.digest.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolveSha() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(
|
||||
name: "apple/containerization/dockermanifestimage", tag: "sha256:c8d344d228b7d9a702a95227438ec0d71f953a9a483e28ffabc5704f70d2b61e")
|
||||
let namedDescriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
#expect(descriptor == namedDescriptor)
|
||||
#expect(descriptor.mediaType == MediaTypes.dockerManifest)
|
||||
#expect(descriptor.size != 0)
|
||||
#expect(!descriptor.digest.isEmpty)
|
||||
}
|
||||
|
||||
@Test func fetchManifest() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
#expect(manifest.schemaVersion == 2)
|
||||
#expect(manifest.layers.count == 1)
|
||||
}
|
||||
|
||||
@Test func fetchManifestAsData() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifestData = try await client.fetchData(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
let checksum = SHA256.hash(data: manifestData)
|
||||
#expect(descriptor.digest == checksum.digest)
|
||||
}
|
||||
|
||||
@Test func fetchConfig() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
let image: Image = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: manifest.config)
|
||||
// This is an empty image -- check that the image label is present in the image config
|
||||
#expect(image.config?.labels?["org.opencontainers.image.source"] == "https://github.com/apple/containerization")
|
||||
#expect(image.rootfs.diffIDs.count == 1)
|
||||
}
|
||||
|
||||
@Test func fetchBlob() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io")
|
||||
let descriptor = try await client.resolve(name: "apple/containerization/dockermanifestimage", tag: "0.0.2")
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/dockermanifestimage", descriptor: descriptor)
|
||||
var called = false
|
||||
var done = false
|
||||
try await client.fetchBlob(name: "apple/containerization/dockermanifestimage", descriptor: manifest.layers.first!) { (expected, body) in
|
||||
called = true
|
||||
#expect(expected != 0)
|
||||
var received = 0
|
||||
for try await buffer in body {
|
||||
received += buffer.readableBytes
|
||||
if received == expected {
|
||||
done = true
|
||||
}
|
||||
}
|
||||
}
|
||||
#expect(called)
|
||||
#expect(done)
|
||||
}
|
||||
|
||||
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
|
||||
func pushIndex() async throws {
|
||||
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
|
||||
let indexDescriptor = try await client.resolve(name: "apple/containerization/emptyimage", tag: "0.0.1")
|
||||
let index: Index = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: indexDescriptor)
|
||||
|
||||
let platform = Platform(arch: "amd64", os: "linux")
|
||||
|
||||
var manifestDescriptor: Descriptor?
|
||||
for m in index.manifests where m.platform == platform {
|
||||
manifestDescriptor = m
|
||||
break
|
||||
}
|
||||
|
||||
#expect(manifestDescriptor != nil)
|
||||
|
||||
let manifest: Manifest = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifestDescriptor!)
|
||||
let imgConfig: Image = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifest.config)
|
||||
|
||||
let layer = try #require(manifest.layers.first)
|
||||
let blobPath = contentPath.appendingPathComponent(layer.digest)
|
||||
let outputStream = OutputStream(toFileAtPath: blobPath.path, append: false)
|
||||
#expect(outputStream != nil)
|
||||
|
||||
try await outputStream!.withThrowingOpeningStream {
|
||||
try await client.fetchBlob(name: "apple/containerization/emptyimage", descriptor: layer) { (expected, body) in
|
||||
var received: Int64 = 0
|
||||
for try await buffer in body {
|
||||
received += Int64(buffer.readableBytes)
|
||||
|
||||
buffer.withUnsafeReadableBytes { pointer in
|
||||
let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self)
|
||||
if let addr = unsafeBufferPointer.baseAddress {
|
||||
_ = outputStream!.write(addr, maxLength: buffer.readableBytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(received == expected)
|
||||
}
|
||||
}
|
||||
|
||||
let name = "apple/test-images/image-push"
|
||||
let ref = "latest"
|
||||
|
||||
// Push the layer first.
|
||||
do {
|
||||
let content = try LocalContent(path: blobPath)
|
||||
let generator = {
|
||||
let stream = try ReadStream(url: content.path)
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code == .exists else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Push the image configuration.
|
||||
var imgConfigDesc: Descriptor?
|
||||
do {
|
||||
imgConfigDesc = try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: imgConfig,
|
||||
baseDescriptor: manifest.config
|
||||
)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code != .exists else {
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
// Push the image manifest.
|
||||
let newManifest = Manifest(
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
mediaType: manifest.mediaType!,
|
||||
config: imgConfigDesc!,
|
||||
layers: manifest.layers,
|
||||
annotations: manifest.annotations
|
||||
)
|
||||
let manifestDesc = try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: newManifest,
|
||||
baseDescriptor: manifestDescriptor!
|
||||
)
|
||||
|
||||
// Push the index.
|
||||
let newIndex = Index(
|
||||
schemaVersion: index.schemaVersion,
|
||||
mediaType: index.mediaType,
|
||||
manifests: [manifestDesc],
|
||||
annotations: index.annotations
|
||||
)
|
||||
try await self.pushDescriptor(
|
||||
client: client,
|
||||
name: name,
|
||||
ref: ref,
|
||||
content: newIndex,
|
||||
baseDescriptor: indexDescriptor
|
||||
)
|
||||
}
|
||||
|
||||
@Test func resolveWithRetry() async throws {
|
||||
let counter = Mutex(0)
|
||||
let client = RegistryClient(
|
||||
host: "ghcr.io",
|
||||
retryOptions: RetryOptions(
|
||||
maxRetries: 3,
|
||||
retryInterval: 500_000_000,
|
||||
shouldRetry: ({ response in
|
||||
if response.status == .notFound {
|
||||
counter.withLock { $0 += 1 }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
)
|
||||
)
|
||||
do {
|
||||
_ = try await client.resolve(name: "containerization/not-exists", tag: "foo")
|
||||
} catch {
|
||||
#expect(counter.withLock { $0 } <= 3)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: private functions
|
||||
|
||||
static var hasRegistryCredentials: Bool {
|
||||
authentication != nil
|
||||
}
|
||||
|
||||
static var authentication: Authentication? {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
guard let password = env["REGISTRY_TOKEN"],
|
||||
let username = env["REGISTRY_USERNAME"]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return BasicAuthentication(username: username, password: password)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func pushDescriptor<T: Encodable>(
|
||||
client: RegistryClient,
|
||||
name: String,
|
||||
ref: String,
|
||||
content: T,
|
||||
baseDescriptor: Descriptor
|
||||
) async throws -> Descriptor {
|
||||
let encoded = try self.encoder.encode(content)
|
||||
let digest = SHA256.hash(data: encoded)
|
||||
let descriptor = Descriptor(
|
||||
mediaType: baseDescriptor.mediaType,
|
||||
digest: digest.digest,
|
||||
size: Int64(encoded.count),
|
||||
urls: baseDescriptor.urls,
|
||||
annotations: baseDescriptor.annotations,
|
||||
platform: baseDescriptor.platform
|
||||
)
|
||||
let generator = {
|
||||
let stream = ReadStream(data: encoded)
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
|
||||
try await client.push(
|
||||
name: name,
|
||||
ref: ref,
|
||||
descriptor: descriptor,
|
||||
streamGenerator: generator,
|
||||
progress: nil
|
||||
)
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
extension OutputStream {
|
||||
fileprivate func withThrowingOpeningStream(_ closure: () async throws -> Void) async throws {
|
||||
self.open()
|
||||
defer { self.close() }
|
||||
|
||||
try await closure()
|
||||
}
|
||||
}
|
||||
|
||||
extension SHA256.Digest {
|
||||
fileprivate var digest: String {
|
||||
let parts = self.description.split(separator: ": ")
|
||||
return "sha256:\(parts[1])"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user