Skip to content

Commit 0b27707

Browse files
Fix image push tests permissions in CI
Replace disabled external registry push tests with mock-based tests that don't require registry write permissions. Changes: - Replace testImageStorePush() with testImageStorePushWithMock() - Replace pushIndex() with pushIndexWithMock() - Add MockRegistryClient implementing ContentClient protocol - Add testPush() extension method for dependency injection - Fix Makefile Swift path for environment compatibility This enables external contributors to run complete test suite without registry credentials. Resolves #56
1 parent 3cf8eee commit 0b27707

File tree

2 files changed

+396
-101
lines changed

2 files changed

+396
-101
lines changed

Tests/ContainerizationOCITests/RegistryClientTests.swift

Lines changed: 241 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -159,113 +159,125 @@ struct OCIClientTests: ~Copyable {
159159
#expect(done)
160160
}
161161

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

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

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

200-
#expect(received == expected)
201-
}
202-
}
206+
// Create test index
207+
let index = Index(
208+
schemaVersion: 2,
209+
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
210+
manifests: [manifestDescriptor]
211+
)
203212

204-
let name = "apple/test-images/image-push"
213+
let name = "test/image"
205214
let ref = "latest"
206215

207-
// Push the layer first.
208-
do {
209-
let content = try LocalContent(path: blobPath)
210-
let generator = {
211-
let stream = try ReadStream(url: content.path)
212-
try stream.reset()
213-
return stream.stream
214-
}
215-
try await client.push(name: name, ref: ref, descriptor: layer, streamGenerator: generator, progress: nil)
216-
} catch let err as ContainerizationError {
217-
guard err.code == .exists else {
218-
throw err
219-
}
220-
}
216+
// Test pushing individual components using the mock client
221217

222-
// Push the image configuration.
223-
var imgConfigDesc: Descriptor?
224-
do {
225-
imgConfigDesc = try await self.pushDescriptor(
226-
client: client,
227-
name: name,
228-
ref: ref,
229-
content: imgConfig,
230-
baseDescriptor: manifest.config
231-
)
232-
} catch let err as ContainerizationError {
233-
guard err.code != .exists else {
234-
return
235-
}
236-
throw err
237-
}
218+
// Push layer
219+
let layerStream = TestByteBufferSequence(data: testLayerData)
220+
try await mockClient.push(
221+
name: name,
222+
ref: ref,
223+
descriptor: layerDescriptor,
224+
streamGenerator: { layerStream },
225+
progress: nil
226+
)
238227

239-
// Push the image manifest.
240-
let newManifest = Manifest(
241-
schemaVersion: manifest.schemaVersion,
242-
mediaType: manifest.mediaType!,
243-
config: imgConfigDesc!,
244-
layers: manifest.layers,
245-
annotations: manifest.annotations
228+
// Push config
229+
let configStream = TestByteBufferSequence(data: configData)
230+
try await mockClient.push(
231+
name: name,
232+
ref: ref,
233+
descriptor: configDescriptor,
234+
streamGenerator: { configStream },
235+
progress: nil
246236
)
247-
let manifestDesc = try await self.pushDescriptor(
248-
client: client,
237+
238+
// Push manifest
239+
let manifestStream = TestByteBufferSequence(data: manifestData)
240+
try await mockClient.push(
249241
name: name,
250242
ref: ref,
251-
content: newManifest,
252-
baseDescriptor: manifestDescriptor!
243+
descriptor: manifestDescriptor,
244+
streamGenerator: { manifestStream },
245+
progress: nil
253246
)
254247

255-
// Push the index.
256-
let newIndex = Index(
257-
schemaVersion: index.schemaVersion,
258-
mediaType: index.mediaType,
259-
manifests: [manifestDesc],
260-
annotations: index.annotations
248+
// Push index
249+
let indexData = try JSONEncoder().encode(index)
250+
let indexDigest = SHA256.hash(data: indexData)
251+
let indexDescriptor = Descriptor(
252+
mediaType: "application/vnd.docker.distribution.manifest.list.v2+json",
253+
digest: "sha256:\(indexDigest.hexString)",
254+
size: Int64(indexData.count)
261255
)
262-
try await self.pushDescriptor(
263-
client: client,
256+
257+
let indexStream = TestByteBufferSequence(data: indexData)
258+
try await mockClient.push(
264259
name: name,
265260
ref: ref,
266-
content: newIndex,
267-
baseDescriptor: indexDescriptor
261+
descriptor: indexDescriptor,
262+
streamGenerator: { indexStream },
263+
progress: nil
268264
)
265+
266+
// Verify all push operations were recorded
267+
#expect(mockClient.pushCalls.count == 4)
268+
269+
// Verify content integrity
270+
let storedLayerData = mockClient.getPushedContent(name: name, descriptor: layerDescriptor)
271+
#expect(storedLayerData == testLayerData)
272+
273+
let storedConfigData = mockClient.getPushedContent(name: name, descriptor: configDescriptor)
274+
#expect(storedConfigData == configData)
275+
276+
let storedManifestData = mockClient.getPushedContent(name: name, descriptor: manifestDescriptor)
277+
#expect(storedManifestData == manifestData)
278+
279+
let storedIndexData = mockClient.getPushedContent(name: name, descriptor: indexDescriptor)
280+
#expect(storedIndexData == indexData)
269281
}
270282

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

0 commit comments

Comments
 (0)