chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
|
||||
#if os(macOS)
|
||||
/// A minimal NBD server for integration testing.
|
||||
///
|
||||
/// Serves a file-backed block device using the NBD newstyle handshake protocol.
|
||||
/// Supports both TCP and Unix domain socket transports.
|
||||
final class NBDServer: Sendable {
|
||||
private let channel: Channel
|
||||
private let socketPath: String?
|
||||
private let group: EventLoopGroup
|
||||
let url: String
|
||||
|
||||
init(filePath: String, socketPath: String, logger: Logger? = nil) throws {
|
||||
self.socketPath = socketPath
|
||||
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
|
||||
self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger)
|
||||
.bind(unixDomainSocketPath: socketPath)
|
||||
.wait()
|
||||
self.url = "nbd+unix:///?socket=\(socketPath)"
|
||||
}
|
||||
|
||||
init(filePath: String, port: Int, logger: Logger? = nil) throws {
|
||||
self.socketPath = nil
|
||||
self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
|
||||
|
||||
self.channel = try Self.bootstrap(group: self.group, filePath: filePath, logger: logger)
|
||||
.bind(host: "127.0.0.1", port: port)
|
||||
.wait()
|
||||
|
||||
guard let boundPort = channel.localAddress?.port, boundPort > 0 else {
|
||||
throw ContainerizationError(.internalError, message: "NBD server failed to bind to a port")
|
||||
}
|
||||
self.url = "nbd://127.0.0.1:\(boundPort)"
|
||||
}
|
||||
|
||||
func stop() {
|
||||
try? channel.close().wait()
|
||||
try? group.syncShutdownGracefully()
|
||||
if let socketPath {
|
||||
try? FileManager.default.removeItem(atPath: socketPath)
|
||||
}
|
||||
}
|
||||
|
||||
private static func bootstrap(group: EventLoopGroup, filePath: String, logger: Logger?) -> ServerBootstrap {
|
||||
ServerBootstrap(group: group)
|
||||
.serverChannelOption(.socketOption(.so_reuseaddr), value: 1)
|
||||
.childChannelInitializer { channel in
|
||||
channel.eventLoop.makeCompletedFuture {
|
||||
try channel.pipeline.syncOperations.addHandler(
|
||||
NBDConnectionHandler(filePath: filePath, logger: logger)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class NBDConnectionHandler: ChannelInboundHandler {
|
||||
typealias InboundIn = ByteBuffer
|
||||
typealias OutboundOut = ByteBuffer
|
||||
|
||||
// Protocol constants
|
||||
static let magic: UInt64 = 0x4e42_444d_4147_4943
|
||||
static let ihaveopt: UInt64 = 0x4948_4156_454f_5054
|
||||
static let replyMagic: UInt64 = 0x3_e889_0455_65a9
|
||||
static let requestMagic: UInt32 = 0x2560_9513
|
||||
static let simpleReplyMagic: UInt32 = 0x6744_6698
|
||||
|
||||
static let optExportName: UInt32 = 1
|
||||
static let optAbort: UInt32 = 2
|
||||
static let optInfo: UInt32 = 6
|
||||
static let optGo: UInt32 = 7
|
||||
|
||||
static let cmdRead: UInt16 = 0
|
||||
static let cmdWrite: UInt16 = 1
|
||||
static let cmdDisc: UInt16 = 2
|
||||
static let cmdFlush: UInt16 = 3
|
||||
|
||||
static let flagFixedNewstyle: UInt16 = 0x1
|
||||
static let flagNoZeroes: UInt16 = 0x2
|
||||
static let clientFlagFixedNewstyle: UInt32 = 0x1
|
||||
static let clientFlagNoZeroes: UInt32 = 0x2
|
||||
static let transmitHasFlags: UInt16 = 0x1
|
||||
static let transmitSendFlush: UInt16 = 0x4
|
||||
static let transmitSendFUA: UInt16 = 0x8
|
||||
|
||||
static let repACK: UInt32 = 1
|
||||
static let repInfo: UInt32 = 3
|
||||
static let repErrUnsup: UInt32 = 0x8000_0001
|
||||
static let infoExport: UInt16 = 0
|
||||
static let infoBlockSize: UInt16 = 3
|
||||
|
||||
// NBD error codes
|
||||
static let errOK: UInt32 = 0
|
||||
static let errIO: UInt32 = 5
|
||||
static let errNotsup: UInt32 = 95
|
||||
|
||||
private let fileFD: Int32
|
||||
private let fileSize: UInt64
|
||||
private let logger: Logger?
|
||||
private var buffer: ByteBuffer = ByteBuffer()
|
||||
private var state: ConnectionState = .handshake
|
||||
|
||||
private enum ConnectionState {
|
||||
case handshake
|
||||
case options(noZeroes: Bool)
|
||||
case transmission
|
||||
}
|
||||
|
||||
init(filePath: String, logger: Logger?) {
|
||||
self.fileFD = open(filePath, O_RDWR)
|
||||
self.logger = logger
|
||||
guard fileFD >= 0 else {
|
||||
self.fileSize = 0
|
||||
logger?.error("NBD server: failed to open \(filePath), errno=\(errno)")
|
||||
return
|
||||
}
|
||||
var st = stat()
|
||||
if fstat(self.fileFD, &st) == 0 {
|
||||
self.fileSize = UInt64(st.st_size)
|
||||
} else {
|
||||
self.fileSize = 0
|
||||
}
|
||||
}
|
||||
|
||||
func channelActive(context: ChannelHandlerContext) {
|
||||
guard fileFD >= 0 else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
// Send initial handshake.
|
||||
var buf = context.channel.allocator.buffer(capacity: 18)
|
||||
buf.writeInteger(Self.magic)
|
||||
buf.writeInteger(Self.ihaveopt)
|
||||
buf.writeInteger(Self.flagFixedNewstyle | Self.flagNoZeroes)
|
||||
context.writeAndFlush(wrapOutboundOut(buf), promise: nil)
|
||||
}
|
||||
|
||||
func channelInactive(context: ChannelHandlerContext) {
|
||||
if fileFD >= 0 {
|
||||
close(fileFD)
|
||||
}
|
||||
}
|
||||
|
||||
func channelRead(context: ChannelHandlerContext, data: NIOAny) {
|
||||
var incoming = unwrapInboundIn(data)
|
||||
buffer.writeBuffer(&incoming)
|
||||
processBuffer(context: context)
|
||||
}
|
||||
|
||||
private func processBuffer(context: ChannelHandlerContext) {
|
||||
while true {
|
||||
switch state {
|
||||
case .handshake:
|
||||
guard buffer.readableBytes >= 4,
|
||||
let clientFlags = buffer.readInteger(as: UInt32.self)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard clientFlags & Self.clientFlagFixedNewstyle != 0 else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
let noZeroes = clientFlags & Self.clientFlagNoZeroes != 0
|
||||
state = .options(noZeroes: noZeroes)
|
||||
|
||||
case .options(let noZeroes):
|
||||
guard buffer.readableBytes >= 16 else {
|
||||
return
|
||||
}
|
||||
// Peek at the header without consuming.
|
||||
let readerIndex = buffer.readerIndex
|
||||
guard let magic = buffer.getInteger(at: readerIndex, as: UInt64.self),
|
||||
let optType = buffer.getInteger(at: readerIndex + 8, as: UInt32.self),
|
||||
let dataLen = buffer.getInteger(at: readerIndex + 12, as: UInt32.self)
|
||||
else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Wait until we have the full option data.
|
||||
guard buffer.readableBytes >= 16 + Int(dataLen) else {
|
||||
return
|
||||
}
|
||||
// Consume the header.
|
||||
buffer.moveReaderIndex(forwardBy: 16)
|
||||
|
||||
guard magic == Self.ihaveopt else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let transmitFlags = Self.transmitHasFlags | Self.transmitSendFlush | Self.transmitSendFUA
|
||||
|
||||
switch optType {
|
||||
case Self.optExportName:
|
||||
if dataLen > 0 {
|
||||
buffer.moveReaderIndex(forwardBy: Int(dataLen))
|
||||
}
|
||||
var reply = context.channel.allocator.buffer(capacity: 10)
|
||||
reply.writeInteger(fileSize)
|
||||
reply.writeInteger(transmitFlags)
|
||||
if !noZeroes {
|
||||
reply.writeRepeatingByte(0, count: 124)
|
||||
}
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
state = .transmission
|
||||
|
||||
case Self.optInfo, Self.optGo:
|
||||
// Parse InfoRequest to check for block size request.
|
||||
var requestedBlockSize = false
|
||||
if dataLen >= 6 {
|
||||
let optDataStart = buffer.readerIndex
|
||||
let nameLen = Int(buffer.getInteger(at: optDataStart, as: UInt32.self) ?? 0)
|
||||
let infoOffset = optDataStart + 4 + nameLen
|
||||
if infoOffset + 2 <= optDataStart + Int(dataLen) {
|
||||
let numReqs = Int(buffer.getInteger(at: infoOffset, as: UInt16.self) ?? 0)
|
||||
for i in 0..<numReqs {
|
||||
let reqOffset = infoOffset + 2 + i * 2
|
||||
if reqOffset + 2 <= optDataStart + Int(dataLen) {
|
||||
let infoType = buffer.getInteger(at: reqOffset, as: UInt16.self) ?? 0
|
||||
if infoType == Self.infoBlockSize {
|
||||
requestedBlockSize = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if dataLen > 0 {
|
||||
buffer.moveReaderIndex(forwardBy: Int(dataLen))
|
||||
}
|
||||
|
||||
// Send NBD_INFO_EXPORT reply.
|
||||
var exportInfo = context.channel.allocator.buffer(capacity: 32)
|
||||
writeOptReply(&exportInfo, optType: optType, replyType: Self.repInfo, dataLen: 12)
|
||||
exportInfo.writeInteger(Self.infoExport)
|
||||
exportInfo.writeInteger(fileSize)
|
||||
exportInfo.writeInteger(transmitFlags)
|
||||
|
||||
// Send NBD_INFO_BLOCK_SIZE if requested.
|
||||
if requestedBlockSize {
|
||||
writeOptReply(&exportInfo, optType: optType, replyType: Self.repInfo, dataLen: 14)
|
||||
exportInfo.writeInteger(Self.infoBlockSize)
|
||||
exportInfo.writeInteger(UInt32(1)) // minimum
|
||||
exportInfo.writeInteger(UInt32(4096)) // preferred
|
||||
exportInfo.writeInteger(UInt32(4096 * 32)) // maximum
|
||||
}
|
||||
|
||||
writeOptReply(&exportInfo, optType: optType, replyType: Self.repACK, dataLen: 0)
|
||||
context.writeAndFlush(wrapOutboundOut(exportInfo), promise: nil)
|
||||
|
||||
if optType == Self.optGo {
|
||||
state = .transmission
|
||||
}
|
||||
|
||||
case Self.optAbort:
|
||||
if dataLen > 0 {
|
||||
buffer.moveReaderIndex(forwardBy: Int(dataLen))
|
||||
}
|
||||
context.close(promise: nil)
|
||||
return
|
||||
|
||||
default:
|
||||
if dataLen > 0 {
|
||||
buffer.moveReaderIndex(forwardBy: Int(dataLen))
|
||||
}
|
||||
var reply = context.channel.allocator.buffer(capacity: 20)
|
||||
writeOptReply(&reply, optType: optType, replyType: Self.repErrUnsup, dataLen: 0)
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
}
|
||||
|
||||
case .transmission:
|
||||
// Request header: 4 magic + 2 flags + 2 type + 8 cookie + 8 offset + 4 length = 28
|
||||
guard buffer.readableBytes >= 28 else {
|
||||
return
|
||||
}
|
||||
let readerIndex = buffer.readerIndex
|
||||
guard let magic = buffer.getInteger(at: readerIndex, as: UInt32.self),
|
||||
let cmdType = buffer.getInteger(at: readerIndex + 6, as: UInt16.self),
|
||||
let cookie = buffer.getInteger(at: readerIndex + 8, as: UInt64.self),
|
||||
let offset = buffer.getInteger(at: readerIndex + 16, as: UInt64.self),
|
||||
let length = buffer.getInteger(at: readerIndex + 24, as: UInt32.self)
|
||||
else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
guard magic == Self.requestMagic else {
|
||||
context.close(promise: nil)
|
||||
return
|
||||
}
|
||||
|
||||
switch cmdType {
|
||||
case Self.cmdWrite:
|
||||
// Need the full write payload before processing.
|
||||
guard buffer.readableBytes >= 28 + Int(length) else {
|
||||
return
|
||||
}
|
||||
buffer.moveReaderIndex(forwardBy: 28)
|
||||
var writeData = [UInt8](repeating: 0, count: Int(length))
|
||||
buffer.readWithUnsafeReadableBytes { ptr in
|
||||
writeData.withUnsafeMutableBytes { dst in
|
||||
guard let dstBase = dst.baseAddress, let srcBase = ptr.baseAddress else {
|
||||
return
|
||||
}
|
||||
_ = memcpy(dstBase, srcBase, Int(length))
|
||||
}
|
||||
return Int(length)
|
||||
}
|
||||
let n = pwrite(fileFD, &writeData, Int(length), off_t(offset))
|
||||
var reply = context.channel.allocator.buffer(capacity: 16)
|
||||
writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK)
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
|
||||
case Self.cmdRead:
|
||||
buffer.moveReaderIndex(forwardBy: 28)
|
||||
var readBuf = [UInt8](repeating: 0, count: Int(length))
|
||||
let n = pread(fileFD, &readBuf, Int(length), off_t(offset))
|
||||
var reply = context.channel.allocator.buffer(capacity: 16 + Int(length))
|
||||
writeSimpleReply(&reply, cookie: cookie, error: n < 0 ? Self.errIO : Self.errOK)
|
||||
if n >= 0 {
|
||||
reply.writeBytes(readBuf[0..<Int(length)])
|
||||
}
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
|
||||
case Self.cmdDisc:
|
||||
buffer.moveReaderIndex(forwardBy: 28)
|
||||
context.close(promise: nil)
|
||||
return
|
||||
|
||||
case Self.cmdFlush:
|
||||
buffer.moveReaderIndex(forwardBy: 28)
|
||||
fsync(fileFD)
|
||||
var reply = context.channel.allocator.buffer(capacity: 16)
|
||||
writeSimpleReply(&reply, cookie: cookie, error: Self.errOK)
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
|
||||
default:
|
||||
buffer.moveReaderIndex(forwardBy: 28)
|
||||
var reply = context.channel.allocator.buffer(capacity: 16)
|
||||
writeSimpleReply(&reply, cookie: cookie, error: Self.errNotsup)
|
||||
context.writeAndFlush(wrapOutboundOut(reply), promise: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func writeOptReply(_ buf: inout ByteBuffer, optType: UInt32, replyType: UInt32, dataLen: UInt32) {
|
||||
buf.writeInteger(Self.replyMagic)
|
||||
buf.writeInteger(optType)
|
||||
buf.writeInteger(replyType)
|
||||
buf.writeInteger(dataLen)
|
||||
}
|
||||
|
||||
private func writeSimpleReply(_ buf: inout ByteBuffer, cookie: UInt64, error: UInt32) {
|
||||
buf.writeInteger(Self.simpleReplyMagic)
|
||||
buf.writeInteger(error)
|
||||
buf.writeInteger(cookie)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,857 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Containerization
|
||||
import ContainerizationArchive
|
||||
import ContainerizationEXT4
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
#if os(macOS)
|
||||
extension IntegrationSuite {
|
||||
private func cloneRootfsForContainer(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount {
|
||||
let clonePath = Self.testDir.appending(component: "\(testID)-\(containerID).ext4").absolutePath()
|
||||
try? FileManager.default.removeItem(atPath: clonePath)
|
||||
return try rootfs.clone(to: clonePath)
|
||||
}
|
||||
|
||||
private func createEXT4DiskImage(testID: String, name: String, size: UInt64 = 64.mib()) throws -> URL {
|
||||
let diskURL = Self.testDir.appending(component: "\(testID)-\(name).ext4")
|
||||
try? FileManager.default.removeItem(at: diskURL)
|
||||
let formatter = try EXT4.Formatter(FilePath(diskURL.absolutePath()), minDiskSize: size)
|
||||
try formatter.close()
|
||||
return diskURL
|
||||
}
|
||||
|
||||
/// Create an ext4 disk image with a file already written to it.
|
||||
private func createEXT4DiskImageWithFile(
|
||||
testID: String, name: String, filePath: String, content: String, size: UInt64 = 64.mib()
|
||||
) throws -> URL {
|
||||
let diskURL = Self.testDir.appending(component: "\(testID)-\(name).ext4")
|
||||
try? FileManager.default.removeItem(at: diskURL)
|
||||
let formatter = try EXT4.Formatter(FilePath(diskURL.absolutePath()), minDiskSize: size)
|
||||
let data = Data(content.utf8)
|
||||
let stream = InputStream(data: data)
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
try formatter.create(path: FilePath(filePath), mode: 0o100644, buf: stream)
|
||||
try formatter.close()
|
||||
return diskURL
|
||||
}
|
||||
|
||||
private func createNBDServer(testID: String, name: String, size: UInt64 = 64.mib()) throws -> (NBDServer, URL) {
|
||||
let diskURL = try createEXT4DiskImage(testID: testID, name: name, size: size)
|
||||
let shortID = String(testID.hashValue, radix: 36, uppercase: false)
|
||||
let socketPath = "/tmp/nbd-\(shortID)-\(name).sock"
|
||||
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
||||
return (server, diskURL)
|
||||
}
|
||||
|
||||
private func readFileFromDiskImage(_ diskURL: URL, path: String) throws -> String {
|
||||
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(diskURL.path))
|
||||
let bytes = try reader.readFile(at: FilePath(path))
|
||||
guard let content = String(bytes: bytes, encoding: .utf8) else {
|
||||
throw IntegrationError.assert(msg: "failed to decode file content from disk image at \(path)")
|
||||
}
|
||||
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func assertVirtioBlockMount(_ output: String, path: String) throws {
|
||||
guard output.contains("/dev/vd") else {
|
||||
throw IntegrationError.assert(msg: "expected virtio block device (/dev/vd*) for \(path), got: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
func testContainerNBDMount() async throws {
|
||||
let id = "test-container-nbd-mount"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, diskURL) = try createNBDServer(testID: id, name: "vol")
|
||||
defer { server.stop() }
|
||||
|
||||
let buffer = BufferWriter()
|
||||
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
||||
config.mounts.append(
|
||||
Mount.block(
|
||||
format: "ext4",
|
||||
source: server.url,
|
||||
destination: "/data"
|
||||
))
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"echo hello > /data/test.txt && cat /data/test.txt && grep /data /proc/mounts",
|
||||
]
|
||||
config.process.stdout = buffer
|
||||
config.bootLog = bs.bootLog
|
||||
}
|
||||
|
||||
try await container.create()
|
||||
try await container.start()
|
||||
|
||||
let status = try await container.wait()
|
||||
try await container.stop()
|
||||
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
||||
}
|
||||
|
||||
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
|
||||
guard lines.count >= 2 else {
|
||||
throw IntegrationError.assert(msg: "expected at least 2 lines of output, got: \(output)")
|
||||
}
|
||||
|
||||
guard lines[0] == "hello" else {
|
||||
throw IntegrationError.assert(msg: "expected 'hello', got '\(lines[0])'")
|
||||
}
|
||||
|
||||
try assertVirtioBlockMount(lines[1], path: "/data")
|
||||
|
||||
// Verify the write landed on the NBD backing file.
|
||||
let diskContent = try readFileFromDiskImage(diskURL, path: "/test.txt")
|
||||
guard diskContent == "hello" else {
|
||||
throw IntegrationError.assert(msg: "NBD backing file: expected 'hello', got '\(diskContent)'")
|
||||
}
|
||||
}
|
||||
|
||||
func testContainerNBDReadOnly() async throws {
|
||||
let id = "test-container-nbd-readonly"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, _) = try createNBDServer(testID: id, name: "ro-vol")
|
||||
defer { server.stop() }
|
||||
|
||||
let buffer = BufferWriter()
|
||||
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
||||
config.mounts.append(
|
||||
Mount.block(
|
||||
format: "ext4",
|
||||
source: server.url,
|
||||
destination: "/data",
|
||||
options: ["ro"]
|
||||
))
|
||||
// Verify virtio block mount, then attempt a write that should fail.
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"grep /data /proc/mounts; echo test > /data/fail.txt 2>&1; echo exit=$?",
|
||||
]
|
||||
config.process.stdout = buffer
|
||||
config.bootLog = bs.bootLog
|
||||
}
|
||||
|
||||
try await container.create()
|
||||
try await container.start()
|
||||
|
||||
_ = try await container.wait()
|
||||
try await container.stop()
|
||||
|
||||
let output = String(data: buffer.data, encoding: .utf8) ?? ""
|
||||
let lines = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\n")
|
||||
|
||||
guard !lines.isEmpty else {
|
||||
throw IntegrationError.assert(msg: "expected output, got nothing")
|
||||
}
|
||||
|
||||
// First line should show the virtio block device mount.
|
||||
try assertVirtioBlockMount(lines[0], path: "/data")
|
||||
|
||||
// Write should have failed on a read-only mount.
|
||||
guard !output.contains("exit=0") else {
|
||||
throw IntegrationError.assert(msg: "write succeeded on read-only NBD mount: \(output)")
|
||||
}
|
||||
}
|
||||
|
||||
func testContainerNBDRawBlock() async throws {
|
||||
let id = "test-container-nbd-raw-block"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
// Create an unformatted disk image, no filesystem.
|
||||
let diskURL = Self.testDir.appending(component: "\(id)-raw.img")
|
||||
try? FileManager.default.removeItem(at: diskURL)
|
||||
FileManager.default.createFile(atPath: diskURL.path, contents: nil)
|
||||
let fh = try FileHandle(forWritingTo: diskURL)
|
||||
try fh.truncate(atOffset: 64.mib())
|
||||
try fh.close()
|
||||
|
||||
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
||||
let socketPath = "/tmp/nbd-\(shortID)-raw.sock"
|
||||
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
||||
defer { server.stop() }
|
||||
|
||||
let buffer = BufferWriter()
|
||||
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
||||
// Attach as raw block, bind mount the device into the container.
|
||||
config.mounts.append(
|
||||
Mount.block(
|
||||
format: "none",
|
||||
source: server.url,
|
||||
destination: "/dev/my-disk",
|
||||
options: ["bind"]
|
||||
))
|
||||
// Verify it's a block device, write known data, read it back.
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"test -b /dev/my-disk && printf 'raw-block-works' | dd of=/dev/my-disk bs=512 count=1 conv=sync 2>/dev/null && dd if=/dev/my-disk bs=1 count=15 2>/dev/null",
|
||||
]
|
||||
config.process.stdout = buffer
|
||||
config.bootLog = bs.bootLog
|
||||
}
|
||||
|
||||
try await container.create()
|
||||
try await container.start()
|
||||
|
||||
let status = try await container.wait()
|
||||
try await container.stop()
|
||||
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
||||
}
|
||||
|
||||
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard output == "raw-block-works" else {
|
||||
throw IntegrationError.assert(msg: "expected 'raw-block-works', got '\(output)'")
|
||||
}
|
||||
}
|
||||
|
||||
func testContainerNBDVolumeIdentity() async throws {
|
||||
let id = "test-container-nbd-volume-identity"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let volumeCount = 5
|
||||
var servers: [NBDServer] = []
|
||||
|
||||
// Create 5 disk images, each pre-filled with unique content.
|
||||
for i in 0..<volumeCount {
|
||||
let diskURL = try createEXT4DiskImageWithFile(
|
||||
testID: id, name: "vol\(i)", filePath: "/id.txt", content: "container-id-\(i)\n")
|
||||
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
||||
let socketPath = "/tmp/nbd-\(shortID)-vol\(i).sock"
|
||||
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
||||
servers.append(server)
|
||||
}
|
||||
defer {
|
||||
for server in servers {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Attach all 5 NBD volumes to a single container and verify each is correct.
|
||||
let buffer = BufferWriter()
|
||||
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
||||
for i in 0..<volumeCount {
|
||||
config.mounts.append(
|
||||
Mount.block(format: "ext4", source: servers[i].url, destination: "/mnt\(i)"))
|
||||
}
|
||||
let readCommands = (0..<volumeCount).map { "cat /mnt\($0)/id.txt" }.joined(separator: " && ")
|
||||
config.process.arguments = ["/bin/sh", "-c", readCommands]
|
||||
config.process.stdout = buffer
|
||||
config.bootLog = bs.bootLog
|
||||
}
|
||||
|
||||
try await container.create()
|
||||
try await container.start()
|
||||
let status = try await container.wait()
|
||||
try await container.stop()
|
||||
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "reader container exited with status \(status)")
|
||||
}
|
||||
|
||||
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
|
||||
guard lines.count == volumeCount else {
|
||||
throw IntegrationError.assert(msg: "expected \(volumeCount) lines, got \(lines.count): \(output)")
|
||||
}
|
||||
|
||||
for i in 0..<volumeCount {
|
||||
let expected = "container-id-\(i)"
|
||||
guard lines[i] == expected else {
|
||||
throw IntegrationError.assert(msg: "volume \(i): expected '\(expected)', got '\(lines[i])'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testPodSharedNBDVolume() async throws {
|
||||
let id = "test-pod-shared-nbd-volume"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, diskURL) = try createNBDServer(testID: id, name: "shared")
|
||||
defer { server.stop() }
|
||||
|
||||
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
|
||||
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "shared-data",
|
||||
source: .nbd(url: URL(string: server.url)!),
|
||||
format: "ext4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// Container 1: writes to the shared volume and verifies mount type.
|
||||
let writerBuffer = BufferWriter()
|
||||
try await pod.addContainer("writer", rootfs: rootfs1) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"echo shared-content > /data/shared.txt && grep /data /proc/mounts",
|
||||
]
|
||||
config.process.stdout = writerBuffer
|
||||
config.mounts.append(.sharedMount(name: "shared-data", destination: "/data"))
|
||||
}
|
||||
|
||||
// Container 2: reads from the same shared volume at a different path and verifies mount type.
|
||||
let readerBuffer = BufferWriter()
|
||||
try await pod.addContainer("reader", rootfs: rootfs2) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"sleep 2 && cat /shared/shared.txt && grep /shared /proc/mounts",
|
||||
]
|
||||
config.process.stdout = readerBuffer
|
||||
config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared"))
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
try await pod.startContainer("writer")
|
||||
try await pod.startContainer("reader")
|
||||
|
||||
let writerStatus = try await pod.waitContainer("writer")
|
||||
guard writerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
|
||||
}
|
||||
|
||||
let readerStatus = try await pod.waitContainer("reader")
|
||||
guard readerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Verify writer output.
|
||||
let writerOutput = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let writerLines = writerOutput.components(separatedBy: "\n")
|
||||
guard !writerLines.isEmpty else {
|
||||
throw IntegrationError.assert(msg: "writer produced no output")
|
||||
}
|
||||
try assertVirtioBlockMount(writerLines.last!, path: "/data")
|
||||
|
||||
// Verify reader output.
|
||||
let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let readerLines = readerOutput.components(separatedBy: "\n")
|
||||
guard readerLines.count >= 2 else {
|
||||
throw IntegrationError.assert(msg: "expected at least 2 lines from reader, got: \(readerOutput)")
|
||||
}
|
||||
guard readerLines[0] == "shared-content" else {
|
||||
throw IntegrationError.assert(msg: "expected 'shared-content', got '\(readerLines[0])'")
|
||||
}
|
||||
try assertVirtioBlockMount(readerLines[1], path: "/shared")
|
||||
|
||||
// Verify the write landed on the NBD backing file.
|
||||
let diskContent = try readFileFromDiskImage(diskURL, path: "/shared.txt")
|
||||
guard diskContent == "shared-content" else {
|
||||
throw IntegrationError.assert(msg: "NBD backing file: expected 'shared-content', got '\(diskContent)'")
|
||||
}
|
||||
}
|
||||
|
||||
func testPodMultipleNBDVolumes() async throws {
|
||||
let id = "test-pod-multiple-nbd-volumes"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server1, diskURL1) = try createNBDServer(testID: id, name: "vol1")
|
||||
defer { server1.stop() }
|
||||
|
||||
let (server2, diskURL2) = try createNBDServer(testID: id, name: "vol2")
|
||||
defer { server2.stop() }
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "volume-a",
|
||||
source: .nbd(url: URL(string: server1.url)!),
|
||||
format: "ext4"
|
||||
),
|
||||
.init(
|
||||
name: "volume-b",
|
||||
source: .nbd(url: URL(string: server2.url)!),
|
||||
format: "ext4"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
let buffer = BufferWriter()
|
||||
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"""
|
||||
echo aaa > /mnt-a/a.txt && echo bbb > /mnt-b/b.txt \
|
||||
&& cat /mnt-a/a.txt && cat /mnt-b/b.txt \
|
||||
&& grep /mnt-a /proc/mounts && grep /mnt-b /proc/mounts
|
||||
""",
|
||||
]
|
||||
config.process.stdout = buffer
|
||||
config.mounts.append(.sharedMount(name: "volume-a", destination: "/mnt-a"))
|
||||
config.mounts.append(.sharedMount(name: "volume-b", destination: "/mnt-b"))
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
try await pod.startContainer("container1")
|
||||
|
||||
let status = try await pod.waitContainer("container1")
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
|
||||
guard lines.count >= 4 else {
|
||||
throw IntegrationError.assert(msg: "expected at least 4 lines, got: \(output)")
|
||||
}
|
||||
|
||||
guard lines[0] == "aaa" && lines[1] == "bbb" else {
|
||||
throw IntegrationError.assert(msg: "expected 'aaa\\nbbb', got '\(lines[0])\\n\(lines[1])'")
|
||||
}
|
||||
|
||||
try assertVirtioBlockMount(lines[2], path: "/mnt-a")
|
||||
try assertVirtioBlockMount(lines[3], path: "/mnt-b")
|
||||
|
||||
// Verify each write landed on the correct NBD backing file.
|
||||
let diskContent1 = try readFileFromDiskImage(diskURL1, path: "/a.txt")
|
||||
guard diskContent1 == "aaa" else {
|
||||
throw IntegrationError.assert(msg: "NBD backing file vol1: expected 'aaa', got '\(diskContent1)'")
|
||||
}
|
||||
let diskContent2 = try readFileFromDiskImage(diskURL2, path: "/b.txt")
|
||||
guard diskContent2 == "bbb" else {
|
||||
throw IntegrationError.assert(msg: "NBD backing file vol2: expected 'bbb', got '\(diskContent2)'")
|
||||
}
|
||||
}
|
||||
|
||||
func testPodUnreferencedVolume() async throws {
|
||||
let id = "test-pod-unreferenced-volume"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, _) = try createNBDServer(testID: id, name: "unused")
|
||||
defer { server.stop() }
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "unused-vol",
|
||||
source: .nbd(url: URL(string: server.url)!),
|
||||
format: "ext4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// Container doesn't reference the volume at all.
|
||||
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
|
||||
config.process.arguments = ["/bin/true"]
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
try await pod.startContainer("container1")
|
||||
|
||||
let status = try await pod.waitContainer("container1")
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "container exited with status \(status)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func testPodNBDVolumePersistence() async throws {
|
||||
let id = "test-pod-nbd-volume-persistence"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, _) = try createNBDServer(testID: id, name: "persistent")
|
||||
defer { server.stop() }
|
||||
|
||||
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
|
||||
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "persistent-data",
|
||||
source: .nbd(url: URL(string: server.url)!),
|
||||
format: "ext4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// First container: write data to the volume.
|
||||
try await pod.addContainer("writer", rootfs: rootfs1) { config in
|
||||
config.process.arguments = ["/bin/sh", "-c", "echo persisted > /data/file.txt && sync"]
|
||||
config.mounts.append(.sharedMount(name: "persistent-data", destination: "/data"))
|
||||
}
|
||||
|
||||
// Second container: will read the data after the first is stopped.
|
||||
let readerBuffer = BufferWriter()
|
||||
try await pod.addContainer("reader", rootfs: rootfs2) { config in
|
||||
config.process.arguments = ["/bin/sh", "-c", "cat /data/file.txt"]
|
||||
config.process.stdout = readerBuffer
|
||||
config.mounts.append(.sharedMount(name: "persistent-data", destination: "/data"))
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
|
||||
// Start writer, wait for it to finish, then stop it.
|
||||
try await pod.startContainer("writer")
|
||||
let writerStatus = try await pod.waitContainer("writer")
|
||||
guard writerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
|
||||
}
|
||||
try await pod.stopContainer("writer")
|
||||
|
||||
// Start reader after writer is stopped — data should persist on the volume.
|
||||
try await pod.startContainer("reader")
|
||||
let readerStatus = try await pod.waitContainer("reader")
|
||||
guard readerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
let output = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard output == "persisted" else {
|
||||
throw IntegrationError.assert(msg: "expected 'persisted', got '\(output ?? "<nil>")'")
|
||||
}
|
||||
}
|
||||
|
||||
func testPodNBDConcurrentWrites() async throws {
|
||||
let id = "test-pod-nbd-concurrent-writes"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
let (server, _) = try createNBDServer(testID: id, name: "shared")
|
||||
defer { server.stop() }
|
||||
|
||||
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c1")
|
||||
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c2")
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "shared-vol",
|
||||
source: .nbd(url: URL(string: server.url)!),
|
||||
format: "ext4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// Both containers write to different files on the same volume concurrently.
|
||||
let buffer1 = BufferWriter()
|
||||
try await pod.addContainer("c1", rootfs: rootfs1) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"echo from-c1 > /vol/c1.txt && sync && cat /vol/c1.txt",
|
||||
]
|
||||
config.process.stdout = buffer1
|
||||
config.mounts.append(.sharedMount(name: "shared-vol", destination: "/vol"))
|
||||
}
|
||||
|
||||
let buffer2 = BufferWriter()
|
||||
try await pod.addContainer("c2", rootfs: rootfs2) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"echo from-c2 > /vol/c2.txt && sync && cat /vol/c2.txt",
|
||||
]
|
||||
config.process.stdout = buffer2
|
||||
config.mounts.append(.sharedMount(name: "shared-vol", destination: "/vol"))
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
try await pod.startContainer("c1")
|
||||
try await pod.startContainer("c2")
|
||||
|
||||
let status1 = try await pod.waitContainer("c1")
|
||||
guard status1.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "c1 exited with status \(status1)")
|
||||
}
|
||||
|
||||
let status2 = try await pod.waitContainer("c2")
|
||||
guard status2.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "c2 exited with status \(status2)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
let output1 = String(data: buffer1.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard output1 == "from-c1" else {
|
||||
throw IntegrationError.assert(msg: "c1: expected 'from-c1', got '\(output1 ?? "<nil>")'")
|
||||
}
|
||||
|
||||
let output2 = String(data: buffer2.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard output2 == "from-c2" else {
|
||||
throw IntegrationError.assert(msg: "c2: expected 'from-c2', got '\(output2 ?? "<nil>")'")
|
||||
}
|
||||
}
|
||||
|
||||
func testPodNBDVolumeIdentity() async throws {
|
||||
let id = "test-pod-nbd-volume-identity"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
// Create 5 disk images, each pre-filled with unique content.
|
||||
let volumeCount = 5
|
||||
var servers: [NBDServer] = []
|
||||
|
||||
for i in 0..<volumeCount {
|
||||
let diskURL = try createEXT4DiskImageWithFile(
|
||||
testID: id, name: "vol\(i)", filePath: "/id.txt", content: "identity-\(i)\n")
|
||||
let shortID = String(id.hashValue, radix: 36, uppercase: false)
|
||||
let socketPath = "/tmp/nbd-\(shortID)-pvol\(i).sock"
|
||||
let server = try NBDServer(filePath: diskURL.path, socketPath: socketPath)
|
||||
servers.append(server)
|
||||
}
|
||||
defer {
|
||||
for server in servers {
|
||||
server.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Create a pod with all 5 volumes and verify each is at the right path.
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 4
|
||||
config.memoryInBytes = 1024.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = (0..<volumeCount).map { i in
|
||||
.init(
|
||||
name: "vol-\(i)",
|
||||
source: .nbd(url: URL(string: servers[i].url)!),
|
||||
format: "ext4"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Container reads from all 5 volumes and prints their content.
|
||||
let buffer = BufferWriter()
|
||||
try await pod.addContainer("reader", rootfs: bs.rootfs) { config in
|
||||
let readCommands = (0..<volumeCount).map { i in
|
||||
"cat /mnt\(i)/id.txt"
|
||||
}.joined(separator: " && ")
|
||||
config.process.arguments = ["/bin/sh", "-c", readCommands]
|
||||
config.process.stdout = buffer
|
||||
for i in 0..<volumeCount {
|
||||
config.mounts.append(.sharedMount(name: "vol-\(i)", destination: "/mnt\(i)"))
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
try await pod.startContainer("reader")
|
||||
|
||||
let status = try await pod.waitContainer("reader")
|
||||
guard status.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "reader exited with status \(status)")
|
||||
}
|
||||
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Verify each volume's content matches its expected identity.
|
||||
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let lines = output.components(separatedBy: "\n")
|
||||
|
||||
guard lines.count == volumeCount else {
|
||||
throw IntegrationError.assert(msg: "expected \(volumeCount) lines, got \(lines.count): \(output)")
|
||||
}
|
||||
|
||||
for i in 0..<volumeCount {
|
||||
let expected = "identity-\(i)"
|
||||
guard lines[i] == expected else {
|
||||
throw IntegrationError.assert(msg: "volume \(i): expected '\(expected)', got '\(lines[i])'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach an empty EXT4 disk-image file as a pod volume and have
|
||||
/// multiple containers read from and write to the shared mount.
|
||||
func testPodSharedDiskImageVolume() async throws {
|
||||
let id = "test-pod-shared-disk-image-volume"
|
||||
let bs = try await bootstrap(id)
|
||||
|
||||
// Create an empty EXT4 disk image to back the shared volume.
|
||||
let diskURL = try createEXT4DiskImage(testID: id, name: "shared")
|
||||
|
||||
let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer")
|
||||
let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "appender")
|
||||
let rootfs3 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader")
|
||||
|
||||
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
|
||||
config.cpus = 1
|
||||
config.memoryInBytes = 512.mib()
|
||||
config.bootLog = bs.bootLog
|
||||
config.volumes = [
|
||||
.init(
|
||||
name: "shared-data",
|
||||
source: .diskImage(path: diskURL),
|
||||
format: "ext4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
// Container 1: writes a file to the shared volume and verifies mount type.
|
||||
let writerBuffer = BufferWriter()
|
||||
try await pod.addContainer("writer", rootfs: rootfs1) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"echo shared-content > /data/shared.txt && grep /data /proc/mounts",
|
||||
]
|
||||
config.process.stdout = writerBuffer
|
||||
config.mounts.append(.sharedMount(name: "shared-data", destination: "/data"))
|
||||
}
|
||||
|
||||
// Container 2: reads what the writer produced and writes a second file,
|
||||
// mounted at a different path to prove it's the same backing store.
|
||||
let appenderBuffer = BufferWriter()
|
||||
try await pod.addContainer("appender", rootfs: rootfs2) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"cat /vol/shared.txt && echo more-content > /vol/second.txt",
|
||||
]
|
||||
config.process.stdout = appenderBuffer
|
||||
config.mounts.append(.sharedMount(name: "shared-data", destination: "/vol"))
|
||||
}
|
||||
|
||||
// Container 3: reads both files written by the previous containers.
|
||||
let readerBuffer = BufferWriter()
|
||||
try await pod.addContainer("reader", rootfs: rootfs3) { config in
|
||||
config.process.arguments = [
|
||||
"/bin/sh", "-c",
|
||||
"cat /shared/shared.txt && cat /shared/second.txt && grep /shared /proc/mounts",
|
||||
]
|
||||
config.process.stdout = readerBuffer
|
||||
config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared"))
|
||||
}
|
||||
|
||||
do {
|
||||
try await pod.create()
|
||||
|
||||
// Run the containers sequentially so reads see prior writes.
|
||||
try await pod.startContainer("writer")
|
||||
let writerStatus = try await pod.waitContainer("writer")
|
||||
guard writerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)")
|
||||
}
|
||||
|
||||
try await pod.startContainer("appender")
|
||||
let appenderStatus = try await pod.waitContainer("appender")
|
||||
guard appenderStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "appender exited with status \(appenderStatus)")
|
||||
}
|
||||
|
||||
try await pod.startContainer("reader")
|
||||
let readerStatus = try await pod.waitContainer("reader")
|
||||
guard readerStatus.exitCode == 0 else {
|
||||
throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)")
|
||||
}
|
||||
try await pod.stop()
|
||||
} catch {
|
||||
try? await pod.stop()
|
||||
throw error
|
||||
}
|
||||
|
||||
// Verify writer mounted a virtio block device at /data.
|
||||
let writerOutput = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let writerLines = writerOutput.components(separatedBy: "\n")
|
||||
guard !writerLines.isEmpty else {
|
||||
throw IntegrationError.assert(msg: "writer produced no output")
|
||||
}
|
||||
try assertVirtioBlockMount(writerLines.last!, path: "/data")
|
||||
|
||||
// Verify the appender read the writer's file.
|
||||
let appenderOutput = String(data: appenderBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard appenderOutput == "shared-content" else {
|
||||
throw IntegrationError.assert(msg: "appender: expected 'shared-content', got '\(appenderOutput)'")
|
||||
}
|
||||
|
||||
// Verify the reader saw both files and a virtio block mount.
|
||||
let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let readerLines = readerOutput.components(separatedBy: "\n")
|
||||
guard readerLines.count >= 3 else {
|
||||
throw IntegrationError.assert(msg: "expected at least 3 lines from reader, got: \(readerOutput)")
|
||||
}
|
||||
guard readerLines[0] == "shared-content" else {
|
||||
throw IntegrationError.assert(msg: "reader: expected 'shared-content', got '\(readerLines[0])'")
|
||||
}
|
||||
guard readerLines[1] == "more-content" else {
|
||||
throw IntegrationError.assert(msg: "reader: expected 'more-content', got '\(readerLines[1])'")
|
||||
}
|
||||
try assertVirtioBlockMount(readerLines[2], path: "/shared")
|
||||
|
||||
// Verify both writes landed on the host-side EXT4 disk image.
|
||||
let firstContent = try readFileFromDiskImage(diskURL, path: "/shared.txt")
|
||||
guard firstContent == "shared-content" else {
|
||||
throw IntegrationError.assert(msg: "disk image /shared.txt: expected 'shared-content', got '\(firstContent)'")
|
||||
}
|
||||
let secondContent = try readFileFromDiskImage(diskURL, path: "/second.txt")
|
||||
guard secondContent == "more-content" else {
|
||||
throw IntegrationError.assert(msg: "disk image /second.txt: expected 'more-content', got '\(secondContent)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,686 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-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 ArgumentParser
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Musl)
|
||||
import Musl
|
||||
#elseif canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
|
||||
actor UnpackCoordinator {
|
||||
private var inFlight: [String: Task<Containerization.Mount, Error>] = [:]
|
||||
|
||||
func unpack(
|
||||
key: String,
|
||||
operation: @escaping @Sendable () async throws -> Containerization.Mount
|
||||
) async throws -> Containerization.Mount {
|
||||
if let existing = inFlight[key] {
|
||||
return try await existing.value
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
try await operation()
|
||||
}
|
||||
inFlight[key] = task
|
||||
|
||||
defer {
|
||||
inFlight.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
return try await task.value
|
||||
}
|
||||
}
|
||||
|
||||
struct Test: Sendable {
|
||||
var name: String
|
||||
var work: @Sendable () async throws -> Void
|
||||
|
||||
init(_ name: String, _ work: @escaping @Sendable () async throws -> Void) {
|
||||
self.name = name
|
||||
self.work = work
|
||||
}
|
||||
}
|
||||
|
||||
final class JobQueue<T>: Sendable where T: Sendable {
|
||||
struct State: Sendable {
|
||||
var next = 0
|
||||
var jobs: [T]
|
||||
}
|
||||
|
||||
private let lock: Mutex<State>
|
||||
init(_ jobs: [T]) {
|
||||
self.lock = Mutex(State(jobs: jobs))
|
||||
}
|
||||
|
||||
func pop() -> T? {
|
||||
self.lock.withLock { state in
|
||||
guard state.next < state.jobs.count else {
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
state.next += 1
|
||||
}
|
||||
return state.jobs[state.next]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let log = {
|
||||
LoggingSystem.bootstrap(StreamLogHandler.standardError)
|
||||
var log = Logger(label: "com.apple.containerization")
|
||||
log.logLevel = .debug
|
||||
return log
|
||||
}()
|
||||
|
||||
enum IntegrationError: Swift.Error {
|
||||
case assert(msg: String)
|
||||
case noOutput
|
||||
}
|
||||
|
||||
struct SkipTest: Swift.Error, CustomStringConvertible {
|
||||
let reason: String
|
||||
|
||||
var description: String {
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct IntegrationSuite: AsyncParsableCommand {
|
||||
static let appRoot: URL = {
|
||||
FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first!
|
||||
.appendingPathComponent("com.apple.containerization")
|
||||
}()
|
||||
|
||||
private static let _contentStore: ContentStore = {
|
||||
try! LocalContentStore(path: appRoot.appending(path: "content"))
|
||||
}()
|
||||
|
||||
private static let _imageStore: ImageStore = {
|
||||
try! ImageStore(
|
||||
path: appRoot,
|
||||
contentStore: contentStore
|
||||
)
|
||||
}()
|
||||
|
||||
static let _testDir: URL = {
|
||||
FileManager.default.uniqueTemporaryDirectory(create: true)
|
||||
}()
|
||||
|
||||
static var testDir: URL {
|
||||
_testDir
|
||||
}
|
||||
|
||||
static var imageStore: ImageStore {
|
||||
_imageStore
|
||||
}
|
||||
|
||||
static var contentStore: ContentStore {
|
||||
_contentStore
|
||||
}
|
||||
|
||||
static let initImage = "vminit:latest"
|
||||
|
||||
private static let unpackCoordinator = UnpackCoordinator()
|
||||
|
||||
@Option(name: .shortAndLong, help: "Path to a directory for boot logs")
|
||||
var bootlogDir: String = "./bin/integration-bootlogs"
|
||||
|
||||
@Option(name: .shortAndLong, help: "Path to a kernel binary")
|
||||
var kernel: String = Self.defaultKernelPath
|
||||
|
||||
#if arch(arm64)
|
||||
private static let kernelCandidates = ["./bin/vmlinux-arm64"]
|
||||
#elseif arch(x86_64)
|
||||
private static let kernelCandidates = ["./bin/vmlinuz-x86_64", "./bin/vmlinux-x86_64"]
|
||||
#else
|
||||
private static let kernelCandidates = ["./bin/vmlinux"]
|
||||
#endif
|
||||
|
||||
private static let defaultKernelPath: String = {
|
||||
let fm = FileManager.default
|
||||
for candidate in kernelCandidates where fm.fileExists(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
return kernelCandidates[0]
|
||||
}()
|
||||
|
||||
@Option(name: .shortAndLong, help: "Maximum number of concurrent tests")
|
||||
var maxConcurrency: Int = 4
|
||||
|
||||
@Option(name: .shortAndLong, help: "Only run tests whose names contain this string")
|
||||
var filter: String?
|
||||
|
||||
#if os(Linux)
|
||||
@Option(name: .long, help: "Path to cloud-hypervisor binary (Linux only). Defaults to PATH lookup.")
|
||||
var chBinary: String?
|
||||
|
||||
@Option(name: .long, help: "Path to virtiofsd binary (Linux only). Defaults to PATH lookup.")
|
||||
var virtiofsdBinary: String?
|
||||
#endif
|
||||
|
||||
static func binPath(name: String) -> URL {
|
||||
URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
|
||||
.appendingPathComponent("bin")
|
||||
.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
static let eventLoop = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
|
||||
|
||||
func bootstrap(_ testID: String) async throws -> (rootfs: Containerization.Mount, vmm: VirtualMachineManager, image: Containerization.Image, bootLog: BootLog) {
|
||||
let reference = "ghcr.io/linuxcontainers/alpine:3.20"
|
||||
let store = Self.imageStore
|
||||
|
||||
let initImage = try await store.getInitImage(reference: Self.initImage)
|
||||
let initfs = try await {
|
||||
let p = Self.binPath(name: "init.block")
|
||||
do {
|
||||
return try await initImage.initBlock(at: p, for: .linuxArm)
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code == .exists else {
|
||||
throw err
|
||||
}
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: p.absolutePath(),
|
||||
destination: "/",
|
||||
options: ["ro"]
|
||||
)
|
||||
}
|
||||
}()
|
||||
|
||||
let testKernel = Kernel(path: .init(filePath: kernel), platform: .linuxArm)
|
||||
// Intentionally NOT adding `debug` or `earlycon=pl011,...` here.
|
||||
// Both look free, but each costs real wall-clock per VM boot:
|
||||
// * `debug` floods printk through hvc0 (which CH writes to the
|
||||
// bootlog file).
|
||||
// * `earlycon=pl011,...` routes every early-boot printk character
|
||||
// through pl011 MMIO traps into CH's serial emulator. With CH's
|
||||
// pl011 wired to a file (see CHVirtualMachineInstance.serialConfig)
|
||||
// each character is a synchronous file write and ~50–80 ms of
|
||||
// dmesg quantization showed up in measurements — adding ~1.5 s
|
||||
// to every VM boot before bootconsole hands over to virtio_console.
|
||||
// Re-add either as a one-shot when actively diagnosing kernel boot.
|
||||
let image = try await Self.fetchImage(reference: reference, store: store)
|
||||
let platform = Platform(arch: "arm64", os: "linux", variant: "v8")
|
||||
|
||||
// Unpack to shared location with coordination to prevent concurrent unpacks
|
||||
let fsPath = Self.testDir.appending(component: image.digest)
|
||||
let fs = try await Self.unpackCoordinator.unpack(key: fsPath.absolutePath()) {
|
||||
do {
|
||||
let unpacker = EXT4Unpacker(blockSizeInBytes: 2.gib())
|
||||
return try await unpacker.unpack(image, for: platform, at: fsPath)
|
||||
} catch let err as ContainerizationError {
|
||||
if err.code == .exists {
|
||||
return .block(
|
||||
format: "ext4",
|
||||
source: fsPath.absolutePath(),
|
||||
destination: "/",
|
||||
options: []
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Reap any per-test artifacts left over from prior tests. With
|
||||
// `--max-concurrency 1` (linux-integration default) this runs after
|
||||
// the previous test has fully completed, so it's race-free; on
|
||||
// macOS where tests can run in parallel we just keep all files —
|
||||
// disk usage isn't a concern there. Each per-test bootstrap clones
|
||||
// a ~2GB rootfs and a ~512MB initfs, so without reaping the dev
|
||||
// container fills its CoW layer in ~10 tests.
|
||||
if self.maxConcurrency == 1 {
|
||||
let preserve = fsPath.absolutePath()
|
||||
if let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: Self.testDir,
|
||||
includingPropertiesForKeys: nil
|
||||
) {
|
||||
for url in entries where url.absolutePath() != preserve {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clone to test-specific path
|
||||
let clPath = Self.testDir.appending(component: "\(testID).ext4").absolutePath()
|
||||
try? FileManager.default.removeItem(atPath: clPath)
|
||||
|
||||
let cl = try fs.clone(to: clPath)
|
||||
|
||||
// Per-test clone of the init.block. The init.block is supposed to be
|
||||
// mounted read-only (kernel cmdline + readonly=true on the virtio-blk
|
||||
// device for both VZ and CH), but sharing the same backing file across
|
||||
// concurrent CH VMs has surfaced "internalError: mount" cascades on
|
||||
// Linux/CH after a single test failure — symptomatic of the file
|
||||
// entering a bad state when one CH instance is killed mid-flight.
|
||||
// Cloning per test isolates each VM from any cross-test fallout.
|
||||
let initClonePath = Self.testDir.appending(component: "\(testID).init.block").absolutePath()
|
||||
try? FileManager.default.removeItem(atPath: initClonePath)
|
||||
let initfsPerTest = try initfs.clone(to: initClonePath)
|
||||
|
||||
// Create bootLog directory and per-container bootLog path
|
||||
let bootlogDirURL = URL(filePath: bootlogDir)
|
||||
try? FileManager.default.createDirectory(at: bootlogDirURL, withIntermediateDirectories: true)
|
||||
let bootlogURL = bootlogDirURL.appendingPathComponent("\(testID).log")
|
||||
|
||||
let vmm: any VirtualMachineManager = try Self.makeVMM(
|
||||
kernel: testKernel,
|
||||
initialFilesystem: initfsPerTest,
|
||||
chBinary: Self.chBinaryOverride(for: self),
|
||||
virtiofsdBinary: Self.virtiofsdBinaryOverride(for: self)
|
||||
)
|
||||
|
||||
return (
|
||||
cl,
|
||||
vmm,
|
||||
image,
|
||||
BootLog.file(path: bootlogURL)
|
||||
)
|
||||
}
|
||||
|
||||
private static func chBinaryOverride(for suite: IntegrationSuite) -> String? {
|
||||
#if os(Linux)
|
||||
return suite.chBinary
|
||||
#else
|
||||
_ = suite
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func virtiofsdBinaryOverride(for suite: IntegrationSuite) -> String? {
|
||||
#if os(Linux)
|
||||
return suite.virtiofsdBinary
|
||||
#else
|
||||
_ = suite
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func makeVMM(
|
||||
kernel: Kernel,
|
||||
initialFilesystem: Containerization.Mount,
|
||||
chBinary: String?,
|
||||
virtiofsdBinary: String?
|
||||
) throws -> any VirtualMachineManager {
|
||||
#if os(macOS)
|
||||
_ = chBinary
|
||||
_ = virtiofsdBinary
|
||||
return VZVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initialFilesystem,
|
||||
group: Self.eventLoop
|
||||
)
|
||||
#elseif os(Linux)
|
||||
return try CHVirtualMachineManager(
|
||||
kernel: kernel,
|
||||
initialFilesystem: initialFilesystem,
|
||||
chBinary: chBinary.map { URL(fileURLWithPath: $0) },
|
||||
virtiofsdBinary: virtiofsdBinary.map { URL(fileURLWithPath: $0) },
|
||||
group: Self.eventLoop,
|
||||
logger: log
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
static func fetchImage(reference: String, store: ImageStore) async throws -> Containerization.Image {
|
||||
do {
|
||||
return try await store.get(reference: reference)
|
||||
} catch let error as ContainerizationError {
|
||||
if error.code == .notFound {
|
||||
return try await store.pull(reference: reference)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static func adjustLimits() throws {
|
||||
var limits = rlimit()
|
||||
#if os(Linux)
|
||||
let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue)
|
||||
#else
|
||||
let resource = RLIMIT_NOFILE
|
||||
#endif
|
||||
|
||||
guard getrlimit(resource, &limits) == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
limits.rlim_cur = 65536
|
||||
limits.rlim_max = 65536
|
||||
|
||||
guard setrlimit(resource, &limits) == 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func macOS26Tests() -> [Test] {
|
||||
if #available(macOS 26.0, *) {
|
||||
return [
|
||||
Test("container interface custom MTU", testInterfaceMTU),
|
||||
Test("container networking disabled", testNetworkingDisabled),
|
||||
Test("container networking enabled", testNetworkingEnabled),
|
||||
Test("container networking enabled ipv6", testNetworkingEnabledIPv6),
|
||||
Test("container IPv6 address", testIPv6AddressAdd),
|
||||
Test("container IPv6 default route", testIPv6DefaultRoute),
|
||||
Test("container IPv6 gateway outside subnet", testIPv6GatewayOutsideSubnet),
|
||||
Test("container IPv6 only default route", testIPv6OnlyDefaultRoute),
|
||||
Test("container IPv6 only gateway outside subnet", testIPv6OnlyGatewayOutsideSubnet),
|
||||
Test("container IPv6 dual stack", testIPv6DualStack),
|
||||
Test("pod IPv6 address", testPodIPv6AddressAdd),
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
#endif
|
||||
|
||||
// Why does this exist?
|
||||
//
|
||||
// We need the virtualization entitlement to execute these tests.
|
||||
// There currently does not exist a straightforward way to do this
|
||||
// in a pure swift package.
|
||||
//
|
||||
// In order to not have a dependency on xcode, we create an executable
|
||||
// for our integration tests that can be signed then ran.
|
||||
//
|
||||
// We also can't import Testing as it expects to be run from a runner.
|
||||
// Hopefully this improves over time.
|
||||
func run() async throws {
|
||||
try Self.adjustLimits()
|
||||
let suiteStarted = Date().timeIntervalSinceReferenceDate
|
||||
log.info("starting integration suite\n")
|
||||
|
||||
let crossPlatformTests: [Test] = [
|
||||
// Process basics
|
||||
Test("process true", testProcessTrue),
|
||||
Test("process false", testProcessFalse),
|
||||
Test("process echo hi", testProcessEchoHi),
|
||||
Test("process no executable", testProcessNoExecutable),
|
||||
Test("process user", testProcessUser),
|
||||
Test("process stdin", testProcessStdin),
|
||||
Test("process home envvar", testProcessHomeEnvvar),
|
||||
Test("process custom home envvar", testProcessCustomHomeEnvvar),
|
||||
Test("process tty ensure TERM", testProcessTtyEnvvar),
|
||||
|
||||
// Hostname / hosts
|
||||
Test("container hostname", testHostname),
|
||||
Test("container hostname defaults to container id", testHostnameDefaultsToContainerID),
|
||||
Test("container hosts", testHostsFile),
|
||||
|
||||
// Statistics / cgroups / memory
|
||||
Test("container statistics", testContainerStatistics),
|
||||
Test("container cgroup limits", testCgroupLimits),
|
||||
Test("container memory events OOM kill", testMemoryEventsOOMKill),
|
||||
|
||||
// Console / boot / lifecycle
|
||||
Test("container no serial console", testNoSerialConsole),
|
||||
Test("container non-closure constructor", testNonClosureConstructor),
|
||||
Test("container test large stdio ingest", testLargeStdioOutput),
|
||||
Test("container bootlog using filehandle", testBootLogFileHandle),
|
||||
Test("process delete idempotency", testProcessDeleteIdempotency),
|
||||
Test("multiple execs without delete", testMultipleExecsWithoutDelete),
|
||||
|
||||
// Capabilities
|
||||
Test("container capabilities sys admin", testCapabilitiesSysAdmin),
|
||||
Test("container capabilities net admin", testCapabilitiesNetAdmin),
|
||||
Test("container capabilities OCI default", testCapabilitiesOCIDefault),
|
||||
Test("container capabilities all capabilities", testCapabilitiesAllCapabilities),
|
||||
Test("container capabilities file ownership", testCapabilitiesFileOwnership),
|
||||
|
||||
// Masked / read-only paths
|
||||
Test("container default masked and read-only paths", testDefaultMaskedAndReadonlyPaths),
|
||||
|
||||
// Stat / Copy
|
||||
Test("container stat", testStat),
|
||||
Test("container copy in", testCopyIn),
|
||||
Test("container copy in file to existing directory", testCopyInFileToExistingDirectory),
|
||||
Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails),
|
||||
Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails),
|
||||
Test("container copy out", testCopyOut),
|
||||
Test("container copy large file", testCopyLargeFile),
|
||||
Test("container copy in directory", testCopyInDirectory),
|
||||
Test("container copy out directory", testCopyOutDirectory),
|
||||
Test("container copy empty file", testCopyEmptyFile),
|
||||
Test("container copy empty directory", testCopyEmptyDirectory),
|
||||
Test("container copy binary file", testCopyBinaryFile),
|
||||
Test("container copy multiple files", testCopyMultipleFiles),
|
||||
Test("container copy directory round trip", testCopyDirectoryRoundTrip),
|
||||
Test("container copy in create parents", testCopyInCreateParents),
|
||||
Test("container copy file permissions", testCopyFilePermissions),
|
||||
Test("container copy large directory", testCopyLargeDirectory),
|
||||
|
||||
// Read-only / writable layers
|
||||
Test("container read-only rootfs", testReadOnlyRootfs),
|
||||
Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten),
|
||||
Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured),
|
||||
Test("container writable layer", testWritableLayer),
|
||||
Test("container writable layer journal writeback", testWritableLayerJournalWriteback),
|
||||
Test("container writable layer journal ordered", testWritableLayerJournalOrdered),
|
||||
Test("container writable layer journal data", testWritableLayerJournalData),
|
||||
Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer),
|
||||
Test("container writable layer reads from lower", testWritableLayerReadsFromLower),
|
||||
Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower),
|
||||
Test("container writable layer size", testWritableLayerSize),
|
||||
Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts),
|
||||
|
||||
// Stdin / stdout / exec
|
||||
Test("large stdin input", testLargeStdinInput),
|
||||
Test("exec large stdin input", testExecLargeStdinInput),
|
||||
Test("exec custom path resolution", testExecCustomPathResolution),
|
||||
Test("stdin explicit close", testStdinExplicitClose),
|
||||
Test("stdin binary data", testStdinBinaryData),
|
||||
Test("stdin multiple chunks", testStdinMultipleChunks),
|
||||
Test("stdin very large", testStdinVeryLarge),
|
||||
|
||||
// RLimit
|
||||
Test("container rlimit open files", testRLimitOpenFiles),
|
||||
Test("container rlimit multiple", testRLimitMultiple),
|
||||
Test("container rlimit exec", testRLimitExec),
|
||||
|
||||
// useInit
|
||||
Test("container useInit basic", testUseInitBasic),
|
||||
Test("container useInit exit code propagation", testUseInitExitCodePropagation),
|
||||
Test("container useInit signal forwarding", testUseInitSignalForwarding),
|
||||
Test("container useInit zombie reaping", testUseInitZombieReaping),
|
||||
Test("container useInit with terminal", testUseInitWithTerminal),
|
||||
Test("container useInit with stdin", testUseInitWithStdin),
|
||||
|
||||
// Sysctl / security / workingDir
|
||||
Test("container sysctl", testSysctl),
|
||||
Test("container sysctl multiple", testSysctlMultiple),
|
||||
Test("container noNewPrivileges", testNoNewPrivileges),
|
||||
Test("container noNewPrivileges disabled", testNoNewPrivilegesDisabled),
|
||||
Test("container noNewPrivileges exec", testNoNewPrivilegesExec),
|
||||
Test("container workingDir created", testWorkingDirCreated),
|
||||
Test("container workingDir exec created", testWorkingDirExecCreated),
|
||||
|
||||
// VM resource overhead
|
||||
Test("container VM resource overhead", testVMResourceOverhead),
|
||||
|
||||
// Pods
|
||||
Test("pod single container", testPodSingleContainer),
|
||||
Test("pod multiple containers", testPodMultipleContainers),
|
||||
Test("pod container output", testPodContainerOutput),
|
||||
Test("pod concurrent containers", testPodConcurrentContainers),
|
||||
Test("pod exec in container", testPodExecInContainer),
|
||||
Test("pod exec in container env", testPodExecInContainerEnv),
|
||||
Test("pod container hostname", testPodContainerHostname),
|
||||
Test("pod container hostname defaults to container id", testPodContainerHostnameDefaultsToContainerID),
|
||||
Test("pod stop container idempotency", testPodStopContainerIdempotency),
|
||||
Test("pod list containers", testPodListContainers),
|
||||
Test("pod container statistics", testPodContainerStatistics),
|
||||
Test("pod memory events OOM kill", testPodMemoryEventsOOMKill),
|
||||
Test("pod container resource limits", testPodContainerResourceLimits),
|
||||
Test("pod container filesystem isolation", testPodContainerFilesystemIsolation),
|
||||
Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation),
|
||||
Test("pod container independent resource limits", testPodContainerIndependentResourceLimits),
|
||||
Test("pod shared PID namespace", testPodSharedPIDNamespace),
|
||||
Test("pod read-only rootfs", testPodReadOnlyRootfs),
|
||||
Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured),
|
||||
Test("pod container hosts config", testPodContainerHostsConfig),
|
||||
Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS),
|
||||
Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts),
|
||||
Test("pod level DNS", testPodLevelDNS),
|
||||
Test("pod level DNS with container override", testPodLevelDNSWithContainerOverride),
|
||||
Test("pod level hosts", testPodLevelHosts),
|
||||
Test("pod level hosts with container override", testPodLevelHostsWithContainerOverride),
|
||||
Test("pod level hostname", testPodLevelHostname),
|
||||
Test("pod level hostname with container override", testPodLevelHostnameWithContainerOverride),
|
||||
Test("pod rlimit open files", testPodRLimitOpenFiles),
|
||||
Test("pod rlimit exec", testPodRLimitExec),
|
||||
Test("pod useInit basic", testPodUseInitBasic),
|
||||
Test("pod useInit exit code propagation", testPodUseInitExitCodePropagation),
|
||||
Test("pod useInit signal forwarding", testPodUseInitSignalForwarding),
|
||||
Test("pod useInit multiple containers", testPodUseInitMultipleContainers),
|
||||
Test("pod useInit with shared PID namespace", testPodUseInitWithSharedPIDNamespace),
|
||||
Test("pod sysctl", testPodSysctl),
|
||||
Test("pod sysctl multiple containers", testPodSysctlMultipleContainers),
|
||||
Test("pod invalid volume reference", testPodInvalidVolumeReference),
|
||||
Test("pod duplicate volume name", testPodDuplicateVolumeName),
|
||||
|
||||
// Mounts / virtiofs shares (cross-platform: VZ on macOS, virtiofsd on Linux/CH).
|
||||
Test("container mount", testMounts),
|
||||
Test("container single file mount", testSingleFileMount),
|
||||
Test("container single file mount read-only", testSingleFileMountReadOnly),
|
||||
Test("container single file mount write-back", testSingleFileMountWriteBack),
|
||||
Test("container single file mount symlink", testSingleFileMountSymlink),
|
||||
Test("container duplicate virtiofs mount", testDuplicateVirtiofsMount),
|
||||
Test("container duplicate virtiofs mount via symlink", testDuplicateVirtiofsMountViaSymlink),
|
||||
Test("container mount sort by depth", testMountsSortedByDepth),
|
||||
Test("pod single file mount", testPodSingleFileMount),
|
||||
]
|
||||
|
||||
#if os(macOS)
|
||||
let macOSOnlyTests: [Test] =
|
||||
[
|
||||
// ContainerManager-based tests (ContainerManager is macOS-only)
|
||||
Test("container stop idempotency", testContainerStopIdempotency),
|
||||
Test("container manager", testContainerManagerCreate),
|
||||
Test("container reuse", testContainerReuse),
|
||||
Test("container /dev/console", testContainerDevConsole),
|
||||
|
||||
// Nested virtualization (VZ-only feature)
|
||||
Test("nested virt", testNestedVirtualizationEnabled),
|
||||
|
||||
// Filesystem operations (TODO: promote to cross-platform once verified on CH)
|
||||
Test("container frozen ext4 clone", testFrozenExt4Clone),
|
||||
Test("container trim ext4 clone", testTrimExt4Clone),
|
||||
|
||||
// Unix socket forwarding (dynamic vsock listen exceeds CH's prebound stdio pool)
|
||||
Test("unix socket into guest", testUnixSocketIntoGuest),
|
||||
Test("unix socket into guest long container id", testUnixSocketIntoGuestLongContainerID),
|
||||
Test("unix socket into guest symlink", testUnixSocketIntoGuestSymlink),
|
||||
Test("pod unix socket into guest symlink", testPodUnixSocketIntoGuestSymlink),
|
||||
|
||||
// High-concurrency stdio (exceeds CH's prebound stdio pool size)
|
||||
Test("multiple concurrent processes", testMultipleConcurrentProcesses),
|
||||
Test("multiple concurrent processes with output stress", testMultipleConcurrentProcessesOutputStress),
|
||||
|
||||
// NBD volumes (test infra is macOS-only)
|
||||
Test("container NBD mount", testContainerNBDMount),
|
||||
Test("container NBD read-only", testContainerNBDReadOnly),
|
||||
Test("container NBD raw block", testContainerNBDRawBlock),
|
||||
Test("container NBD volume identity", testContainerNBDVolumeIdentity),
|
||||
Test("pod shared NBD volume", testPodSharedNBDVolume),
|
||||
Test("pod multiple NBD volumes", testPodMultipleNBDVolumes),
|
||||
Test("pod unreferenced NBD volume", testPodUnreferencedVolume),
|
||||
Test("pod NBD volume persistence", testPodNBDVolumePersistence),
|
||||
Test("pod NBD concurrent writes", testPodNBDConcurrentWrites),
|
||||
Test("pod NBD volume identity", testPodNBDVolumeIdentity),
|
||||
Test("pod filesystem operation", testPodFilesystemOperation),
|
||||
Test("pod shared disk image volume", testPodSharedDiskImageVolume),
|
||||
] + macOS26Tests()
|
||||
let tests: [Test] = crossPlatformTests + macOSOnlyTests
|
||||
#else
|
||||
let tests: [Test] = crossPlatformTests
|
||||
#endif
|
||||
|
||||
let filteredTests: [Test]
|
||||
if let filter {
|
||||
// Comma-separated; ANY pattern matching the test name keeps it.
|
||||
// E.g. `--filter "container mount,pod single file"`.
|
||||
let patterns = filter.split(separator: ",").map { String($0) }
|
||||
filteredTests = tests.filter { test in
|
||||
patterns.contains { test.name.contains($0) }
|
||||
}
|
||||
log.info("filter '\(filter)' matched \(filteredTests.count)/\(tests.count) tests")
|
||||
} else {
|
||||
filteredTests = tests
|
||||
}
|
||||
|
||||
let passed: Atomic<Int> = Atomic(0)
|
||||
let skipped: Atomic<Int> = Atomic(0)
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
let jobQueue = JobQueue(filteredTests)
|
||||
for _ in 0..<maxConcurrency {
|
||||
group.addTask { @Sendable in
|
||||
while let job = jobQueue.pop() {
|
||||
do {
|
||||
log.info("test \(job.name) started...")
|
||||
|
||||
let started = Date().timeIntervalSinceReferenceDate
|
||||
try await job.work()
|
||||
let lasted = Date().timeIntervalSinceReferenceDate - started
|
||||
|
||||
log.info("✅ test \(job.name) complete in \(lasted)s.")
|
||||
passed.add(1, ordering: .relaxed)
|
||||
} catch let err as SkipTest {
|
||||
log.info("⏭️ skipped test: \(err)")
|
||||
skipped.add(1, ordering: .relaxed)
|
||||
} catch {
|
||||
log.error("❌ test \(job.name) failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await group.waitForAll()
|
||||
}
|
||||
|
||||
let passedCount = passed.load(ordering: .acquiring)
|
||||
let skippedCount = skipped.load(ordering: .acquiring)
|
||||
|
||||
let ended = Date().timeIntervalSinceReferenceDate - suiteStarted
|
||||
var finishingText = "\n\nIntegration suite completed in \(ended)s with \(passedCount)/\(filteredTests.count) passed"
|
||||
if skipped.load(ordering: .acquiring) > 0 {
|
||||
finishingText += " and \(skippedCount)/\(filteredTests.count) skipped"
|
||||
}
|
||||
finishingText += "!"
|
||||
|
||||
log.info("\(finishingText)")
|
||||
|
||||
try? FileManager.default.removeItem(at: Self.testDir)
|
||||
if passedCount + skippedCount < filteredTests.count {
|
||||
log.error("❌")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user