Files
container/Tests/ContainerizationOCITests/RegistryClientTests.swift
T
Kathryn Baldauf 7c063dfbe4 Remove most uses of env variables for image registries (#100)
When we were testing the repo, we added authentication to most of the
registry tests so that we could access private images. Now that the
images are public, this PR removes the use of REGISTRY_TOKEN and
REGISTRY_USERNAME in tests that no longer require authenticating.

Note: REGISTRY_TOKEN, REGISTRY_USERNAME, and REGISTRY_HOST are still
required in the CI to push images and for a few registry tests. So the
env variables are not completely removed. The variables are only used in
the test tool cctl and in our swift tests.

Signed-off-by: Kathryn Baldauf <k_baldauf@apple.com>
2025-06-12 10:16:43 -04:00

360 lines
13 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025 Apple Inc. and the Containerization 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 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 func ping() async throws {
let client = RegistryClient(host: "registry-1.docker.io")
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() }
if case .invalidStatus = error {
} else {
Issue.record("encountered unexpected error \(error)")
}
}
@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: "conatinerization/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])"
}
}