diff --git a/Sources/tart/OCI/Manifest.swift b/Sources/tart/OCI/Manifest.swift index 4d6bd61..56f7a80 100644 --- a/Sources/tart/OCI/Manifest.swift +++ b/Sources/tart/OCI/Manifest.swift @@ -3,26 +3,26 @@ import Foundation let ociManifestMediaType = "application/vnd.oci.image.manifest.v1+json" let ociConfigMediaType = "application/vnd.oci.image.config.v1+json" -struct OCIManifest: Encodable, Decodable { +struct OCIManifest: Codable, Equatable { var schemaVersion: Int = 2 var mediaType: String = ociManifestMediaType var config: OCIManifestConfig var layers: [OCIManifestLayer] = Array() } -struct OCIManifestConfig: Encodable, Decodable { +struct OCIManifestConfig: Codable, Equatable { var mediaType: String = ociConfigMediaType var size: Int var digest: String } -struct OCIManifestLayer: Encodable, Decodable { +struct OCIManifestLayer: Codable, Equatable { var mediaType: String var size: Int var digest: String } -struct Descriptor { +struct Descriptor: Equatable { var size: Int var digest: String } diff --git a/Sources/tart/OCI/Registry.swift b/Sources/tart/OCI/Registry.swift index 88ae52e..68c6b87 100644 --- a/Sources/tart/OCI/Registry.swift +++ b/Sources/tart/OCI/Registry.swift @@ -60,19 +60,29 @@ class Registry { var currentAuthToken: TokenResponse? = nil - init(host: String, namespace: String) throws { + init(urlComponents: URLComponents, namespace: String) throws { + baseURL = urlComponents.url! + self.namespace = namespace + } + + convenience init(host: String, namespace: String) throws { var baseURLComponents = URLComponents() + baseURLComponents.scheme = "https" baseURLComponents.host = host baseURLComponents.path = "/v2/" - baseURL = baseURLComponents.url! - self.namespace = namespace + try self.init(urlComponents: baseURLComponents, namespace: namespace) } - func pushManifest(reference: String, config: Descriptor, layers: [OCIManifestLayer]) async throws -> String { - let manifest = OCIManifest(config: OCIManifestConfig(size: config.size, digest: config.digest), - layers: layers) + func ping() async throws { + let (_, response) = try await endpointRequest("GET", "/v2/") + if response.statusCode != 200 { + throw RegistryError.UnexpectedHTTPStatusCode(when: "doing ping", code: response.statusCode) + } + } + + func pushManifest(reference: String, manifest: OCIManifest) async throws -> String { let manifestJSON = try JSONEncoder().encode(manifest) let (responseData, response) = try await endpointRequest("PUT", "\(namespace)/manifests/\(reference)", diff --git a/Sources/tart/VMDirectory+OCI.swift b/Sources/tart/VMDirectory+OCI.swift index 32c162e..ce23d5d 100644 --- a/Sources/tart/VMDirectory+OCI.swift +++ b/Sources/tart/VMDirectory+OCI.swift @@ -120,20 +120,23 @@ extension VMDirectory { layers.append(OCIManifestLayer(mediaType: Self.nvramMediaType, size: nvram.count, digest: nvramDigest)) // Craft a stub OCI config for Docker Hub compatibility - struct OCIConfig: Encodable, Decodable { + struct OCIConfig: Codable { var architecture: String = "arm64" var os: String = "darwin" } let ociConfigJSON = try JSONEncoder().encode(OCIConfig()) let ociConfigDigest = try await registry.pushBlob(fromData: ociConfigJSON) - let ociConfigDescriptor = Descriptor(size: ociConfigJSON.count, digest: ociConfigDigest) + let manifest = OCIManifest( + config: OCIManifestConfig(size: ociConfigJSON.count, digest: ociConfigDigest), + layers: layers + ) // Manifest for reference in references { defaultLogger.appendNewLine("pushing manifest for \(reference)...") - _ = try await registry.pushManifest(reference: reference, config: ociConfigDescriptor, layers: layers) + _ = try await registry.pushManifest(reference: reference, manifest: manifest) } } } diff --git a/Tests/TartTests/RegistryTests.swift b/Tests/TartTests/RegistryTests.swift new file mode 100644 index 0000000..d56a835 --- /dev/null +++ b/Tests/TartTests/RegistryTests.swift @@ -0,0 +1,87 @@ +import XCTest +@testable import tart + +final class RegistryTests: XCTestCase { + var registryRunner: RegistryRunner? + + override func setUp() async throws { + try await super.setUp() + + do { + registryRunner = try await RegistryRunner() + } catch { + try XCTSkipIf(ProcessInfo.processInfo.environment["CI"] == nil) + } + } + + override func tearDown() async throws { + try await super.tearDown() + + registryRunner = nil + } + + var registry: Registry { + registryRunner!.registry + } + + func testPushPullBlobSmall() async throws { + // Generate a simple blob + let pushedBlob = Data("The quick brown fox jumps over the lazy dog".utf8) + + // Push it + let pushedBlobDigest = try await registry.pushBlob(fromData: pushedBlob) + XCTAssertEqual("sha256:d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", pushedBlobDigest) + + // Pull it + let pulledBlob = try await registry.pullBlob(pushedBlobDigest) + + // Ensure that both blobs are identical + XCTAssertEqual(pushedBlob, pulledBlob) + } + + func testPushPullBlobHuge() async throws { + // Generate a large enough blob + let fh = FileHandle(forReadingAtPath: "/dev/urandom")! + let largeBlobToPush = try fh.read(upToCount: 768 * 1024 * 1024)! + + // Push it + let largeBlobDigest = try await registry.pushBlob(fromData: largeBlobToPush) + + // Pull it + let pulledLargeBlob = try await registry.pullBlob(largeBlobDigest) + + // Ensure that both blobs are identical + XCTAssertEqual(largeBlobToPush, pulledLargeBlob) + } + + func testPushPullManifest() async throws { + // Craft a basic config + struct OCIConfig: Codable { + var architecture: String = "arm64" + var os: String = "darwin" + } + let configData = try JSONEncoder().encode(OCIConfig()) + let configDigest = try await registry.pushBlob(fromData: configData) + + // Craft a basic layer + let layerData = Data("doesn't matter".utf8) + let layerDigest = try await registry.pushBlob(fromData: layerData) + + // Craft a basic manifest and push it + let manifest = OCIManifest( + config: OCIManifestConfig(size: configData.count, digest: configDigest), + layers: [ + OCIManifestLayer(mediaType: "application/octet-stream", size: layerData.count, digest: layerDigest) + ] + ) + let pushedManifestDigest = try await registry.pushManifest(reference: "latest", manifest: manifest) + + // Ensure that the manifest pulled by tag matches with the one pushed above + let (pulledByTagManifest, _) = try await registry.pullManifest(reference: "latest") + XCTAssertEqual(manifest, pulledByTagManifest) + + // Ensure that the manifest pulled by digest matches with the one pushed above + let (pulledByDigestManifest, _) = try await registry.pullManifest(reference: "\(pushedManifestDigest)") + XCTAssertEqual(manifest, pulledByDigestManifest) + } +} diff --git a/Tests/TartTests/Util/RegistryRunner.swift b/Tests/TartTests/Util/RegistryRunner.swift new file mode 100644 index 0000000..1002201 --- /dev/null +++ b/Tests/TartTests/Util/RegistryRunner.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import tart + +enum RegistryRunnerError: Error { + case DockerFailed(exitCode: Int32) +} + +class RegistryRunner { + let containerID: String + let registry: Registry + + static func dockerCmd(_ arguments: String...) throws -> String { + let stdoutPipe = Pipe() + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/local/bin/docker") + proc.arguments = arguments + proc.standardOutput = stdoutPipe + try proc.run() + + let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + + proc.waitUntilExit() + + if proc.terminationStatus != 0 { + throw RegistryRunnerError.DockerFailed(exitCode: proc.terminationStatus) + } + + return String(data: stdoutData, encoding: .utf8) ?? "" + } + + init() async throws { + // Start container + let container = try Self.dockerCmd("run", "-d", "--rm", "-p", "5000", "registry:2") + .trimmingCharacters(in: CharacterSet.newlines) + containerID = container + + // Get forwarded port + let port = try Self.dockerCmd("inspect", containerID, "--format", "{{(index (index .NetworkSettings.Ports \"5000/tcp\") 0).HostPort}}") + .trimmingCharacters(in: CharacterSet.newlines) + + registry = try Registry(urlComponents: URLComponents(string: "http://127.0.0.1:\(port)/v2/")!, + namespace: "vm-image") + + // Wait for the Docker Registry to start + while ((try? await registry.ping()) == nil) { + try await Task.sleep(nanoseconds: 100_000_000) + } + } + + deinit { + _ = try! Self.dockerCmd("kill", containerID) + } +}