680845cb1c
Linux build / Determine Swift version (push) Waiting to run
Linux build / Linux compile check (push) Blocked by required conditions
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
507 lines
20 KiB
Swift
507 lines
20 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 NIOPosix
|
|
import Testing
|
|
|
|
@testable import CloudHypervisor
|
|
|
|
@Suite("CloudHypervisor.Client")
|
|
struct ClientTests {
|
|
private static let group = MultiThreadedEventLoopGroup.singleton
|
|
|
|
// MARK: - Init
|
|
|
|
@Test("Client init succeeds with file:// URL")
|
|
func initSucceeds() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let socketURL = URL(filePath: server.socketPath)
|
|
let _ = try CloudHypervisor.Client(
|
|
socketPath: socketURL,
|
|
eventLoopGroup: Self.group
|
|
)
|
|
}
|
|
|
|
// MARK: - Invalid socket path
|
|
|
|
@Test("Client init throws .invalidSocketPath for non-file URL")
|
|
func initThrowsForNonFileURL() throws {
|
|
let url = try #require(URL(string: "https://example.com"))
|
|
#expect(throws: CloudHypervisor.Error.self) {
|
|
try CloudHypervisor.Client(socketPath: url, eventLoopGroup: Self.group)
|
|
}
|
|
}
|
|
|
|
// MARK: - Non-2xx response
|
|
|
|
@Test("Non-2xx response throws .http with correct status")
|
|
func non2xxThrowsHTTPError() async throws {
|
|
let body = Data("not found".utf8)
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.notFound, body: body)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let socketURL = URL(filePath: server.socketPath)
|
|
let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group)
|
|
|
|
struct Dummy: Decodable, Sendable {}
|
|
|
|
do {
|
|
let _: Dummy = try await client.get("/api/v1/missing")
|
|
Issue.record("Expected .http error but call succeeded")
|
|
} catch let err as CloudHypervisor.Error {
|
|
guard case .http(let status, let respBody) = err else {
|
|
Issue.record("Expected .http, got \(err)")
|
|
return
|
|
}
|
|
#expect(status == .notFound)
|
|
#expect(respBody == body)
|
|
} catch {
|
|
Issue.record("Expected CloudHypervisor.Error but got \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - vmmPing
|
|
|
|
@Test("vmmPing sends GET /api/v1/vmm.ping and decodes VmmPingResponse")
|
|
func vmmPing() async throws {
|
|
let expected = CloudHypervisor.VmmPingResponse(version: "v40.0", pid: 12345)
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmmPing()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .GET)
|
|
#expect(recorded[0].uri == "/api/v1/vmm.ping")
|
|
#expect(recorded[0].body.isEmpty)
|
|
#expect(result.version == "v40.0")
|
|
#expect(result.pid == 12345)
|
|
}
|
|
|
|
// MARK: - vmmShutdown
|
|
|
|
@Test("vmmShutdown sends PUT /api/v1/vmm.shutdown and returns without throwing")
|
|
func vmmShutdown() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmmShutdown()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vmm.shutdown")
|
|
}
|
|
|
|
// MARK: - vmmInfo
|
|
|
|
@Test("vmmInfo sends GET /api/v1/vmm.info and decodes VmmInfo")
|
|
func vmmInfo() async throws {
|
|
let expected = CloudHypervisor.VmmInfo(version: "v40.0", pid: 99)
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmmInfo()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .GET)
|
|
#expect(recorded[0].uri == "/api/v1/vmm.info")
|
|
#expect(result.version == "v40.0")
|
|
}
|
|
|
|
// MARK: - vmCreate
|
|
|
|
@Test("vmCreate sends PUT /api/v1/vm.create with encoded body")
|
|
func vmCreate() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let config = CloudHypervisor.VmConfig(
|
|
cpus: .init(bootVcpus: 2, maxVcpus: 4),
|
|
memory: .init(size: 512 * 1024 * 1024),
|
|
payload: .init(kernel: "/boot/vmlinux"),
|
|
console: .init(mode: .Null),
|
|
serial: .init(mode: .Tty)
|
|
)
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmCreate(config)
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.create")
|
|
|
|
let decoded = try JSONDecoder().decode(CloudHypervisor.VmConfig.self, from: recorded[0].body)
|
|
#expect(decoded == config)
|
|
}
|
|
|
|
// MARK: - vmBoot
|
|
|
|
@Test("vmBoot sends PUT /api/v1/vm.boot with no body")
|
|
func vmBoot() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmBoot()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.boot")
|
|
#expect(recorded[0].body.isEmpty)
|
|
}
|
|
|
|
// Regression: cloud-hypervisor's HTTP parser rejects body-less PUTs
|
|
// unless they carry an explicit `Content-Length: 0`. With the
|
|
// AsyncHTTPClient transport, that wire shape is produced by
|
|
// assigning `request.body = .bytes(ByteBuffer())` so AHC's
|
|
// RequestValidation re-derives framing as `known(0)` per RFC 7230
|
|
// §3.3.2. This test asserts the on-the-wire result rather than how
|
|
// it's produced, so any future transport change that drops the
|
|
// empty-body framing surfaces here.
|
|
@Test("Body-less PUT sends Content-Length: 0 with empty body")
|
|
func bodylessPUTSendsContentLengthZero() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(
|
|
socketPath: URL(filePath: server.socketPath),
|
|
eventLoopGroup: Self.group
|
|
)
|
|
try await client.vmBoot()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
let req = try #require(recorded.first)
|
|
#expect(req.method == .PUT)
|
|
#expect(req.uri == "/api/v1/vm.boot")
|
|
#expect(req.body.isEmpty)
|
|
#expect(req.headers["Content-Length"].first == "0")
|
|
}
|
|
|
|
// MARK: - vmShutdown
|
|
|
|
@Test("vmShutdown sends PUT /api/v1/vm.shutdown with no body")
|
|
func vmShutdown() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmShutdown()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.shutdown")
|
|
#expect(recorded[0].body.isEmpty)
|
|
}
|
|
|
|
// MARK: - vmInfo
|
|
|
|
@Test("vmInfo sends GET /api/v1/vm.info and decodes VmInfo")
|
|
func vmInfo() async throws {
|
|
let expectedConfig = CloudHypervisor.VmConfig(
|
|
cpus: .init(bootVcpus: 1, maxVcpus: 1),
|
|
memory: .init(size: 256 * 1024 * 1024),
|
|
payload: .init(kernel: "/boot/vmlinux"),
|
|
console: .init(mode: .Null),
|
|
serial: .init(mode: .Null)
|
|
)
|
|
let expected = CloudHypervisor.VmInfo(config: expectedConfig, state: .Running)
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(expected)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmInfo()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .GET)
|
|
#expect(recorded[0].uri == "/api/v1/vm.info")
|
|
#expect(recorded[0].body.isEmpty)
|
|
#expect(result == expected)
|
|
}
|
|
|
|
// MARK: - vmPause
|
|
|
|
@Test("vmPause sends PUT /api/v1/vm.pause with no body")
|
|
func vmPause() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmPause()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.pause")
|
|
#expect(recorded[0].body.isEmpty)
|
|
}
|
|
|
|
// MARK: - vmResume
|
|
|
|
@Test("vmResume sends PUT /api/v1/vm.resume with no body")
|
|
func vmResume() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmResume()
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.resume")
|
|
#expect(recorded[0].body.isEmpty)
|
|
}
|
|
|
|
// MARK: - vmAddDisk
|
|
|
|
@Test("vmAddDisk sends PUT /api/v1/vm.add-disk and returns PciDeviceInfo")
|
|
func vmAddDisk() async throws {
|
|
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let config = CloudHypervisor.DiskConfig(path: "/tmp/disk.img", readonly: true, id: "_disk0")
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmAddDisk(config)
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.add-disk")
|
|
|
|
let decoded = try JSONDecoder().decode(CloudHypervisor.DiskConfig.self, from: recorded[0].body)
|
|
#expect(decoded == config)
|
|
#expect(result == pciInfo)
|
|
}
|
|
|
|
// MARK: - vmAddFs
|
|
|
|
@Test("vmAddFs sends PUT /api/v1/vm.add-fs and returns PciDeviceInfo")
|
|
func vmAddFs() async throws {
|
|
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let config = CloudHypervisor.FsConfig(tag: "myfs", socket: "/tmp/virtiofsd.sock", id: "_fs0")
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmAddFs(config)
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.add-fs")
|
|
|
|
let decoded = try JSONDecoder().decode(CloudHypervisor.FsConfig.self, from: recorded[0].body)
|
|
#expect(decoded == config)
|
|
#expect(result == pciInfo)
|
|
}
|
|
|
|
// MARK: - vmAddNet
|
|
|
|
@Test("vmAddNet sends PUT /api/v1/vm.add-net and returns PciDeviceInfo")
|
|
func vmAddNet() async throws {
|
|
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let config = CloudHypervisor.NetConfig(tap: "tap0", mac: "AA:BB:CC:DD:EE:FF", id: "_net0")
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmAddNet(config)
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.add-net")
|
|
|
|
let decoded = try JSONDecoder().decode(CloudHypervisor.NetConfig.self, from: recorded[0].body)
|
|
#expect(decoded == config)
|
|
#expect(result == pciInfo)
|
|
}
|
|
|
|
// MARK: - vmAddVsock
|
|
|
|
@Test("vmAddVsock sends PUT /api/v1/vm.add-vsock and returns PciDeviceInfo")
|
|
func vmAddVsock() async throws {
|
|
let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0")
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
(try? StubResponse.json(pciInfo)) ?? StubResponse.ok()
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let config = CloudHypervisor.VsockConfig(cid: 3, socket: "/tmp/vsock.sock", id: "_vsock0")
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
let result = try await client.vmAddVsock(config)
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.add-vsock")
|
|
|
|
let decoded = try JSONDecoder().decode(CloudHypervisor.VsockConfig.self, from: recorded[0].body)
|
|
#expect(decoded == config)
|
|
#expect(result == pciInfo)
|
|
}
|
|
|
|
// MARK: - vmRemoveDevice
|
|
|
|
@Test("vmRemoveDevice sends PUT /api/v1/vm.remove-device with id body")
|
|
func vmRemoveDevice() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group)
|
|
try await client.vmRemoveDevice(id: "_disk0")
|
|
|
|
let recorded = server.recordedRequests()
|
|
#expect(recorded.count == 1)
|
|
#expect(recorded[0].method == .PUT)
|
|
#expect(recorded[0].uri == "/api/v1/vm.remove-device")
|
|
|
|
struct RemoveRequest: Decodable { let id: String }
|
|
let decoded = try JSONDecoder().decode(RemoveRequest.self, from: recorded[0].body)
|
|
#expect(decoded.id == "_disk0")
|
|
}
|
|
|
|
// MARK: - Malformed JSON
|
|
|
|
@Test("Malformed JSON on 200 response throws .decoding")
|
|
func malformedJSONThrowsDecoding() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.ok(Data("not json".utf8))
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let socketURL = URL(filePath: server.socketPath)
|
|
let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group)
|
|
|
|
struct Dummy: Decodable, Sendable {}
|
|
|
|
do {
|
|
let _: Dummy = try await client.get("/api/v1/vmm.info")
|
|
Issue.record("Expected .decoding error but call succeeded")
|
|
} catch let err as CloudHypervisor.Error {
|
|
guard case .decoding = err else {
|
|
Issue.record("Expected .decoding, got \(err)")
|
|
return
|
|
}
|
|
// Expected path — decoding error correctly surfaced.
|
|
} catch {
|
|
Issue.record("Expected CloudHypervisor.Error but got \(error)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Shutdown ordering
|
|
|
|
/// Regression: with a caller-supplied group, `Client.shutdown()` must
|
|
/// drain the underlying HTTPClient before the caller tears the group
|
|
/// down. Without this, AsyncHTTPClient's deferred connection-cleanup
|
|
/// runs on the (now-dead) event loops and SwiftNIO prints
|
|
/// "Cannot schedule tasks on an EventLoop that has already shut down".
|
|
/// The singleton group used by the rest of this suite can't surface
|
|
/// the bug because it never shuts down, so we spin up a dedicated
|
|
/// group for the client here. The server stays on the singleton so
|
|
/// only the client-side AHC channels are at risk when we shut the
|
|
/// owned group down — otherwise the server's own pipeline cleanup
|
|
/// would race the same group teardown and confound the test.
|
|
@Test("Client.shutdown drains HTTPClient before a caller-owned group is torn down")
|
|
func shutdownDrainsHTTPClientBeforeGroup() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
let clientGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2)
|
|
let client = try CloudHypervisor.Client(
|
|
socketPath: URL(filePath: server.socketPath),
|
|
eventLoopGroup: clientGroup
|
|
)
|
|
// Round-trip a real request so AHC actually opens a connection
|
|
// and parks its post-response cleanup on `clientGroup`.
|
|
try await client.vmmShutdown()
|
|
|
|
try await client.shutdown()
|
|
// Idempotent — a second call must not throw.
|
|
try await client.shutdown()
|
|
|
|
// The owned group should now be safe to tear down without NIO
|
|
// warnings.
|
|
try await clientGroup.shutdownGracefully()
|
|
}
|
|
|
|
@Test("Client.shutdown also tears down the group when the client owns it")
|
|
func shutdownOwnsGroup() async throws {
|
|
let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in
|
|
StubResponse.status(.noContent)
|
|
}
|
|
defer { Task { try? await server.shutdown() } }
|
|
|
|
// No eventLoopGroup → client owns its own.
|
|
let client = try CloudHypervisor.Client(
|
|
socketPath: URL(filePath: server.socketPath)
|
|
)
|
|
try await client.vmmShutdown()
|
|
try await client.shutdown()
|
|
// Idempotent.
|
|
try await client.shutdown()
|
|
}
|
|
}
|