Skip to content

Commit 108db95

Browse files
Fix image push tests permissions in CI
1 parent 28b97f2 commit 108db95

File tree

3 files changed

+402
-103
lines changed

3 files changed

+402
-103
lines changed

Sources/ContainerizationOCI/Descriptor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import Foundation
2121
/// Descriptor describes the disposition of targeted content.
2222
/// This structure provides `application/vnd.oci.descriptor.v1+json` mediatype
2323
/// when marshalled to JSON.
24-
public struct Descriptor: Codable, Sendable, Equatable {
24+
public struct Descriptor: Codable, Sendable, Equatable, Hashable {
2525
/// mediaType is the media type of the object this schema refers to.
2626
public let mediaType: String
2727

Tests/ContainerizationOCITests/RegistryClientTests.swift

Lines changed: 243 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import ContainerizationIO
2121
import Crypto
2222
import Foundation
2323
import NIO
24+
import NIOCore
2425
import Synchronization
2526
import Testing
2627

@@ -166,113 +167,125 @@ struct OCIClientTests: ~Copyable {
166167
#expect(done)
167168
}
168169

169-
@Test(.disabled("External users cannot push images, disable while we find a better solution"))
170-
func pushIndex() async throws {
171-
let client = RegistryClient(host: "ghcr.io", authentication: Self.authentication)
172-
let indexDescriptor = try await client.resolve(name: "apple/containerization/emptyimage", tag: "0.0.1")
173-
let index: Index = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: indexDescriptor)
174-
175-
let platform = Platform(arch: "amd64", os: "linux")
176-
177-
var manifestDescriptor: Descriptor?
178-
for m in index.manifests where m.platform == platform {
179-
manifestDescriptor = m
180-
break
181-
}
182-
183-
#expect(manifestDescriptor != nil)
184-
185-
let manifest: Manifest = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifestDescriptor!)
186-
let imgConfig: Image = try await client.fetch(name: "apple/containerization/emptyimage", descriptor: manifest.config)
187-
188-
let layer = try #require(manifest.layers.first)
189-
let blobPath = contentPath.appendingPathComponent(layer.digest)
190-
let outputStream = OutputStream(toFileAtPath: blobPath.path, append: false)
191-
#expect(outputStream != nil)
170+
@Test func pushIndexWithMock() async throws {
171+
// Create a mock client for testing push operations
172+
let mockClient = MockRegistryClient()
173+
174+
// Create test data for an index and its components
175+
let testLayerData = "test layer content".data(using: .utf8)!
176+
let layerDigest = SHA256.hash(data: testLayerData)
177+
let layerDescriptor = Descriptor(
178+
mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
179+
digest: "sha256:\(layerDigest.hexString)",
180+
size: Int64(testLayerData.count)
181+
)
192182

193-
try await outputStream!.withThrowingOpeningStream {
194-
try await client.fetchBlob(name: "apple/containerization/emptyimage", descriptor: layer) { (expected, body) in
195-
var received: Int64 = 0
196-
for try await buffer in body {
197-
received += Int64(buffer.readableBytes)
183+
// Create test image config
184+
let imageConfig = Image(
185+
architecture: "amd64",
186+
os: "linux",
187+
config: ImageConfig(labels: ["test": "value"]),
188+
rootfs: Rootfs(type: "layers", diffIDs: ["sha256:\(layerDigest.hexString)"])
189+
)
190+
let configData = try JSONEncoder().encode(imageConfig)
191+
let configDigest = SHA256.hash(data: configData)
192+
let configDescriptor = Descriptor(
193+
mediaType: "application/vnd.docker.container.image.v1+json",
194+
digest: "sha256:\(configDigest.hexString)",
195+
size: Int64(configData.count)
196+
)
198197

199-
buffer.withUnsafeReadableBytes { pointer in
200-
let unsafeBufferPointer = pointer.bindMemory(to: UInt8.self)
201-
if let addr = unsafeBufferPointer.baseAddress {
202-
outputStream!.write(addr, maxLength: buffer.readableBytes)
203-
}
204-
}
205-
}
198+
// Create test manifest
199+
let manifest = Manifest(
200+
schemaVersion: 2,
201+
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
202+
config: configDescriptor,
203+
layers: [layerDescriptor]
204+
)
205+
let manifestData = try JSONEncoder().encode(manifest)
206+
let manifestDigest = SHA256.hash(data: manifestData)
207+
let manifestDescriptor = Descriptor(
208+
mediaType: "application/vnd.docker.distribution.manifest.v2+json",
209+
digest: "sha256:\(manifestDigest.hexString)",
210+
size: Int64(manifestData.count),
211+
platform: Platform(arch: "amd64", os: "linux")
212+
)
206213

207-
#expect(received == expected)
208-
}
209-
}
214+
// Create test index
215+
let index = Index(
216+
schemaVersion: 2,
217+
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
218+
manifests: [manifestDescriptor]
219+
)
210220

211-
let name = "apple/test-images/image-push"
221+
let name = "test/image"
212222
let ref = "latest"
213223

214-
// Push the layer first.
215-
do {
216-
let content = try LocalContent(path: blobPath)
217-
let generator = {
218-
let stream = try ReadStream(url: content.path)
219-
try stream.reset()
220-
return stream.stream
221-
}
222-
try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
223-
} catch let err as ContainerizationError {
224-
guard err.code == .exists else {
225-
throw err
226-
}
227-
}
224+
// Test pushing individual components using the mock client
228225

229-
// Push the image configuration.
230-
var imgConfigDesc: Descriptor?
231-
do {
232-
imgConfigDesc = try await self.pushDescriptor(
233-
client: client,
234-
name: name,
235-
ref: ref,
236-
content: imgConfig,
237-
baseDescriptor: manifest.config
238-
)
239-
} catch let err as ContainerizationError {
240-
guard err.code != .exists else {
241-
return
242-
}
243-
throw err
244-
}
226+
// Push layer
227+
let layerStream = TestByteBufferSequence(data: testLayerData)
228+
try await mockClient.push(
229+
name: name,
230+
ref: ref,
231+
descriptor: layerDescriptor,
232+
streamGenerator: { layerStream },
233+
progress: nil as ProgressHandler?
234+
)
245235

246-
// Push the image manifest.
247-
let newManifest = Manifest(
248-
schemaVersion: manifest.schemaVersion,
249-
mediaType: manifest.mediaType!,
250-
config: imgConfigDesc!,
251-
layers: manifest.layers,
252-
annotations: manifest.annotations
236+
// Push config
237+
let configStream = TestByteBufferSequence(data: configData)
238+
try await mockClient.push(
239+
name: name,
240+
ref: ref,
241+
descriptor: configDescriptor,
242+
streamGenerator: { configStream },
243+
progress: nil as ProgressHandler?
253244
)
254-
let manifestDesc = try await self.pushDescriptor(
255-
client: client,
245+
246+
// Push manifest
247+
let manifestStream = TestByteBufferSequence(data: manifestData)
248+
try await mockClient.push(
256249
name: name,
257250
ref: ref,
258-
content: newManifest,
259-
baseDescriptor: manifestDescriptor!
251+
descriptor: manifestDescriptor,
252+
streamGenerator: { manifestStream },
253+
progress: nil as ProgressHandler?
260254
)
261255

262-
// Push the index.
263-
let newIndex = Index(
264-
schemaVersion: index.schemaVersion,
265-
mediaType: index.mediaType,
266-
manifests: [manifestDesc],
267-
annotations: index.annotations
256+
// Push index
257+
let indexData = try JSONEncoder().encode(index)
258+
let indexDigest = SHA256.hash(data: indexData)
259+
let indexDescriptor = Descriptor(
260+
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
261+
digest: "sha256:\(indexDigest.hexString)",
262+
size: Int64(indexData.count)
268263
)
269-
try await self.pushDescriptor(
270-
client: client,
264+
265+
let indexStream = TestByteBufferSequence(data: indexData)
266+
try await mockClient.push(
271267
name: name,
272268
ref: ref,
273-
content: newIndex,
274-
baseDescriptor: indexDescriptor
269+
descriptor: indexDescriptor,
270+
streamGenerator: { indexStream },
271+
progress: nil as ProgressHandler?
275272
)
273+
274+
// Verify all push operations were recorded
275+
#expect(mockClient.pushCalls.count == 4)
276+
277+
// Verify content integrity
278+
let storedLayerData = mockClient.getPushedContent(name: name, descriptor: layerDescriptor)
279+
#expect(storedLayerData == testLayerData)
280+
281+
let storedConfigData = mockClient.getPushedContent(name: name, descriptor: configDescriptor)
282+
#expect(storedConfigData == configData)
283+
284+
let storedManifestData = mockClient.getPushedContent(name: name, descriptor: manifestDescriptor)
285+
#expect(storedManifestData == manifestData)
286+
287+
let storedIndexData = mockClient.getPushedContent(name: name, descriptor: indexDescriptor)
288+
#expect(storedIndexData == indexData)
276289
}
277290

278291
@Test func resolveWithRetry() async throws {
@@ -343,7 +356,7 @@ struct OCIClientTests: ~Copyable {
343356
ref: ref,
344357
descriptor: descriptor,
345358
streamGenerator: generator,
346-
progress: nil
359+
progress: nil as ProgressHandler?
347360
)
348361
return descriptor
349362
}
@@ -363,4 +376,143 @@ extension SHA256.Digest {
363376
let parts = self.description.split(separator: ": ")
364377
return "sha256:\(parts[1])"
365378
}
379+
380+
var hexString: String {
381+
self.compactMap { String(format: "%02x", $0) }.joined()
382+
}
383+
}
384+
385+
// Helper to create ByteBuffer sequences for testing
386+
struct TestByteBufferSequence: Sendable, AsyncSequence {
387+
typealias Element = ByteBuffer
388+
389+
private let data: Data
390+
391+
init(data: Data) {
392+
self.data = data
393+
}
394+
395+
func makeAsyncIterator() -> AsyncIterator {
396+
AsyncIterator(data: data)
397+
}
398+
399+
struct AsyncIterator: AsyncIteratorProtocol {
400+
private let data: Data
401+
private var sent = false
402+
403+
init(data: Data) {
404+
self.data = data
405+
}
406+
407+
mutating func next() async throws -> ByteBuffer? {
408+
guard !sent else { return nil }
409+
sent = true
410+
411+
var buffer = ByteBufferAllocator().buffer(capacity: data.count)
412+
buffer.writeBytes(data)
413+
return buffer
414+
}
415+
}
416+
}
417+
418+
// Helper class to create a mock ContentClient for testing
419+
final class MockRegistryClient: ContentClient, @unchecked Sendable {
420+
private var pushedContent: [String: [Descriptor: Data]] = [:]
421+
private var fetchableContent: [String: [Descriptor: Data]] = [:]
422+
423+
// Track push operations for verification
424+
var pushCalls: [(name: String, ref: String, descriptor: Descriptor)] = []
425+
426+
func addFetchableContent<T: Codable>(name: String, descriptor: Descriptor, content: T) throws {
427+
let data = try JSONEncoder().encode(content)
428+
if fetchableContent[name] == nil {
429+
fetchableContent[name] = [:]
430+
}
431+
fetchableContent[name]![descriptor] = data
432+
}
433+
434+
func addFetchableData(name: String, descriptor: Descriptor, data: Data) {
435+
if fetchableContent[name] == nil {
436+
fetchableContent[name] = [:]
437+
}
438+
fetchableContent[name]![descriptor] = data
439+
}
440+
441+
func getPushedContent(name: String, descriptor: Descriptor) -> Data? {
442+
pushedContent[name]?[descriptor]
443+
}
444+
445+
// MARK: - ContentClient Implementation
446+
447+
func fetch<T: Codable>(name: String, descriptor: Descriptor) async throws -> T {
448+
guard let imageContent = fetchableContent[name],
449+
let data = imageContent[descriptor]
450+
else {
451+
throw ContainerizationError(.notFound, message: "Content not found for \(name) with descriptor \(descriptor.digest)")
452+
}
453+
454+
return try JSONDecoder().decode(T.self, from: data)
455+
}
456+
457+
func fetchBlob(name: String, descriptor: Descriptor, into file: URL, progress: ProgressHandler?) async throws -> (Int64, SHA256.Digest) {
458+
guard let imageContent = fetchableContent[name],
459+
let data = imageContent[descriptor]
460+
else {
461+
throw ContainerizationError(.notFound, message: "Blob not found for \(name) with descriptor \(descriptor.digest)")
462+
}
463+
464+
try data.write(to: file)
465+
let digest = SHA256.hash(data: data)
466+
return (Int64(data.count), digest)
467+
}
468+
469+
func fetchData(name: String, descriptor: Descriptor) async throws -> Data {
470+
guard let imageContent = fetchableContent[name],
471+
let data = imageContent[descriptor]
472+
else {
473+
throw ContainerizationError(.notFound, message: "Data not found for \(name) with descriptor \(descriptor.digest)")
474+
}
475+
476+
return data
477+
}
478+
479+
func push<T: Sendable & AsyncSequence>(
480+
name: String,
481+
ref: String,
482+
descriptor: Descriptor,
483+
streamGenerator: () throws -> T,
484+
progress: ProgressHandler?
485+
) async throws where T.Element == ByteBuffer {
486+
// Record the push call for verification
487+
pushCalls.append((name: name, ref: ref, descriptor: descriptor))
488+
489+
// Simulate reading the stream and storing the data
490+
let stream = try streamGenerator()
491+
var data = Data()
492+
493+
for try await buffer in stream {
494+
data.append(contentsOf: buffer.readableBytesView)
495+
}
496+
497+
// Verify the pushed data matches the expected descriptor
498+
let actualDigest = SHA256.hash(data: data)
499+
guard descriptor.digest == "sha256:\(actualDigest.hexString)" else {
500+
throw ContainerizationError(.invalidArgument, message: "Digest mismatch: expected \(descriptor.digest), got sha256:\(actualDigest.hexString)")
501+
}
502+
503+
guard data.count == descriptor.size else {
504+
throw ContainerizationError(.invalidArgument, message: "Size mismatch: expected \(descriptor.size), got \(data.count)")
505+
}
506+
507+
// Store the pushed content
508+
if pushedContent[name] == nil {
509+
pushedContent[name] = [:]
510+
}
511+
pushedContent[name]![descriptor] = data
512+
513+
// Simulate progress reporting
514+
if let progress = progress {
515+
await progress(Int64(data.count), Int64(data.count))
516+
}
517+
}
366518
}

0 commit comments

Comments
 (0)