5676 lines
213 KiB
Swift
5676 lines
213 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 ContainerizationEXT4
|
|
import ContainerizationError
|
|
import ContainerizationExtras
|
|
import ContainerizationOCI
|
|
import ContainerizationOS
|
|
import Crypto
|
|
import Foundation
|
|
import Logging
|
|
import SystemPackage
|
|
|
|
extension IntegrationSuite {
|
|
func testProcessTrue() async throws {
|
|
let id = "test-process-true"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/true"]
|
|
config.memoryInBytes = 250_000_000
|
|
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: "process status \(status) != 0")
|
|
}
|
|
}
|
|
|
|
func testProcessFalse() async throws {
|
|
let id = "test-process-false"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/false"]
|
|
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 == 1 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 1")
|
|
}
|
|
}
|
|
|
|
final class DiscardingWriter: @unchecked Sendable, Writer {
|
|
var count: Int = 0
|
|
|
|
func write(_ data: Data) throws {
|
|
count += data.count
|
|
}
|
|
|
|
func close() throws {
|
|
return
|
|
}
|
|
}
|
|
|
|
final class BufferWriter: Writer {
|
|
// `data` isn't used concurrently.
|
|
nonisolated(unsafe) var data = Data()
|
|
|
|
func write(_ data: Data) throws {
|
|
guard data.count > 0 else {
|
|
return
|
|
}
|
|
self.data.append(data)
|
|
}
|
|
|
|
func close() throws {
|
|
return
|
|
}
|
|
}
|
|
|
|
final class StdinBuffer: ReaderStream {
|
|
let data: Data
|
|
|
|
init(data: Data) {
|
|
self.data = data
|
|
}
|
|
|
|
func stream() -> AsyncStream<Data> {
|
|
let (stream, cont) = AsyncStream<Data>.makeStream()
|
|
cont.yield(self.data)
|
|
cont.finish()
|
|
return stream
|
|
}
|
|
}
|
|
|
|
final class ChunkedStdinBuffer: ReaderStream {
|
|
let chunks: [Data]
|
|
let delayMs: Int
|
|
|
|
init(chunks: [Data], delayMs: Int = 0) {
|
|
self.chunks = chunks
|
|
self.delayMs = delayMs
|
|
}
|
|
|
|
func stream() -> AsyncStream<Data> {
|
|
let chunks = self.chunks
|
|
let delayMs = self.delayMs
|
|
return AsyncStream { cont in
|
|
Task {
|
|
for chunk in chunks {
|
|
if delayMs > 0 {
|
|
try? await Task.sleep(for: .milliseconds(delayMs))
|
|
}
|
|
cont.yield(chunk)
|
|
}
|
|
cont.finish()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func testProcessEchoHi() async throws {
|
|
let id = "test-process-echo-hi"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/echo", "hi"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 1")
|
|
}
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "hi\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout 'hi' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testProcessNoExecutable() async throws {
|
|
let id = "test-process-no-executable"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["foobarbaz"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let _ = try await container.wait()
|
|
try await container.stop()
|
|
|
|
throw IntegrationError.assert(msg: "process didn't throw 'no executable' error")
|
|
} catch {
|
|
try? await container.stop()
|
|
guard let err = error as? ContainerizationError,
|
|
err.isCode(.internalError), err.description.contains("failed to find target executable")
|
|
else {
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
func testMultipleConcurrentProcesses() async throws {
|
|
let id = "test-concurrent-processes"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for i in 0...80 {
|
|
let exec = try await container.exec("exec-\(i)") { config in
|
|
config.arguments = ["/bin/true"]
|
|
}
|
|
|
|
group.addTask {
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
if status.exitCode != 0 {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
try await exec.delete()
|
|
}
|
|
}
|
|
|
|
try await group.waitForAll()
|
|
|
|
try await container.stop()
|
|
}
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testMultipleConcurrentProcessesOutputStress() async throws {
|
|
let id = "test-concurrent-processes-output-stress"
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("expected-value") { config in
|
|
config.arguments = [
|
|
"sh",
|
|
"-c",
|
|
"dd if=/dev/random of=/tmp/bytes bs=1M count=20 status=none ; sha256sum /tmp/bytes",
|
|
]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
if status.exitCode != 0 {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)!
|
|
let expected = String(output.split(separator: " ").first!)
|
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
|
for i in 0...80 {
|
|
let idx = i
|
|
group.addTask {
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("exec-\(idx)") { config in
|
|
config.arguments = ["cat", "/tmp/bytes"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
|
|
let status = try await exec.wait()
|
|
if status.exitCode != 0 {
|
|
throw IntegrationError.assert(msg: "process \(idx) status \(status) != 0")
|
|
}
|
|
|
|
var hasher = SHA256()
|
|
hasher.update(data: buffer.data)
|
|
let hash = hasher.finalize().digestString.trimmingDigestPrefix
|
|
guard hash == expected else {
|
|
throw IntegrationError.assert(
|
|
msg: "process \(idx) output \(hash) != expected \(expected)")
|
|
}
|
|
try await exec.delete()
|
|
}
|
|
}
|
|
|
|
try await group.waitForAll()
|
|
}
|
|
try await exec.delete()
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
}
|
|
}
|
|
|
|
func testProcessUser() async throws {
|
|
let id = "test-process-user"
|
|
|
|
let bs = try await bootstrap(id)
|
|
var buffer = BufferWriter()
|
|
var container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/usr/bin/id"]
|
|
config.process.user = .init(uid: 1, gid: 1, additionalGids: [1])
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
var status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
var expected = "uid=1(bin) gid=1(bin) groups=1(bin)"
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
|
|
buffer = BufferWriter()
|
|
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/usr/bin/id"]
|
|
// Try some uid that doesn't exist. This is supported.
|
|
config.process.user = .init(uid: 40000, gid: 40000)
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
expected = "uid=40000 gid=40000 groups=40000"
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
|
|
buffer = BufferWriter()
|
|
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/usr/bin/id"]
|
|
// Try some uid that doesn't exist. This is supported.
|
|
config.process.user = .init(username: "40000:40000")
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
expected = "uid=40000 gid=40000 groups=40000"
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
|
|
buffer = BufferWriter()
|
|
container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/usr/bin/id"]
|
|
// Now for our final trick, try and run a username that doesn't exist.
|
|
config.process.user = .init(username: "thisdoesntexist")
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
do {
|
|
try await container.start()
|
|
} catch {
|
|
return
|
|
}
|
|
throw IntegrationError.assert(msg: "container start should have failed")
|
|
}
|
|
|
|
// Ensure if we ask for a terminal we set TERM.
|
|
func testProcessTtyEnvvar() async throws {
|
|
let id = "test-process-tty-envvar"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["env"]
|
|
config.process.terminal = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let str = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(
|
|
msg: "failed to convert standard output to a UTF8 string")
|
|
}
|
|
|
|
let homeEnvvar = "TERM=xterm"
|
|
guard str.contains(homeEnvvar) else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have TERM environment variable defined")
|
|
}
|
|
}
|
|
|
|
// Make sure we set HOME by default if we can find it in /etc/passwd in the guest.
|
|
func testProcessHomeEnvvar() async throws {
|
|
let id = "test-process-home-envvar"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["env"]
|
|
config.process.user = .init(uid: 0, gid: 0)
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let str = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(
|
|
msg: "failed to convert standard output to a UTF8 string")
|
|
}
|
|
|
|
let homeEnvvar = "HOME=/root"
|
|
guard str.contains(homeEnvvar) else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have HOME environment variable defined")
|
|
}
|
|
}
|
|
|
|
func testProcessCustomHomeEnvvar() async throws {
|
|
let id = "test-process-custom-home-envvar"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let customHomeEnvvar = "HOME=/tmp/custom/home"
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sh", "-c", "echo HOME=$HOME"]
|
|
config.process.environmentVariables.append(customHomeEnvvar)
|
|
config.process.user = .init(uid: 0, gid: 0)
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.contains(customHomeEnvvar) else {
|
|
throw IntegrationError.assert(msg: "process should have preserved custom HOME environment variable, expected \(customHomeEnvvar), got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testHostname() async throws {
|
|
let id = "test-container-hostname"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/hostname"]
|
|
config.hostname = "foo-bar"
|
|
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: "process status \(status) != 0")
|
|
}
|
|
let expected = "foo-bar"
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
}
|
|
|
|
func testHostnameDefaultsToContainerID() async throws {
|
|
let id = "test-container-hostname-default"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/hostname"]
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(id)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "hostname should default to container id '\(id)', got '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
}
|
|
|
|
func testHostsFile() async throws {
|
|
let id = "test-container-hosts-file"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let entry = Hosts.Entry.localHostIPV4(comment: "Testaroo")
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat", "/etc/hosts"]
|
|
config.hosts = Hosts(entries: [entry])
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let expected = entry.rendered
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
}
|
|
|
|
func testProcessStdin() async throws {
|
|
let id = "test-container-stdin"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat"]
|
|
config.process.stdin = StdinBuffer(data: "Hello from test".data(using: .utf8)!)
|
|
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: "process status \(status) != 0")
|
|
}
|
|
let expected = "Hello from test"
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "\(expected)" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned on stdout '\(expected)' != '\(String(data: buffer.data, encoding: .utf8)!)'")
|
|
}
|
|
}
|
|
|
|
func testMounts() async throws {
|
|
let id = "test-cat-mount"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
let directory = try createMountDirectory()
|
|
config.process.arguments = ["/bin/cat", "/mnt/hi.txt"]
|
|
config.mounts.append(.share(source: directory.path, destination: "/mnt"))
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let value = String(data: buffer.data, encoding: .utf8)
|
|
guard value == "hello" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned from file 'hello' != '\(String(data: buffer.data, encoding: .utf8)!)")
|
|
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
func testNestedVirtualizationEnabled() async throws {
|
|
let id = "test-nested-virt"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/true"]
|
|
config.virtualization = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
} catch {
|
|
if let err = error as? ContainerizationError {
|
|
if err.code == .unsupported {
|
|
throw SkipTest(reason: err.message)
|
|
}
|
|
}
|
|
}
|
|
|
|
let status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
}
|
|
|
|
func testContainerManagerCreate() async throws {
|
|
let id = "test-container-manager"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
var manager = try ContainerManager(vmm: bs.vmm)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
config.process.arguments = ["/bin/echo", "ContainerManager test"]
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)
|
|
guard output == "ContainerManager test\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned 'ContainerManager test' != '\(output ?? "nil")'")
|
|
}
|
|
}
|
|
|
|
func testContainerStopIdempotency() async throws {
|
|
let id = "test-container-stop-idempotency"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
var manager = try ContainerManager(vmm: bs.vmm)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
config.process.arguments = ["/bin/echo", "please stop me"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let status = try await container.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
try await container.stop()
|
|
try await container.stop()
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)
|
|
guard output == "please stop me\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned 'ContainerManager test' != '\(output ?? "nil")'")
|
|
}
|
|
}
|
|
|
|
func testContainerReuse() async throws {
|
|
let id = "test-container-reuse"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
var manager = try ContainerManager(vmm: bs.vmm)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
config.process.arguments = ["/bin/echo", "ContainerManager test"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
var status = try await container.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
try await container.stop()
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Wait for completion.. again.
|
|
status = try await container.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "process status \(status) != 0")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)
|
|
let expected = "ContainerManager test\nContainerManager test\n"
|
|
guard output == expected else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have returned '\(expected)' != '\(output ?? "nil")'")
|
|
}
|
|
}
|
|
|
|
func testContainerDevConsole() async throws {
|
|
let id = "test-container-devconsole"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
var manager = try ContainerManager(vmm: bs.vmm)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
// We mount devtmpfs by default, and while this includes creating
|
|
// /dev/console typically that'll be pointing to /dev/hvc0 (the
|
|
// virtio serial console). This is just a character device, so a trivial
|
|
// way to check that our bind mounted console setup worked is by just
|
|
// parsing `mount`'s output and looking for /dev/console as it wouldn't
|
|
// be there normally without our dance.
|
|
config.process.arguments = ["mount"]
|
|
config.process.terminal = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let str = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(
|
|
msg: "failed to convert standard output to a UTF8 string")
|
|
}
|
|
|
|
let devConsole = "/dev/console"
|
|
guard str.contains(devConsole) else {
|
|
throw IntegrationError.assert(
|
|
msg: "process should have \(devConsole) in `mount` output")
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
func testContainerStatistics() async throws {
|
|
let id = "test-container-statistics"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "infinity"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let stats = try await container.statistics()
|
|
|
|
guard stats.id == id else {
|
|
throw IntegrationError.assert(msg: "stats container ID '\(stats.id)' != '\(id)'")
|
|
}
|
|
|
|
guard let process = stats.process, process.current > 0 else {
|
|
throw IntegrationError.assert(msg: "process count should be > 0, got \(stats.process?.current ?? 0)")
|
|
}
|
|
|
|
guard let memory = stats.memory, memory.usageBytes > 0 else {
|
|
throw IntegrationError.assert(msg: "memory usage should be > 0, got \(stats.memory?.usageBytes ?? 0)")
|
|
}
|
|
|
|
guard let cpu = stats.cpu, cpu.usageUsec > 0 else {
|
|
throw IntegrationError.assert(msg: "CPU usage should be > 0, got \(stats.cpu?.usageUsec ?? 0)")
|
|
}
|
|
|
|
print("Container statistics:")
|
|
print(" Processes: \(process.current)")
|
|
print(" Memory: \(memory.usageBytes) bytes")
|
|
print(" CPU: \(cpu.usageUsec) usec")
|
|
print(" Networks: \(stats.networks?.count ?? 0) interfaces")
|
|
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCgroupLimits() async throws {
|
|
let id = "test-cgroup-limits"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "infinity"]
|
|
config.cpus = 2
|
|
config.memoryInBytes = 512.mib()
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Start an exec with sleep infinity
|
|
let sleepExec = try await container.exec("sleep-exec") { config in
|
|
config.arguments = ["sleep", "infinity"]
|
|
}
|
|
try await sleepExec.start()
|
|
|
|
// Verify we have 3 PIDs in cgroup.procs: init, exec sleep, and cat itself
|
|
let procsBuffer = BufferWriter()
|
|
let procsExec = try await container.exec("check-procs") { config in
|
|
config.arguments = ["cat", "/sys/fs/cgroup/cgroup.procs"]
|
|
config.stdout = procsBuffer
|
|
}
|
|
try await procsExec.start()
|
|
var status = try await procsExec.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "check-procs status \(status) != 0")
|
|
}
|
|
try await procsExec.delete()
|
|
|
|
guard let procsContent = String(data: procsBuffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to parse cgroup.procs")
|
|
}
|
|
let pids = procsContent.split(separator: "\n").filter { !$0.isEmpty }
|
|
guard pids.count == 3 else {
|
|
throw IntegrationError.assert(msg: "expected 3 PIDs in cgroup.procs, got \(pids.count): \(procsContent)")
|
|
}
|
|
|
|
// Verify memory limit
|
|
let memoryBuffer = BufferWriter()
|
|
let memoryExec = try await container.exec("check-memory") { config in
|
|
config.arguments = ["cat", "/sys/fs/cgroup/memory.max"]
|
|
config.stdout = memoryBuffer
|
|
}
|
|
try await memoryExec.start()
|
|
status = try await memoryExec.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "check-memory status \(status) != 0")
|
|
}
|
|
try await memoryExec.delete()
|
|
|
|
guard let memoryLimit = String(data: memoryBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to parse memory.max")
|
|
}
|
|
let expectedMemory = "\(512.mib())"
|
|
guard memoryLimit == expectedMemory else {
|
|
throw IntegrationError.assert(msg: "memory.max \(memoryLimit) != expected \(expectedMemory)")
|
|
}
|
|
|
|
// Verify CPU limit
|
|
let cpuBuffer = BufferWriter()
|
|
let cpuExec = try await container.exec("check-cpu") { config in
|
|
config.arguments = ["cat", "/sys/fs/cgroup/cpu.max"]
|
|
config.stdout = cpuBuffer
|
|
}
|
|
try await cpuExec.start()
|
|
status = try await cpuExec.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "check-cpu status \(status) != 0")
|
|
}
|
|
try await cpuExec.delete()
|
|
|
|
guard let cpuLimit = String(data: cpuBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to parse cpu.max")
|
|
}
|
|
let expectedCpu = "200000 100000" // 2 CPUs: quota=200000, period=100000
|
|
guard cpuLimit == expectedCpu else {
|
|
throw IntegrationError.assert(msg: "cpu.max '\(cpuLimit)' != expected '\(expectedCpu)'")
|
|
}
|
|
|
|
try await sleepExec.delete()
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testMemoryEventsOOMKill() async throws {
|
|
let id = "test-memory-events-oom-kill"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "infinity"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Run a process that will exceed the memory limit and get OOM-killed
|
|
let exec = try await container.exec("oom-trigger") { config in
|
|
// First set a 2MB memory limit on the container's cgroup, then allocate more
|
|
config.arguments = [
|
|
"sh",
|
|
"-c",
|
|
"echo 2097152 > /sys/fs/cgroup/memory.max && dd if=/dev/zero of=/dev/null bs=100M",
|
|
]
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
if status.exitCode == 0 {
|
|
throw IntegrationError.assert(msg: "expected exit code > 0")
|
|
}
|
|
try await exec.delete()
|
|
|
|
let stats = try await container.statistics(categories: .memoryEvents)
|
|
|
|
guard let events = stats.memoryEvents else {
|
|
throw IntegrationError.assert(msg: "expected memoryEvents to be present")
|
|
}
|
|
|
|
print("Memory events for container \(id):")
|
|
print(" low: \(events.low)")
|
|
print(" high: \(events.high)")
|
|
print(" max: \(events.max)")
|
|
print(" oom: \(events.oom)")
|
|
print(" oomKill: \(events.oomKill)")
|
|
|
|
guard events.oomKill > 0 else {
|
|
throw IntegrationError.assert(msg: "expected oomKill > 0, got \(events.oomKill)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testNoSerialConsole() async throws {
|
|
let id = "test-no-serial-console"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/true"]
|
|
}
|
|
|
|
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: "process status \(status) != 0")
|
|
}
|
|
}
|
|
|
|
func testUnixSocketIntoGuest() async throws {
|
|
let id = "test-unixsocket-into-guest"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostSocketPath = try createHostUnixSocket()
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.sockets = [
|
|
UnixSocketConfiguration(
|
|
source: URL(filePath: hostSocketPath),
|
|
destination: URL(filePath: "/tmp/test.sock"),
|
|
direction: .into
|
|
)
|
|
]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Execute ls -l to check the socket exists and is indeed a socket
|
|
let lsExec = try await container.exec("ls-socket") { config in
|
|
config.arguments = ["ls", "-l", "/tmp/test.sock"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await lsExec.start()
|
|
let status = try await lsExec.wait()
|
|
try await lsExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls command failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert ls output to UTF8")
|
|
}
|
|
|
|
// Socket files in ls -l output start with 's'
|
|
guard output.hasPrefix("s") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected socket file (starting with 's'), got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// NOTE: Once upon a time our guest agent created any proxied unix sockets at
|
|
// a path that contained the container ID in it. The problem here is if the container
|
|
// ID is comically long we exceed the max length of a unix domain socket path.
|
|
func testUnixSocketIntoGuestLongContainerID() async throws {
|
|
let id = "test-unixsocket-long-id-" + String(repeating: "a", count: 40)
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostSocketPath = try createHostUnixSocket()
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.sockets = [
|
|
UnixSocketConfiguration(
|
|
source: URL(filePath: hostSocketPath),
|
|
destination: URL(filePath: "/tmp/test.sock"),
|
|
direction: .into
|
|
)
|
|
]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let lsExec = try await container.exec("ls-socket") { config in
|
|
config.arguments = ["ls", "-l", "/tmp/test.sock"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await lsExec.start()
|
|
let status = try await lsExec.wait()
|
|
try await lsExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls command failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert ls output to UTF8")
|
|
}
|
|
|
|
guard output.hasPrefix("s") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected socket file (starting with 's'), got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testNonClosureConstructor() async throws {
|
|
let id = "test-container-non-closure-constructor"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let config = LinuxContainer.Configuration(
|
|
process: LinuxProcessConfiguration(arguments: ["/bin/true"])
|
|
)
|
|
let container = try LinuxContainer(
|
|
id,
|
|
rootfs: bs.rootfs,
|
|
vmm: bs.vmm,
|
|
configuration: config
|
|
)
|
|
|
|
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: "process status \(status) != 0")
|
|
}
|
|
}
|
|
|
|
private func createHostUnixSocket() throws -> String {
|
|
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let socketPath = dir.appendingPathComponent("test.sock").path
|
|
|
|
let socket = try Socket(type: UnixType(path: socketPath))
|
|
try socket.listen()
|
|
|
|
return socketPath
|
|
}
|
|
|
|
private func createMountDirectory() throws -> URL {
|
|
let dir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
try "hello".write(to: dir.appendingPathComponent("hi.txt"), atomically: true, encoding: .utf8)
|
|
return dir
|
|
}
|
|
|
|
func testUnixSocketIntoGuestSymlink() async throws {
|
|
let id = "test-unixsocket-into-guest-symlink"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostSocketPath = try createHostUnixSocket()
|
|
|
|
let buffer = BufferWriter()
|
|
// Use /var/run/test.sock. Alpine has /var/run -> /run symlink
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.sockets = [
|
|
UnixSocketConfiguration(
|
|
source: URL(filePath: hostSocketPath),
|
|
destination: URL(filePath: "/var/run/test.sock"),
|
|
direction: .into
|
|
)
|
|
]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let lsExec = try await container.exec("ls-socket") { config in
|
|
config.arguments = ["ls", "-l", "/var/run/test.sock"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await lsExec.start()
|
|
let status = try await lsExec.wait()
|
|
try await lsExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls command failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert ls output to UTF8")
|
|
}
|
|
|
|
// Socket files in ls -l output start with 's'
|
|
guard output.hasPrefix("s") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected socket file (starting with 's'), got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testBootLogFileHandle() async throws {
|
|
let id = "test-bootlog-filehandle"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a pipe to capture boot log data
|
|
let pipe = Pipe()
|
|
let bootLog = BootLog.fileHandle(pipe.fileHandleForWriting)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/echo", "test complete"]
|
|
config.bootLog = bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
try pipe.fileHandleForWriting.close()
|
|
let bootLogData = try pipe.fileHandleForReading.readToEnd()
|
|
guard let bootLogData = bootLogData, bootLogData.count > 0 else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected to receive boot log data from pipe, but got no data")
|
|
}
|
|
|
|
guard let bootLogString = String(data: bootLogData, encoding: .utf8) else {
|
|
throw IntegrationError.assert(
|
|
msg: "failed to convert boot log data to UTF8 string")
|
|
}
|
|
|
|
guard bootLogString.count > 100 else {
|
|
throw IntegrationError.assert(
|
|
msg: "boot log output smaller than expected: got \(bootLogString.count)")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testLargeStdioOutput() async throws {
|
|
let id = "test-large-stdout-stderr-output"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let stdoutBuffer = DiscardingWriter()
|
|
let stderrBuffer = DiscardingWriter()
|
|
|
|
let exec = try await container.exec("large-output") { config in
|
|
config.arguments = [
|
|
"sh",
|
|
"-c",
|
|
"""
|
|
dd if=/dev/zero bs=1M count=250 status=none && \
|
|
dd if=/dev/zero bs=1M count=250 status=none >&2
|
|
""",
|
|
]
|
|
config.stdout = stdoutBuffer
|
|
config.stderr = stderrBuffer
|
|
}
|
|
|
|
let started = Date().timeIntervalSinceReferenceDate
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
|
|
let lasted = Date().timeIntervalSinceReferenceDate - started
|
|
print("Test \(id) finished process ingesting stdio in \(lasted)")
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec process status \(status) != 0")
|
|
}
|
|
|
|
try await exec.delete()
|
|
|
|
let expectedSize = 250 * 1024 * 1024
|
|
guard stdoutBuffer.count == expectedSize else {
|
|
throw IntegrationError.assert(
|
|
msg: "stdout size \(stdoutBuffer.count) != expected \(expectedSize)")
|
|
}
|
|
|
|
guard stderrBuffer.count == expectedSize else {
|
|
throw IntegrationError.assert(
|
|
msg: "stderr size \(stderrBuffer.count) != expected \(expectedSize)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testProcessDeleteIdempotency() async throws {
|
|
let id = "test-process-delete-idempotency"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Create an exec process
|
|
let exec = try await container.exec("test-exec") { config in
|
|
config.arguments = ["/bin/true"]
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec process status \(status) != 0")
|
|
}
|
|
|
|
// Call delete twice to verify idempotency
|
|
try await exec.delete()
|
|
try await exec.delete() // Should be a no-op
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testMultipleExecsWithoutDelete() async throws {
|
|
let id = "test-multiple-execs-without-delete"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Create 3 exec processes without deleting them
|
|
let exec1 = try await container.exec("exec-1") { config in
|
|
config.arguments = ["/bin/true"]
|
|
}
|
|
try await exec1.start()
|
|
let status1 = try await exec1.wait()
|
|
guard status1.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec1 process status \(status1) != 0")
|
|
}
|
|
|
|
let exec2 = try await container.exec("exec-2") { config in
|
|
config.arguments = ["/bin/true"]
|
|
}
|
|
try await exec2.start()
|
|
let status2 = try await exec2.wait()
|
|
guard status2.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec2 process status \(status2) != 0")
|
|
}
|
|
|
|
let exec3 = try await container.exec("exec-3") { config in
|
|
config.arguments = ["/bin/true"]
|
|
}
|
|
try await exec3.start()
|
|
let status3 = try await exec3.wait()
|
|
guard status3.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec3 process status \(status3) != 0")
|
|
}
|
|
|
|
// Stop should handle cleanup of all exec processes gracefully
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testNonExistentBinary() async throws {
|
|
let id = "test-non-existent-binary"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["foo-bar-baz"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
do {
|
|
try await container.start()
|
|
} catch {
|
|
return
|
|
}
|
|
try await container.stop()
|
|
throw IntegrationError.assert(msg: "container start should have failed")
|
|
}
|
|
|
|
// MARK: - Capability Tests
|
|
|
|
func testCapabilitiesSysAdmin() async throws {
|
|
let id = "test-capabilities-sysadmin"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// First test: without CAP_SYS_ADMIN (should be denied)
|
|
let bufferDenied = BufferWriter()
|
|
let containerWithoutSysAdmin = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities()
|
|
config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp || echo 'mount failed as expected'"]
|
|
config.process.stdout = bufferDenied
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithoutSysAdmin.create()
|
|
try await containerWithoutSysAdmin.start()
|
|
|
|
var status = try await containerWithoutSysAdmin.wait()
|
|
try await containerWithoutSysAdmin.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container should have run successfully, got exit code \(status.exitCode)")
|
|
}
|
|
|
|
guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard outputDenied.contains("mount failed as expected") else {
|
|
throw IntegrationError.assert(msg: "expected mount failure message, got: \(outputDenied)")
|
|
}
|
|
|
|
// Second test: with CAP_SYS_ADMIN (should succeed)
|
|
let containerWithSysAdmin = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities(capabilities: [.sysAdmin])
|
|
config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithSysAdmin.create()
|
|
try await containerWithSysAdmin.start()
|
|
|
|
status = try await containerWithSysAdmin.wait()
|
|
try await containerWithSysAdmin.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container with CAP_SYS_ADMIN should mount successfully, got exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testCapabilitiesNetAdmin() async throws {
|
|
let id = "test-capabilities-netadmin"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// First test: without CAP_NET_ADMIN (should be denied)
|
|
let bufferDenied = BufferWriter()
|
|
let containerWithoutNetAdmin = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities()
|
|
config.process.arguments = ["/bin/sh", "-c", "ip link set lo down 2>/dev/null || echo 'network operation denied as expected'"]
|
|
config.process.stdout = bufferDenied
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithoutNetAdmin.create()
|
|
try await containerWithoutNetAdmin.start()
|
|
|
|
var status = try await containerWithoutNetAdmin.wait()
|
|
try await containerWithoutNetAdmin.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container should handle network denial gracefully, got exit code \(status.exitCode)")
|
|
}
|
|
|
|
guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard outputDenied.contains("network operation denied as expected") else {
|
|
throw IntegrationError.assert(msg: "expected network denial message, got: \(outputDenied)")
|
|
}
|
|
|
|
// Second test: with CAP_NET_ADMIN (should succeed)
|
|
let bufferAllowed = BufferWriter()
|
|
let containerWithNetAdmin = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities(capabilities: [.netAdmin])
|
|
config.process.arguments = ["/bin/sh", "-c", "ip link set lo down && ip link set lo up"]
|
|
config.process.stdout = bufferAllowed
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithNetAdmin.create()
|
|
try await containerWithNetAdmin.start()
|
|
|
|
status = try await containerWithNetAdmin.wait()
|
|
try await containerWithNetAdmin.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container with CAP_NET_ADMIN should perform network operations, got exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testCapabilitiesOCIDefault() async throws {
|
|
let id = "test-capabilities-OCI-default"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
// Use default capability set
|
|
config.process.capabilities = .defaultOCICapabilities
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'Running with OCI default capabilities'"]
|
|
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 with OCI default capabilities should run, got exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testCapabilitiesAllCapabilities() async throws {
|
|
let id = "test-capabilities-all"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = .allCapabilities
|
|
config.process.arguments = ["/bin/sh", "-c", "mount -t tmpfs tmpfs /tmp && ip link set lo down"]
|
|
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 with all capabilities should perform all operations, got exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testCapabilitiesFileOwnership() async throws {
|
|
let id = "test-capabilities-chown"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// First test: without CAP_CHOWN
|
|
let bufferDenied = BufferWriter()
|
|
let containerWithoutChown = try LinuxContainer("\(id)-denied", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities()
|
|
config.process.arguments = ["/bin/sh", "-c", "touch /tmp/testfile && chown 1000:1000 /tmp/testfile 2>/dev/null || echo 'chown denied as expected'"]
|
|
config.process.stdout = bufferDenied
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithoutChown.create()
|
|
try await containerWithoutChown.start()
|
|
|
|
var status = try await containerWithoutChown.wait()
|
|
try await containerWithoutChown.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container should handle chown denial gracefully, got exit code \(status.exitCode)")
|
|
}
|
|
|
|
guard let outputDenied = String(data: bufferDenied.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard outputDenied.contains("chown denied as expected") else {
|
|
throw IntegrationError.assert(msg: "expected chown denial message, got: \(outputDenied)")
|
|
}
|
|
|
|
// Second test: with CAP_CHOWN
|
|
let bufferAllowed = BufferWriter()
|
|
let containerWithChown = try LinuxContainer("\(id)-allowed", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.capabilities = LinuxCapabilities(capabilities: [.chown])
|
|
config.process.arguments = ["/bin/sh", "-c", "touch /tmp/testfile && chown 1000:1000 /tmp/testfile"]
|
|
config.process.stdout = bufferAllowed
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await containerWithChown.create()
|
|
try await containerWithChown.start()
|
|
|
|
status = try await containerWithChown.wait()
|
|
try await containerWithChown.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "container with CAP_CHOWN should succeed, got exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testDefaultMaskedAndReadonlyPaths() async throws {
|
|
let id = "test-masked-readonly-defaults"
|
|
|
|
// A default container (default capabilities + default masked/readonly
|
|
// paths) must have the OCI standard set enforced by vmexec without any
|
|
// opt-in. Probe from inside the guest:
|
|
// 1. readonlyPaths: writing under /proc/sys fails with EROFS. EROFS
|
|
// (not EPERM) proves the read-only remount rather than a mere
|
|
// capability denial — the default (restricted) caps already lack
|
|
// CAP_SYS_ADMIN, so a writable /proc/sys would fail with EPERM.
|
|
// The write is wrapped in a brace group so the shell's redirection
|
|
// failure ("can't create ...: Read-only file system") is captured:
|
|
// a trailing `2>&1` on the command misses it, because the failing
|
|
// `>` redirection aborts before `2>&1` is applied.
|
|
// 2. maskedPaths: at least one default-masked path is mounted over
|
|
// (visible in /proc/self/mountinfo). Checked via mountinfo rather
|
|
// than reading the target, since some masked paths (e.g.
|
|
// /proc/kcore) require CAP_SYS_RAWIO to read and would appear empty
|
|
// even if masking were broken, yielding a false pass.
|
|
let probe = """
|
|
set -u
|
|
rerr=$( { echo x > /proc/sys/kernel/hostname; } 2>&1 )
|
|
case "$rerr" in
|
|
*"Read-only file system"*) ;;
|
|
*) echo "RO-FAIL: writing /proc/sys expected EROFS, got: ${rerr:-<write succeeded>}"; exit 1 ;;
|
|
esac
|
|
masked=0
|
|
for p in /proc/kcore /proc/keys /proc/scsi /proc/sched_debug /sys/firmware /sys/devices/virtual/powercap; do
|
|
grep -q " $p " /proc/self/mountinfo && masked=$((masked + 1))
|
|
done
|
|
if [ "$masked" -eq 0 ]; then
|
|
echo "MASK-FAIL: no default masked path mounted"
|
|
cat /proc/self/mountinfo
|
|
exit 1
|
|
fi
|
|
echo "MASKED-RO-OK masked=$masked"
|
|
"""
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
// Intentionally leave config.maskedPaths / readonlyPaths /
|
|
// process.capabilities untouched — the point is that the defaults
|
|
// are secure out of the box.
|
|
config.process.arguments = ["/bin/sh", "-c", probe]
|
|
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()
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8) ?? "<non-utf8 output>"
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "default masked/readonly enforcement failed (exit \(status.exitCode)): \(output)")
|
|
}
|
|
guard output.contains("MASKED-RO-OK") else {
|
|
throw IntegrationError.assert(msg: "expected MASKED-RO-OK sentinel, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testStat() async throws {
|
|
let id = "test-stat"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
func assertExec(_ container: LinuxContainer, id: String, cmd: String) async throws {
|
|
let exec = try await container.exec(id) { config in
|
|
config.arguments = ["sh", "-c", cmd]
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "\(id) failed with exit code \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// regular file: "regular file" is exactly 12 bytes
|
|
try await assertExec(container, id: "create-regular-file", cmd: "echo -n 'regular file' > /tmp/regular-file.txt")
|
|
// directory
|
|
try await assertExec(container, id: "create-dir", cmd: "mkdir /tmp/test-dir")
|
|
// relative symlink so stat() resolves the target within the same directory
|
|
try await assertExec(container, id: "create-symlink", cmd: "ln -s regular-file.txt /tmp/test-link")
|
|
// FIFO
|
|
try await assertExec(container, id: "create-fifo", cmd: "mkfifo /tmp/test-fifo")
|
|
|
|
let vsock = try await container.dialVsock(port: 1024)
|
|
let vminitd = try await Vminitd(connection: vsock, group: Self.eventLoop)
|
|
|
|
let root = URL(filePath: container.root)
|
|
|
|
// --- regular file ---
|
|
let regularStat = try await vminitd.stat(path: root.appending(path: "tmp/regular-file.txt"))
|
|
guard (regularStat.mode & UInt32(S_IFMT)) == S_IFREG else {
|
|
throw IntegrationError.assert(msg: "regular file: expected S_IFREG, got mode 0x\(String(regularStat.mode, radix: 16))")
|
|
}
|
|
guard regularStat.size == 12 else {
|
|
throw IntegrationError.assert(msg: "regular file: expected size 12, got \(regularStat.size)")
|
|
}
|
|
guard regularStat.ino > 0 else {
|
|
throw IntegrationError.assert(msg: "regular file: expected non-zero inode, got \(regularStat.ino)")
|
|
}
|
|
guard regularStat.nlink >= 1 else {
|
|
throw IntegrationError.assert(msg: "regular file: expected nlink >= 1, got \(regularStat.nlink)")
|
|
}
|
|
|
|
// --- directory ---
|
|
let dirStat = try await vminitd.stat(path: root.appending(path: "tmp/test-dir"))
|
|
guard (dirStat.mode & UInt32(S_IFMT)) == S_IFDIR else {
|
|
throw IntegrationError.assert(msg: "directory: expected S_IFDIR, got mode 0x\(String(dirStat.mode, radix: 16))")
|
|
}
|
|
// A directory always has at least 2 hard links (. and its entry in the parent)
|
|
guard dirStat.nlink >= 2 else {
|
|
throw IntegrationError.assert(msg: "directory: expected nlink >= 2, got \(dirStat.nlink)")
|
|
}
|
|
|
|
// --- symlink ---
|
|
// stat(2) follows symlinks, so the result reflects the target regular file
|
|
let symlinkStat = try await vminitd.stat(path: root.appending(path: "tmp/test-link"))
|
|
guard (symlinkStat.mode & UInt32(S_IFMT)) == S_IFREG else {
|
|
throw IntegrationError.assert(msg: "symlink (followed): expected S_IFREG, got mode 0x\(String(symlinkStat.mode, radix: 16))")
|
|
}
|
|
guard symlinkStat.size == regularStat.size else {
|
|
throw IntegrationError.assert(msg: "symlink (followed): expected size \(regularStat.size), got \(symlinkStat.size)")
|
|
}
|
|
|
|
// --- FIFO ---
|
|
let fifoStat = try await vminitd.stat(path: root.appending(path: "tmp/test-fifo"))
|
|
guard (fifoStat.mode & UInt32(S_IFMT)) == S_IFIFO else {
|
|
throw IntegrationError.assert(msg: "FIFO: expected S_IFIFO, got mode 0x\(String(fifoStat.mode, radix: 16))")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyIn() async throws {
|
|
let id = "test-copy-in"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp file on the host with known content
|
|
let testContent = "Hello from the host! This is a copyIn test."
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("test-input.txt")
|
|
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy the file into the container
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/copied-file.txt")
|
|
)
|
|
|
|
// Verify the file exists and has correct content
|
|
let exec = try await container.exec("verify-copy") { config in
|
|
config.arguments = ["cat", "/tmp/copied-file.txt"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat command failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output == testContent else {
|
|
throw IntegrationError.assert(
|
|
msg: "copied file content mismatch: expected '\(testContent)', got '\(output)'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyInFileToExistingDirectory() async throws {
|
|
let id = "test-copy-in-file-to-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let testContent = "copy into an existing guest directory"
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("host-file.txt")
|
|
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let mkdir = try await container.exec("create-copy-target") { config in
|
|
config.arguments = ["mkdir", "-p", "/tmp/copy-target"]
|
|
}
|
|
try await mkdir.start()
|
|
let mkdirStatus = try await mkdir.wait()
|
|
try await mkdir.delete()
|
|
|
|
guard mkdirStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "mkdir failed with status \(mkdirStatus)")
|
|
}
|
|
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/copy-target")
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let verify = try await container.exec("verify-copy-target") { config in
|
|
config.arguments = ["cat", "/tmp/copy-target/host-file.txt"]
|
|
config.stdout = buffer
|
|
}
|
|
try await verify.start()
|
|
let verifyStatus = try await verify.wait()
|
|
try await verify.delete()
|
|
|
|
guard verifyStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat copied file failed with status \(verifyStatus)")
|
|
}
|
|
guard String(data: buffer.data, encoding: .utf8) == testContent else {
|
|
throw IntegrationError.assert(msg: "copied file should land under the existing destination directory")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyInFileToMissingDirectoryFails() async throws {
|
|
let id = "test-copy-in-file-missing-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("host-file.txt")
|
|
try "missing destination directory".write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
do {
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/missing-copy-target/")
|
|
)
|
|
throw IntegrationError.assert(msg: "copyIn should fail when copying a file to a missing destination directory")
|
|
} catch let error as ContainerizationError where error.code == .invalidArgument {
|
|
guard error.description.contains("destination directory does not exist") else {
|
|
throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)")
|
|
}
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyInDirectoryOverExistingFileFails() async throws {
|
|
let id = "test-copy-in-dir-over-file"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("host-dir")
|
|
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
|
|
try "directory content".write(to: hostDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let createFile = try await container.exec("create-existing-file") { config in
|
|
config.arguments = ["sh", "-c", "echo -n existing > /tmp/existing-file"]
|
|
}
|
|
try await createFile.start()
|
|
let createStatus = try await createFile.wait()
|
|
try await createFile.delete()
|
|
|
|
guard createStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "failed to create existing file, status \(createStatus)")
|
|
}
|
|
|
|
do {
|
|
try await container.copyIn(
|
|
from: hostDir,
|
|
to: URL(filePath: "/tmp/existing-file")
|
|
)
|
|
throw IntegrationError.assert(msg: "copyIn should fail when copying a directory over an existing file")
|
|
} catch let error as ContainerizationError where error.code == .invalidArgument {
|
|
guard error.description.contains("cannot copy directory over existing file") else {
|
|
throw IntegrationError.assert(msg: "unexpected copyIn error: \(error)")
|
|
}
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyOut() async throws {
|
|
let id = "test-copy-out"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let testContent = "Hello from the guest! This is a copyOut test."
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("test-output.txt")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Create a file inside the container
|
|
let exec = try await container.exec("create-file") { config in
|
|
config.arguments = ["sh", "-c", "echo -n '\(testContent)' > /tmp/guest-file.txt"]
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "failed to create file in guest, status \(status)")
|
|
}
|
|
|
|
// Copy the file out of the container
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/guest-file.txt"),
|
|
to: hostDestination
|
|
)
|
|
|
|
// Verify the file was copied correctly
|
|
let copiedContent = try String(contentsOf: hostDestination, encoding: .utf8)
|
|
|
|
guard copiedContent == testContent else {
|
|
throw IntegrationError.assert(
|
|
msg: "copied file content mismatch: expected '\(testContent)', got '\(copiedContent)'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyLargeFile() async throws {
|
|
let id = "test-copy-large-file"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a 10MB file on the host with a repeating pattern
|
|
let fileSize = 10 * 1024 * 1024
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("large-file.bin")
|
|
|
|
// Generate data with a repeating pattern
|
|
let pattern = Data("ContainerizationCopyTest".utf8)
|
|
var testData = Data(capacity: fileSize)
|
|
while testData.count < fileSize {
|
|
testData.append(pattern)
|
|
}
|
|
testData = testData.prefix(fileSize)
|
|
try testData.write(to: hostFile)
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("large-file-out.bin")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy large file into the container
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/large-file.bin")
|
|
)
|
|
|
|
// Copy it back out
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/large-file.bin"),
|
|
to: hostDestination
|
|
)
|
|
|
|
// Verify the content matches
|
|
let copiedData = try Data(contentsOf: hostDestination)
|
|
|
|
guard copiedData.count == testData.count else {
|
|
throw IntegrationError.assert(
|
|
msg: "file size mismatch: expected \(testData.count), got \(copiedData.count)")
|
|
}
|
|
|
|
guard copiedData == testData else {
|
|
throw IntegrationError.assert(msg: "file content mismatch after round-trip copy")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyInDirectory() async throws {
|
|
let id = "test-copy-in-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp directory with files, a subdirectory, and a symlink.
|
|
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("test-dir")
|
|
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
|
|
try "file1 content".write(to: hostDir.appendingPathComponent("file1.txt"), atomically: true, encoding: .utf8)
|
|
|
|
let subDir = hostDir.appendingPathComponent("subdir")
|
|
try FileManager.default.createDirectory(at: subDir, withIntermediateDirectories: true)
|
|
try "file2 content".write(to: subDir.appendingPathComponent("file2.txt"), atomically: true, encoding: .utf8)
|
|
|
|
try FileManager.default.createSymbolicLink(
|
|
at: hostDir.appendingPathComponent("link.txt"),
|
|
withDestinationURL: hostDir.appendingPathComponent("file1.txt")
|
|
)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy the directory into the container.
|
|
try await container.copyIn(
|
|
from: hostDir,
|
|
to: URL(filePath: "/tmp/copied-dir")
|
|
)
|
|
|
|
// Verify file1.txt exists with correct content.
|
|
let buffer1 = BufferWriter()
|
|
let exec1 = try await container.exec("verify-file1") { config in
|
|
config.arguments = ["cat", "/tmp/copied-dir/file1.txt"]
|
|
config.stdout = buffer1
|
|
}
|
|
try await exec1.start()
|
|
let status1 = try await exec1.wait()
|
|
try await exec1.delete()
|
|
|
|
guard status1.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat file1.txt failed with status \(status1)")
|
|
}
|
|
guard String(data: buffer1.data, encoding: .utf8) == "file1 content" else {
|
|
throw IntegrationError.assert(msg: "file1.txt content mismatch")
|
|
}
|
|
|
|
// Verify subdir/file2.txt exists with correct content.
|
|
let buffer2 = BufferWriter()
|
|
let exec2 = try await container.exec("verify-file2") { config in
|
|
config.arguments = ["cat", "/tmp/copied-dir/subdir/file2.txt"]
|
|
config.stdout = buffer2
|
|
}
|
|
try await exec2.start()
|
|
let status2 = try await exec2.wait()
|
|
try await exec2.delete()
|
|
|
|
guard status2.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat subdir/file2.txt failed with status \(status2)")
|
|
}
|
|
guard String(data: buffer2.data, encoding: .utf8) == "file2 content" else {
|
|
throw IntegrationError.assert(msg: "subdir/file2.txt content mismatch")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyOutDirectory() async throws {
|
|
let id = "test-copy-out-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("copied-out-dir")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Create a directory structure inside the container.
|
|
let exec = try await container.exec("create-dir") { config in
|
|
config.arguments = [
|
|
"sh", "-c",
|
|
"mkdir -p /tmp/guest-dir/subdir && echo -n 'guest file1' > /tmp/guest-dir/file1.txt && echo -n 'guest file2' > /tmp/guest-dir/subdir/file2.txt",
|
|
]
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "failed to create directory in guest, status \(status)")
|
|
}
|
|
|
|
// Copy the directory out of the container.
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/guest-dir"),
|
|
to: hostDestination
|
|
)
|
|
|
|
// Verify file1.txt was copied correctly.
|
|
let file1Content = try String(contentsOf: hostDestination.appendingPathComponent("file1.txt"), encoding: .utf8)
|
|
guard file1Content == "guest file1" else {
|
|
throw IntegrationError.assert(
|
|
msg: "file1.txt content mismatch: expected 'guest file1', got '\(file1Content)'")
|
|
}
|
|
|
|
// Verify subdir/file2.txt was copied correctly.
|
|
let file2Content = try String(
|
|
contentsOf: hostDestination.appendingPathComponent("subdir").appendingPathComponent("file2.txt"),
|
|
encoding: .utf8
|
|
)
|
|
guard file2Content == "guest file2" else {
|
|
throw IntegrationError.assert(
|
|
msg: "subdir/file2.txt content mismatch: expected 'guest file2', got '\(file2Content)'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyEmptyFile() async throws {
|
|
let id = "test-copy-empty-file"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("empty.txt")
|
|
try Data().write(to: hostFile)
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("empty-out.txt")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy empty file in.
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/empty.txt")
|
|
)
|
|
|
|
// Verify it exists and is empty in the guest.
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("verify-empty") { config in
|
|
config.arguments = ["stat", "-c", "%s", "/tmp/empty.txt"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "stat failed with status \(status)")
|
|
}
|
|
let sizeStr = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard sizeStr == "0" else {
|
|
throw IntegrationError.assert(msg: "empty file should have size 0, got '\(sizeStr ?? "nil")'")
|
|
}
|
|
|
|
// Copy it back out.
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/empty.txt"),
|
|
to: hostDestination
|
|
)
|
|
|
|
let copiedData = try Data(contentsOf: hostDestination)
|
|
guard copiedData.isEmpty else {
|
|
throw IntegrationError.assert(msg: "round-tripped empty file should be empty, got \(copiedData.count) bytes")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyEmptyDirectory() async throws {
|
|
let id = "test-copy-empty-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("empty-dir")
|
|
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy empty directory in.
|
|
try await container.copyIn(
|
|
from: hostDir,
|
|
to: URL(filePath: "/tmp/empty-dir")
|
|
)
|
|
|
|
// Verify it exists and is a directory.
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("verify-empty-dir") { config in
|
|
config.arguments = ["sh", "-c", "test -d /tmp/empty-dir && ls -a /tmp/empty-dir | wc -l"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "empty dir check failed with status \(status)")
|
|
}
|
|
|
|
// ls -a shows . and .. so count should be 2 for an empty dir.
|
|
let countStr = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard countStr == "2" else {
|
|
throw IntegrationError.assert(msg: "empty dir should have 2 entries (. and ..), got '\(countStr ?? "nil")'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyBinaryFile() async throws {
|
|
let id = "test-copy-binary"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a file with all 256 byte values to test binary safety.
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("binary.bin")
|
|
var binaryData = Data(count: 256 * 64)
|
|
for i in 0..<binaryData.count {
|
|
binaryData[i] = UInt8(i % 256)
|
|
}
|
|
try binaryData.write(to: hostFile)
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("binary-out.bin")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/binary.bin")
|
|
)
|
|
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/binary.bin"),
|
|
to: hostDestination
|
|
)
|
|
|
|
let copiedData = try Data(contentsOf: hostDestination)
|
|
|
|
guard copiedData.count == binaryData.count else {
|
|
throw IntegrationError.assert(
|
|
msg: "binary file size mismatch: expected \(binaryData.count), got \(copiedData.count)")
|
|
}
|
|
guard copiedData == binaryData else {
|
|
throw IntegrationError.assert(msg: "binary file content mismatch after round-trip")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyMultipleFiles() async throws {
|
|
let id = "test-copy-multiple"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let tmpDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let files = (0..<5).map { i in
|
|
(
|
|
host: tmpDir.appendingPathComponent("file\(i).txt"),
|
|
guest: URL(filePath: "/tmp/multi/file\(i).txt"),
|
|
content: "Content of file \(i) with some padding: \(String(repeating: "x", count: i * 1000))"
|
|
)
|
|
}
|
|
|
|
for file in files {
|
|
try file.content.write(to: file.host, atomically: true, encoding: .utf8)
|
|
}
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy all files in sequentially.
|
|
for file in files {
|
|
try await container.copyIn(
|
|
from: file.host,
|
|
to: file.guest,
|
|
createParents: true
|
|
)
|
|
}
|
|
|
|
// Verify each file.
|
|
for (i, file) in files.enumerated() {
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("verify-\(i)") { config in
|
|
config.arguments = ["cat", file.guest.path]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat file\(i).txt failed with status \(status)")
|
|
}
|
|
let output = String(data: buffer.data, encoding: .utf8)
|
|
guard output == file.content else {
|
|
throw IntegrationError.assert(msg: "file\(i).txt content mismatch")
|
|
}
|
|
}
|
|
|
|
// Copy all files back out and verify.
|
|
for (i, file) in files.enumerated() {
|
|
let outPath = tmpDir.appendingPathComponent("out-file\(i).txt")
|
|
try await container.copyOut(
|
|
from: file.guest,
|
|
to: outPath
|
|
)
|
|
let copiedContent = try String(contentsOf: outPath, encoding: .utf8)
|
|
guard copiedContent == file.content else {
|
|
throw IntegrationError.assert(msg: "file\(i).txt round-trip content mismatch")
|
|
}
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyDirectoryRoundTrip() async throws {
|
|
let id = "test-copy-dir-rt"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a directory tree with varied content: nested dirs, different sizes, binary data.
|
|
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("dir-rt")
|
|
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
|
|
|
|
// Top-level files.
|
|
try "top level".write(to: hostDir.appendingPathComponent("root.txt"), atomically: true, encoding: .utf8)
|
|
try Data(repeating: 0xAB, count: 4096).write(to: hostDir.appendingPathComponent("binary.bin"))
|
|
|
|
// Nested 3 levels deep.
|
|
let deep = hostDir.appendingPathComponent("a").appendingPathComponent("b").appendingPathComponent("c")
|
|
try FileManager.default.createDirectory(at: deep, withIntermediateDirectories: true)
|
|
try "deep file".write(to: deep.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8)
|
|
|
|
// Empty subdirectory.
|
|
let emptySubdir = hostDir.appendingPathComponent("empty-sub")
|
|
try FileManager.default.createDirectory(at: emptySubdir, withIntermediateDirectories: true)
|
|
|
|
// File with special characters in content (not name).
|
|
try "line1\nline2\ttab\r\nwindows\0null".write(
|
|
to: hostDir.appendingPathComponent("special.txt"), atomically: true, encoding: .utf8)
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("dir-rt-out")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy in.
|
|
try await container.copyIn(
|
|
from: hostDir,
|
|
to: URL(filePath: "/tmp/dir-rt")
|
|
)
|
|
|
|
// Copy back out.
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/dir-rt"),
|
|
to: hostDestination
|
|
)
|
|
|
|
// Verify root.txt.
|
|
let rootContent = try String(contentsOf: hostDestination.appendingPathComponent("root.txt"), encoding: .utf8)
|
|
guard rootContent == "top level" else {
|
|
throw IntegrationError.assert(msg: "root.txt mismatch: '\(rootContent)'")
|
|
}
|
|
|
|
// Verify binary.bin.
|
|
let binaryContent = try Data(contentsOf: hostDestination.appendingPathComponent("binary.bin"))
|
|
guard binaryContent == Data(repeating: 0xAB, count: 4096) else {
|
|
throw IntegrationError.assert(msg: "binary.bin mismatch")
|
|
}
|
|
|
|
// Verify deep nested file.
|
|
let deepOut =
|
|
hostDestination
|
|
.appendingPathComponent("a")
|
|
.appendingPathComponent("b")
|
|
.appendingPathComponent("c")
|
|
.appendingPathComponent("deep.txt")
|
|
let deepContent = try String(contentsOf: deepOut, encoding: .utf8)
|
|
guard deepContent == "deep file" else {
|
|
throw IntegrationError.assert(msg: "deep.txt mismatch: '\(deepContent)'")
|
|
}
|
|
|
|
// Verify empty subdirectory exists.
|
|
var isDir: ObjCBool = false
|
|
let emptySubdirExists = FileManager.default.fileExists(
|
|
atPath: hostDestination.appendingPathComponent("empty-sub").path,
|
|
isDirectory: &isDir
|
|
)
|
|
guard emptySubdirExists && isDir.boolValue else {
|
|
throw IntegrationError.assert(msg: "empty-sub directory should exist after round trip")
|
|
}
|
|
|
|
// Verify special characters file.
|
|
let specialContent = try String(
|
|
contentsOf: hostDestination.appendingPathComponent("special.txt"), encoding: .utf8)
|
|
guard specialContent == "line1\nline2\ttab\r\nwindows\0null" else {
|
|
throw IntegrationError.assert(msg: "special.txt mismatch")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyInCreateParents() async throws {
|
|
let id = "test-copy-parents"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let testContent = "create parents test"
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("parents-test.txt")
|
|
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy to a deeply nested path that doesn't exist yet.
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/a/b/c/d/e/file.txt"),
|
|
createParents: true
|
|
)
|
|
|
|
// Verify the file got there.
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("verify-parents") { config in
|
|
config.arguments = ["cat", "/tmp/a/b/c/d/e/file.txt"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "cat failed with status \(status)")
|
|
}
|
|
guard String(data: buffer.data, encoding: .utf8) == testContent else {
|
|
throw IntegrationError.assert(msg: "content mismatch after copy with createParents")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyFilePermissions() async throws {
|
|
let id = "test-copy-perms"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("perms-test.sh")
|
|
try "#!/bin/sh\necho hello".write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Copy with executable permissions.
|
|
try await container.copyIn(
|
|
from: hostFile,
|
|
to: URL(filePath: "/tmp/perms-test.sh"),
|
|
mode: 0o755
|
|
)
|
|
|
|
// Verify the file is executable by running it.
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("run-script") { config in
|
|
config.arguments = ["/tmp/perms-test.sh"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "script execution failed with status \(status)")
|
|
}
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard output == "hello" else {
|
|
throw IntegrationError.assert(msg: "script output mismatch: '\(output ?? "nil")'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testCopyLargeDirectory() async throws {
|
|
let id = "test-copy-large-dir"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a directory with many files to stress the archive path.
|
|
let hostDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("large-dir")
|
|
try FileManager.default.createDirectory(at: hostDir, withIntermediateDirectories: true)
|
|
|
|
let fileCount = 100
|
|
for i in 0..<fileCount {
|
|
let content = "File \(i): \(String(repeating: String(i), count: 512))"
|
|
try content.write(
|
|
to: hostDir.appendingPathComponent("file-\(String(format: "%03d", i)).txt"),
|
|
atomically: true,
|
|
encoding: .utf8
|
|
)
|
|
}
|
|
|
|
let hostDestination = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("large-dir-out")
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
try await container.copyIn(
|
|
from: hostDir,
|
|
to: URL(filePath: "/tmp/large-dir")
|
|
)
|
|
|
|
// Verify file count in the guest.
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("count-files") { config in
|
|
config.arguments = ["sh", "-c", "ls /tmp/large-dir/*.txt | wc -l"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls | wc -l failed with status \(status)")
|
|
}
|
|
let countStr = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard countStr == "\(fileCount)" else {
|
|
throw IntegrationError.assert(msg: "expected \(fileCount) files in guest, got '\(countStr ?? "nil")'")
|
|
}
|
|
|
|
// Copy back out.
|
|
try await container.copyOut(
|
|
from: URL(filePath: "/tmp/large-dir"),
|
|
to: hostDestination
|
|
)
|
|
|
|
// Spot-check a few files.
|
|
for i in [0, fileCount / 2, fileCount - 1] {
|
|
let expectedContent = "File \(i): \(String(repeating: String(i), count: 512))"
|
|
let actualContent = try String(
|
|
contentsOf: hostDestination.appendingPathComponent("file-\(String(format: "%03d", i)).txt"),
|
|
encoding: .utf8
|
|
)
|
|
guard actualContent == expectedContent else {
|
|
throw IntegrationError.assert(msg: "file-\(String(format: "%03d", i)).txt content mismatch after round trip")
|
|
}
|
|
}
|
|
|
|
// Verify total file count on host.
|
|
let outFiles = try FileManager.default.contentsOfDirectory(atPath: hostDestination.path)
|
|
guard outFiles.count == fileCount else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected \(fileCount) files on host after copyOut, got \(outFiles.count)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testReadOnlyRootfs() async throws {
|
|
let id = "test-readonly-rootfs"
|
|
|
|
let bs = try await bootstrap(id)
|
|
var rootfs = bs.rootfs
|
|
rootfs.options.append("ro")
|
|
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["touch", "/testfile"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
// touch should fail on a read-only rootfs
|
|
guard status.exitCode != 0 else {
|
|
throw IntegrationError.assert(msg: "touch should have failed on read-only rootfs")
|
|
}
|
|
}
|
|
|
|
func testReadOnlyRootfsHostsFileWritten() async throws {
|
|
let id = "test-readonly-rootfs-hosts"
|
|
|
|
let bs = try await bootstrap(id)
|
|
var rootfs = bs.rootfs
|
|
rootfs.options.append("ro")
|
|
let buffer = BufferWriter()
|
|
let entry = Hosts.Entry.localHostIPV4(comment: "ReadOnlyTest")
|
|
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
|
|
// Verify /etc/hosts was written before rootfs was remounted read-only
|
|
config.process.arguments = ["cat", "/etc/hosts"]
|
|
config.process.stdout = buffer
|
|
config.hosts = Hosts(entries: [entry])
|
|
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: "cat /etc/hosts failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.contains("ReadOnlyTest") else {
|
|
throw IntegrationError.assert(msg: "expected /etc/hosts to contain our entry, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testReadOnlyRootfsDNSConfigured() async throws {
|
|
let id = "test-readonly-rootfs-dns"
|
|
|
|
let bs = try await bootstrap(id)
|
|
var rootfs = bs.rootfs
|
|
rootfs.options.append("ro")
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
|
|
// Verify /etc/resolv.conf was written before rootfs was remounted read-only
|
|
config.process.arguments = ["cat", "/etc/resolv.conf"]
|
|
config.process.stdout = buffer
|
|
config.dns = DNS(nameservers: ["8.8.8.8", "8.8.4.4"])
|
|
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: "cat /etc/resolv.conf failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.contains("8.8.8.8") && output.contains("8.8.4.4") else {
|
|
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testLargeStdinInput() async throws {
|
|
let id = "test-large-stdin-input"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let inputSize = 128 * 1024
|
|
let inputData = Data(repeating: 0x41, count: inputSize) // 'A' repeated
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat"]
|
|
config.process.stdin = StdinBuffer(data: inputData)
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard buffer.data.count == inputSize else {
|
|
throw IntegrationError.assert(
|
|
msg: "output size \(buffer.data.count) != input size \(inputSize)")
|
|
}
|
|
|
|
guard buffer.data == inputData else {
|
|
throw IntegrationError.assert(msg: "output data does not match input data")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testExecLargeStdinInput() async throws {
|
|
let id = "test-exec-large-stdin-input"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let inputSize = 128 * 1024
|
|
let inputData = Data(repeating: 0x42, count: inputSize)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("large-stdin-exec") { config in
|
|
config.arguments = ["cat"]
|
|
config.stdin = StdinBuffer(data: inputData)
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec status \(status) != 0")
|
|
}
|
|
|
|
guard buffer.data.count == inputSize else {
|
|
throw IntegrationError.assert(msg: "output size \(buffer.data.count) != \(inputSize)")
|
|
}
|
|
|
|
guard buffer.data == inputData else {
|
|
throw IntegrationError.assert(msg: "output data mismatch")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testExecCustomPathResolution() async throws {
|
|
let id = "test-exec-custom-path"
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Create a script in a non-standard directory
|
|
let setup = try await container.exec("setup") { config in
|
|
config.arguments = [
|
|
"sh", "-c",
|
|
"mkdir -p /tmp/custom-bin && printf '#!/bin/sh\\necho CUSTOM_PATH_OK' > /tmp/custom-bin/mytest && chmod +x /tmp/custom-bin/mytest",
|
|
]
|
|
}
|
|
try await setup.start()
|
|
let setupStatus = try await setup.wait()
|
|
try await setup.delete()
|
|
guard setupStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "setup failed: \(setupStatus)")
|
|
}
|
|
|
|
// Exec bare command with custom PATH — this exercises ExecCommand.swift
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("custom-path") { config in
|
|
config.arguments = ["mytest"]
|
|
config.environmentVariables = ["PATH=/tmp/custom-bin"]
|
|
config.stdout = buffer
|
|
}
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec with custom PATH failed: \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to read output")
|
|
}
|
|
guard output.contains("CUSTOM_PATH_OK") else {
|
|
throw IntegrationError.assert(msg: "expected CUSTOM_PATH_OK, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testStdinExplicitClose() async throws {
|
|
let id = "test-stdin-explicit-close"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let inputData = "explicit close test\n".data(using: .utf8)!
|
|
let buffer = BufferWriter()
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let exec = try await container.exec("stdin-close-exec") { config in
|
|
config.arguments = ["head", "-n", "1"]
|
|
config.stdin = StdinBuffer(data: inputData)
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec status \(status) != 0")
|
|
}
|
|
|
|
guard buffer.data == inputData else {
|
|
throw IntegrationError.assert(msg: "output mismatch")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testStdinBinaryData() async throws {
|
|
let id = "test-stdin-binary-data"
|
|
let bs = try await bootstrap(id)
|
|
|
|
var inputData = Data()
|
|
for i: UInt8 in 0...255 {
|
|
inputData.append(contentsOf: [UInt8](repeating: i, count: 256))
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat"]
|
|
config.process.stdin = StdinBuffer(data: inputData)
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard buffer.data == inputData else {
|
|
throw IntegrationError.assert(msg: "binary data mismatch")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testStdinMultipleChunks() async throws {
|
|
let id = "test-stdin-multiple-chunks"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let chunks = (0..<10).map { i in
|
|
Data(repeating: UInt8(0x30 + i), count: 10 * 1024)
|
|
}
|
|
let expectedData = chunks.reduce(Data()) { $0 + $1 }
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat"]
|
|
config.process.stdin = ChunkedStdinBuffer(chunks: chunks, delayMs: 10)
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard buffer.data == expectedData else {
|
|
throw IntegrationError.assert(msg: "chunked data mismatch")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testStdinVeryLarge() async throws {
|
|
let id = "test-stdin-very-large"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let inputSize = 10 * 1024 * 1024
|
|
let inputData = Data(repeating: 0x58, count: inputSize)
|
|
|
|
let stdout = DiscardingWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["wc", "-c"]
|
|
config.process.stdin = StdinBuffer(data: inputData)
|
|
config.process.stdout = stdout
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard stdout.count > 0 else {
|
|
throw IntegrationError.assert(msg: "no output from wc")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
@available(macOS 26.0, *)
|
|
func testInterfaceMTU() async throws {
|
|
let id = "test-interface-mtu"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let customMTU: UInt32 = 1400
|
|
var network = try VmnetNetwork()
|
|
defer {
|
|
try? network.releaseInterface(id)
|
|
}
|
|
|
|
guard let interface = try network.createInterface(id, mtu: customMTU) else {
|
|
throw IntegrationError.assert(msg: "failed to create network interface")
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Check the MTU of eth0
|
|
let exec = try await container.exec("check-mtu") { config in
|
|
config.arguments = ["ip", "link", "show", "eth0"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip link show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// Output should contain "mtu 1400"
|
|
guard output.contains("mtu \(customMTU)") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected MTU \(customMTU) in output, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
func testSingleFileMount() async throws {
|
|
let id = "test-single-file-mount"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp file with known content
|
|
let testContent = "Hello from single file mount!"
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("config.txt")
|
|
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat", "/etc/myconfig.txt"]
|
|
// Mount a single file using virtiofs share
|
|
config.mounts.append(.share(source: hostFile.path, destination: "/etc/myconfig.txt"))
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output == testContent else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected '\(testContent)', got '\(output)'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testSingleFileMountReadOnly() async throws {
|
|
let id = "test-single-file-mount-readonly"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp file with known content
|
|
let testContent = "Read-only file content"
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("readonly.txt")
|
|
try testContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
// Mount a single file as read-only
|
|
config.mounts.append(.share(source: hostFile.path, destination: "/etc/readonly.txt", options: ["ro"]))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// First verify we can read the file
|
|
let readBuffer = BufferWriter()
|
|
let readExec = try await container.exec("read-file") { config in
|
|
config.arguments = ["cat", "/etc/readonly.txt"]
|
|
config.stdout = readBuffer
|
|
}
|
|
try await readExec.start()
|
|
var status = try await readExec.wait()
|
|
try await readExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read status \(status) != 0")
|
|
}
|
|
|
|
guard String(data: readBuffer.data, encoding: .utf8) == testContent else {
|
|
throw IntegrationError.assert(msg: "file content mismatch")
|
|
}
|
|
|
|
// Now try to write to the file - should fail
|
|
let writeExec = try await container.exec("write-file") { config in
|
|
config.arguments = ["sh", "-c", "echo 'modified' > /etc/readonly.txt"]
|
|
}
|
|
try await writeExec.start()
|
|
status = try await writeExec.wait()
|
|
try await writeExec.delete()
|
|
|
|
// Write should fail on a read-only mount
|
|
guard status.exitCode != 0 else {
|
|
throw IntegrationError.assert(msg: "write should have failed on read-only mount")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testSingleFileMountWriteBack() async throws {
|
|
let id = "test-single-file-mount-write-back"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp file with initial content
|
|
let initialContent = "initial content"
|
|
let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
.appendingPathComponent("writeable.txt")
|
|
try initialContent.write(to: hostFile, atomically: true, encoding: .utf8)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
// Mount a single file (writable by default)
|
|
config.mounts.append(.share(source: hostFile.path, destination: "/etc/writeable.txt"))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Write new content from inside the container
|
|
let newContent = "modified from container"
|
|
let writeExec = try await container.exec("write-file") { config in
|
|
config.arguments = ["sh", "-c", "echo -n '\(newContent)' > /etc/writeable.txt"]
|
|
}
|
|
try await writeExec.start()
|
|
let status = try await writeExec.wait()
|
|
try await writeExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "write status \(status) != 0")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
|
|
let hostContent = try String(contentsOf: hostFile, encoding: .utf8)
|
|
guard hostContent == newContent else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected '\(newContent)' on host, got '\(hostContent)'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testSingleFileMountSymlink() async throws {
|
|
let id = "test-single-file-mount-symlink"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp directory with a real file and a symlink to it
|
|
let tempDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let realFile = tempDir.appendingPathComponent("realfile.txt")
|
|
let symlinkFile = tempDir.appendingPathComponent("symlink.txt")
|
|
|
|
let initialContent = "content via symlink"
|
|
try initialContent.write(to: realFile, atomically: true, encoding: .utf8)
|
|
try FileManager.default.createSymbolicLink(at: symlinkFile, withDestinationURL: realFile)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
// Mount the symlink (should resolve to real file)
|
|
config.mounts.append(.share(source: symlinkFile.path, destination: "/etc/config.txt"))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Read the file to verify content
|
|
let readBuffer = BufferWriter()
|
|
let readExec = try await container.exec("read-file") { config in
|
|
config.arguments = ["cat", "/etc/config.txt"]
|
|
config.stdout = readBuffer
|
|
}
|
|
try await readExec.start()
|
|
var status = try await readExec.wait()
|
|
try await readExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read status \(status) != 0")
|
|
}
|
|
|
|
guard String(data: readBuffer.data, encoding: .utf8) == initialContent else {
|
|
throw IntegrationError.assert(msg: "content mismatch on read")
|
|
}
|
|
|
|
// Write new content from container
|
|
let newContent = "modified via symlink mount"
|
|
let writeExec = try await container.exec("write-file") { config in
|
|
config.arguments = ["sh", "-c", "echo -n '\(newContent)' > /etc/config.txt"]
|
|
}
|
|
try await writeExec.start()
|
|
status = try await writeExec.wait()
|
|
try await writeExec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "write status \(status) != 0")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
|
|
// Verify the REAL file (not symlink) was modified on the host
|
|
let hostContent = try String(contentsOf: realFile, encoding: .utf8)
|
|
guard hostContent == newContent else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected '\(newContent)' in real file, got '\(hostContent)'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testRLimitOpenFiles() async throws {
|
|
let id = "test-rlimit-open-files"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sh", "-c", "ulimit -n"]
|
|
config.process.rlimits = [
|
|
LinuxRLimit(kind: .openFiles, hard: 2048, soft: 1024)
|
|
]
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
// ulimit -n returns the soft limit
|
|
guard output == "1024" else {
|
|
throw IntegrationError.assert(msg: "expected soft limit '1024', got '\(output)'")
|
|
}
|
|
}
|
|
|
|
func testRLimitMultiple() async throws {
|
|
let id = "test-rlimit-multiple"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
// Read /proc/self/limits to verify multiple rlimits are set
|
|
config.process.arguments = ["cat", "/proc/self/limits"]
|
|
config.process.rlimits = [
|
|
LinuxRLimit(kind: .openFiles, hard: 4096, soft: 2048),
|
|
LinuxRLimit(kind: .stackSize, hard: 16_777_216, soft: 8_388_608),
|
|
LinuxRLimit(kind: .coreFileSize, hard: 0, soft: 0),
|
|
]
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
// Parse /proc/self/limits and verify the values
|
|
// Format: "Limit Name Soft Limit Hard Limit Units"
|
|
let lines = output.split(separator: "\n")
|
|
|
|
// Helper to find and verify a limit line
|
|
func verifyLimit(name: String, expectedSoft: String, expectedHard: String) throws {
|
|
guard let line = lines.first(where: { $0.contains(name) }) else {
|
|
throw IntegrationError.assert(msg: "limit '\(name)' not found in output")
|
|
}
|
|
let parts = line.split(whereSeparator: { $0.isWhitespace }).map(String.init)
|
|
// The line format varies, but soft and hard are typically the last numeric values before units
|
|
guard parts.contains(expectedSoft) && parts.contains(expectedHard) else {
|
|
throw IntegrationError.assert(
|
|
msg: "limit '\(name)' expected soft=\(expectedSoft) hard=\(expectedHard), got: \(line)")
|
|
}
|
|
}
|
|
|
|
try verifyLimit(name: "Max open files", expectedSoft: "2048", expectedHard: "4096")
|
|
try verifyLimit(name: "Max stack size", expectedSoft: "8388608", expectedHard: "16777216")
|
|
try verifyLimit(name: "Max core file size", expectedSoft: "0", expectedHard: "0")
|
|
}
|
|
|
|
func testRLimitExec() async throws {
|
|
let id = "test-rlimit-exec"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Exec a process with rlimits set
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("rlimit-exec") { config in
|
|
config.arguments = ["sh", "-c", "ulimit -n"]
|
|
config.rlimits = [
|
|
LinuxRLimit(kind: .openFiles, hard: 512, soft: 256)
|
|
]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output == "256" else {
|
|
throw IntegrationError.assert(msg: "expected soft limit '256', got '\(output)'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testDuplicateVirtiofsMount() async throws {
|
|
let id = "test-duplicate-virtiofs-mount"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp directory with a file
|
|
let sharedDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
try "shared content".write(to: sharedDir.appendingPathComponent("data.txt"), atomically: true, encoding: .utf8)
|
|
|
|
let buffer1 = BufferWriter()
|
|
let buffer2 = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
// Mount the same source directory to two different destinations
|
|
config.mounts.append(.share(source: sharedDir.path, destination: "/mnt1"))
|
|
config.mounts.append(.share(source: sharedDir.path, destination: "/mnt2"))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Verify both mounts work. Read from /mnt1, then /mnt2
|
|
let exec1 = try await container.exec("read-mnt1") { config in
|
|
config.arguments = ["cat", "/mnt1/data.txt"]
|
|
config.stdout = buffer1
|
|
}
|
|
try await exec1.start()
|
|
var status = try await exec1.wait()
|
|
try await exec1.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read from /mnt1 failed with status \(status)")
|
|
}
|
|
|
|
guard String(data: buffer1.data, encoding: .utf8) == "shared content" else {
|
|
throw IntegrationError.assert(msg: "unexpected content from /mnt1")
|
|
}
|
|
|
|
let exec2 = try await container.exec("read-mnt2") { config in
|
|
config.arguments = ["cat", "/mnt2/data.txt"]
|
|
config.stdout = buffer2
|
|
}
|
|
try await exec2.start()
|
|
status = try await exec2.wait()
|
|
try await exec2.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read from /mnt2 failed with status \(status)")
|
|
}
|
|
|
|
guard String(data: buffer2.data, encoding: .utf8) == "shared content" else {
|
|
throw IntegrationError.assert(msg: "unexpected content from /mnt2")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testDuplicateVirtiofsMountViaSymlink() async throws {
|
|
let id = "test-duplicate-virtiofs-mount-symlink"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a temp directory with a file, and a symlink to the same directory
|
|
let tempDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
let realDir = tempDir.appendingPathComponent("realdir")
|
|
let symlinkDir = tempDir.appendingPathComponent("symlinkdir")
|
|
|
|
try FileManager.default.createDirectory(at: realDir, withIntermediateDirectories: true)
|
|
try "symlink test content".write(to: realDir.appendingPathComponent("file.txt"), atomically: true, encoding: .utf8)
|
|
try FileManager.default.createSymbolicLink(at: symlinkDir, withDestinationURL: realDir)
|
|
|
|
let buffer1 = BufferWriter()
|
|
let buffer2 = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.mounts.append(.share(source: realDir.path, destination: "/mnt1"))
|
|
config.mounts.append(.share(source: symlinkDir.path, destination: "/mnt2"))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
// This should succeed as the symlink should resolve to the same directory
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let exec1 = try await container.exec("read-mnt1") { config in
|
|
config.arguments = ["cat", "/mnt1/file.txt"]
|
|
config.stdout = buffer1
|
|
}
|
|
try await exec1.start()
|
|
var status = try await exec1.wait()
|
|
try await exec1.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read from /mnt1 failed with status \(status)")
|
|
}
|
|
|
|
guard String(data: buffer1.data, encoding: .utf8) == "symlink test content" else {
|
|
throw IntegrationError.assert(msg: "unexpected content from /mnt1")
|
|
}
|
|
|
|
// Verify mount via symlink works now
|
|
let exec2 = try await container.exec("read-mnt2") { config in
|
|
config.arguments = ["cat", "/mnt2/file.txt"]
|
|
config.stdout = buffer2
|
|
}
|
|
try await exec2.start()
|
|
status = try await exec2.wait()
|
|
try await exec2.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "read from /mnt2 failed with status \(status)")
|
|
}
|
|
|
|
guard String(data: buffer2.data, encoding: .utf8) == "symlink test content" else {
|
|
throw IntegrationError.assert(msg: "unexpected content from /mnt2")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testWritableLayer() async throws {
|
|
let id = "test-writable-layer"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib())
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
// Write a file, then read it back to verify writes work
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'writable layer test' > /tmp/testfile && cat /tmp/testfile"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "writable layer test" else {
|
|
throw IntegrationError.assert(msg: "unexpected output: \(output)")
|
|
}
|
|
}
|
|
|
|
// Validates the on-disk structure of a journaled EXT4 image on the host before
|
|
// attempting a container mount. This catches geometry mismatches (superblock block
|
|
// count vs. physical file size) and missing journal metadata without requiring
|
|
// e2fsck to be present in the container image.
|
|
//
|
|
// Uses raw values for internal constants because integration tests cannot use
|
|
// @testable import: CompatFeature.hasJournal = 0x4, EXT4.JournalInode = 8.
|
|
private func verifyJournalFilesystem(
|
|
at path: URL,
|
|
minDiskSize: UInt64,
|
|
expectedMountOpts: UInt32
|
|
) throws {
|
|
let attrs = try FileManager.default.attributesOfItem(atPath: path.absolutePath())
|
|
guard let fileSize = attrs[.size] as? UInt64 else {
|
|
throw IntegrationError.assert(msg: "could not read file size for \(path.lastPathComponent)")
|
|
}
|
|
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(path.absolutePath()))
|
|
let sb = reader.superBlock
|
|
let blocksCount = UInt64(sb.blocksCountLow) | (UInt64(sb.blocksCountHigh) << 32)
|
|
let blockSize = UInt64(sb.blockSize)
|
|
guard fileSize == blocksCount * blockSize else {
|
|
throw IntegrationError.assert(
|
|
msg: "geometry mismatch: fileSize=\(fileSize), blocksCount=\(blocksCount), blockSize=\(blockSize)")
|
|
}
|
|
guard fileSize > minDiskSize else {
|
|
throw IntegrationError.assert(
|
|
msg: "journal did not grow the image: fileSize=\(fileSize), minDiskSize=\(minDiskSize)")
|
|
}
|
|
guard sb.featureCompat & 0x4 != 0 else {
|
|
throw IntegrationError.assert(
|
|
msg: "COMPAT_HAS_JOURNAL not set in featureCompat (0x\(String(sb.featureCompat, radix: 16)))")
|
|
}
|
|
guard sb.journalInum == 8 else {
|
|
throw IntegrationError.assert(msg: "journalInum=\(sb.journalInum), expected 8")
|
|
}
|
|
guard sb.defaultMountOpts == expectedMountOpts else {
|
|
throw IntegrationError.assert(
|
|
msg: "defaultMountOpts=0x\(String(sb.defaultMountOpts, radix: 16)), expected 0x\(String(expectedMountOpts, radix: 16)))")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerJournalWriteback() async throws {
|
|
let id = "test-writable-layer-journal-writeback"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(
|
|
FilePath(writableLayerPath.absolutePath()),
|
|
minDiskSize: 512.mib(),
|
|
journal: .init(defaultMode: .writeback)
|
|
)
|
|
try filesystem.close()
|
|
// 0x0060 = data=writeback | barrier
|
|
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0060)
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'journal writeback' > /tmp/testfile && cat /tmp/testfile"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal writeback" else {
|
|
throw IntegrationError.assert(msg: "unexpected output: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerJournalOrdered() async throws {
|
|
let id = "test-writable-layer-journal-ordered"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(
|
|
FilePath(writableLayerPath.absolutePath()),
|
|
minDiskSize: 512.mib(),
|
|
journal: .init(defaultMode: .ordered)
|
|
)
|
|
try filesystem.close()
|
|
// 0x0040 = data=ordered | barrier
|
|
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0040)
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'journal ordered' > /tmp/testfile && cat /tmp/testfile"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal ordered" else {
|
|
throw IntegrationError.assert(msg: "unexpected output: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerJournalData() async throws {
|
|
let id = "test-writable-layer-journal-data"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(
|
|
FilePath(writableLayerPath.absolutePath()),
|
|
minDiskSize: 512.mib(),
|
|
journal: .init(defaultMode: .journal)
|
|
)
|
|
try filesystem.close()
|
|
// 0x0020 = data=journal | barrier
|
|
try verifyJournalFilesystem(at: writableLayerPath, minDiskSize: 512.mib(), expectedMountOpts: 0x0020)
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'journal data' > /tmp/testfile && cat /tmp/testfile"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "journal data" else {
|
|
throw IntegrationError.assert(msg: "unexpected output: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerPreservesLowerLayer() async throws {
|
|
let id = "test-writable-layer-preserves-lower"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib())
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
// Get the size of /bin/sh before any modifications
|
|
let buffer1 = BufferWriter()
|
|
let container1 = try LinuxContainer("\(id)-1", rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
// Modify a file in /bin. This should go in the writable layer.
|
|
config.process.arguments = ["/bin/sh", "-c", "ls -la /bin/sh && echo 'modified' > /bin/test-file"]
|
|
config.process.stdout = buffer1
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container1.create()
|
|
try await container1.start()
|
|
let status1 = try await container1.wait()
|
|
try await container1.stop()
|
|
|
|
guard status1.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "first container failed with status \(status1)")
|
|
}
|
|
|
|
// Now run a second container with the SAME rootfs but without the writable layer
|
|
// The /bin/test-file should NOT exist because it was written to the writable layer
|
|
let buffer2 = BufferWriter()
|
|
let container2 = try LinuxContainer("\(id)-2", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "test -f /bin/test-file && echo 'exists' || echo 'not-exists'"]
|
|
config.process.stdout = buffer2
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container2.create()
|
|
try await container2.start()
|
|
let status2 = try await container2.wait()
|
|
try await container2.stop()
|
|
|
|
guard status2.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "second container failed with status \(status2)")
|
|
}
|
|
|
|
guard let output2 = String(data: buffer2.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output2.trimmingCharacters(in: .whitespacesAndNewlines) == "not-exists" else {
|
|
throw IntegrationError.assert(msg: "expected 'not-exists' but got: \(output2)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerReadsFromLower() async throws {
|
|
let id = "test-writable-layer-reads-lower"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib())
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["head", "-1", "/etc/passwd"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
// Alpine's first line of /etc/passwd should be root
|
|
guard output.hasPrefix("root:") else {
|
|
throw IntegrationError.assert(msg: "expected /etc/passwd to start with 'root:', got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerWithReadOnlyLower() async throws {
|
|
let id = "test-writable-layer-ro-lower"
|
|
|
|
let bs = try await bootstrap(id)
|
|
var rootfs = bs.rootfs
|
|
rootfs.options.append("ro")
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib())
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
// Even though lower layer is ro, writes should succeed via overlay
|
|
config.process.arguments = ["/bin/sh", "-c", "echo 'overlay write test' > /tmp/test && cat /tmp/test"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.trimmingCharacters(in: .whitespacesAndNewlines) == "overlay write test" else {
|
|
throw IntegrationError.assert(msg: "unexpected output: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerSize() async throws {
|
|
let id = "test-writable-layer-size"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Create a 1 GiB writable layer
|
|
let expectedSizeBytes: UInt64 = 1.gib()
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: expectedSizeBytes)
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
// Use df to check the available space on the root filesystem
|
|
// The overlay will report the size of the upper layer's backing store
|
|
config.process.arguments = ["/bin/sh", "-c", "df -B1 / | tail -1 | awk '{print $2}'"]
|
|
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: "process failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard let reportedSize = UInt64(output.trimmingCharacters(in: .whitespacesAndNewlines)) else {
|
|
throw IntegrationError.assert(msg: "failed to parse df output as UInt64: \(output)")
|
|
}
|
|
|
|
// The reported size should be close to our expected size (within 10%)
|
|
let minExpected: UInt64 = (expectedSizeBytes * 90) / 100
|
|
let maxExpected: UInt64 = (expectedSizeBytes * 110) / 100
|
|
|
|
guard reportedSize >= minExpected && reportedSize <= maxExpected else {
|
|
throw IntegrationError.assert(msg: "expected size ~\(expectedSizeBytes) bytes, but df reported \(reportedSize) bytes")
|
|
}
|
|
}
|
|
|
|
func testWritableLayerWithDNSAndHosts() async throws {
|
|
let id = "test-writable-layer-dns-hosts"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4")
|
|
try? FileManager.default.removeItem(at: writableLayerPath)
|
|
let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib())
|
|
try filesystem.close()
|
|
let writableLayer = Mount.block(
|
|
format: "ext4",
|
|
source: writableLayerPath.absolutePath(),
|
|
destination: "/",
|
|
options: []
|
|
)
|
|
|
|
let buffer = BufferWriter()
|
|
let dnsEntry = "8.8.8.8"
|
|
let hostsEntry = Hosts.Entry.localHostIPV4(comment: "WritableLayerTest")
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "cat /etc/resolv.conf && echo '---' && cat /etc/hosts"]
|
|
config.process.stdout = buffer
|
|
config.dns = DNS(nameservers: [dnsEntry])
|
|
config.hosts = Hosts(entries: [hostsEntry])
|
|
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: "process failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.contains(dnsEntry) else {
|
|
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain \(dnsEntry), got: \(output)")
|
|
}
|
|
|
|
guard output.contains("WritableLayerTest") else {
|
|
throw IntegrationError.assert(msg: "expected /etc/hosts to contain our entry, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testFrozenExt4Clone() async throws {
|
|
let id = "test-frozen-ext4-clone"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4")
|
|
try? FileManager.default.removeItem(at: diskImageURL)
|
|
|
|
let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib())
|
|
try filesystem.close()
|
|
|
|
let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4")
|
|
try? FileManager.default.removeItem(at: cloneImageURL)
|
|
|
|
let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: diskImageURL.absolutePath(),
|
|
destination: "/data"
|
|
))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await writerContainer.create()
|
|
try await writerContainer.start()
|
|
|
|
try await writerContainer.filesystemOperation(operation: .freeze, path: "/data")
|
|
|
|
let writeExec = try await writerContainer.exec("write-hello") { config in
|
|
config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"]
|
|
}
|
|
try await writeExec.start()
|
|
let writeStatus = try await writeExec.wait()
|
|
try await writeExec.delete()
|
|
guard writeStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)")
|
|
}
|
|
|
|
try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL)
|
|
|
|
try await writerContainer.filesystemOperation(operation: .thaw, path: "/data")
|
|
|
|
try await writerContainer.kill(.kill)
|
|
_ = try await writerContainer.wait()
|
|
try await writerContainer.stop()
|
|
} catch {
|
|
try? await writerContainer.filesystemOperation(operation: .thaw, path: "/data")
|
|
try? await writerContainer.stop()
|
|
throw error
|
|
}
|
|
|
|
let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: cloneImageURL.absolutePath(),
|
|
destination: "/data"
|
|
))
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await verifyContainer.create()
|
|
try await verifyContainer.start()
|
|
|
|
let mountBuffer = BufferWriter()
|
|
let mountExec = try await verifyContainer.exec("verify-mount") { config in
|
|
config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"]
|
|
config.stdout = mountBuffer
|
|
}
|
|
try await mountExec.start()
|
|
var status = try await mountExec.wait()
|
|
try await mountExec.delete()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)")
|
|
}
|
|
|
|
let mountOutput = String(decoding: mountBuffer.data, as: UTF8.self)
|
|
guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else {
|
|
throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)")
|
|
}
|
|
|
|
let lsBuffer = BufferWriter()
|
|
let lsExec = try await verifyContainer.exec("verify-no-hello") { config in
|
|
config.arguments = ["ls", "-1", "/data"]
|
|
config.stdout = lsBuffer
|
|
}
|
|
try await lsExec.start()
|
|
status = try await lsExec.wait()
|
|
try await lsExec.delete()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls /data failed with status \(status)")
|
|
}
|
|
|
|
let lsOutput = String(decoding: lsBuffer.data, as: UTF8.self)
|
|
let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init))
|
|
guard !listedFiles.contains("hello.txt") else {
|
|
throw IntegrationError.assert(msg: "expected cloned /data to not contain hello.txt, got: \(lsOutput)")
|
|
}
|
|
|
|
try await verifyContainer.kill(.kill)
|
|
_ = try await verifyContainer.wait()
|
|
try await verifyContainer.stop()
|
|
} catch {
|
|
try? await verifyContainer.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testTrimExt4Clone() async throws {
|
|
let id = "test-trim-ext4-clone"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4")
|
|
try? FileManager.default.removeItem(at: diskImageURL)
|
|
|
|
let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib())
|
|
try filesystem.close()
|
|
|
|
let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4")
|
|
try? FileManager.default.removeItem(at: cloneImageURL)
|
|
|
|
let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: diskImageURL.absolutePath(),
|
|
destination: "/data"
|
|
))
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await writerContainer.create()
|
|
try await writerContainer.start()
|
|
|
|
let writeExec = try await writerContainer.exec("write-temp") { config in
|
|
config.arguments = [
|
|
"/bin/sh",
|
|
"-c",
|
|
"dd if=/dev/zero of=/data/trim.dat bs=1M count=8 status=none && sync && rm /data/trim.dat && sync",
|
|
]
|
|
}
|
|
try await writeExec.start()
|
|
let writeStatus = try await writeExec.wait()
|
|
try await writeExec.delete()
|
|
guard writeStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "trim setup exec failed with status \(writeStatus)")
|
|
}
|
|
|
|
try await writerContainer.filesystemOperation(operation: .trim, path: "/data")
|
|
|
|
try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL)
|
|
|
|
try await writerContainer.kill(.kill)
|
|
_ = try await writerContainer.wait()
|
|
try await writerContainer.stop()
|
|
} catch {
|
|
try? await writerContainer.stop()
|
|
throw error
|
|
}
|
|
|
|
let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.mounts.append(
|
|
Mount.block(
|
|
format: "ext4",
|
|
source: cloneImageURL.absolutePath(),
|
|
destination: "/data"
|
|
))
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await verifyContainer.create()
|
|
try await verifyContainer.start()
|
|
|
|
let mountBuffer = BufferWriter()
|
|
let mountExec = try await verifyContainer.exec("verify-mount") { config in
|
|
config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"]
|
|
config.stdout = mountBuffer
|
|
}
|
|
try await mountExec.start()
|
|
var status = try await mountExec.wait()
|
|
try await mountExec.delete()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)")
|
|
}
|
|
|
|
let mountOutput = String(decoding: mountBuffer.data, as: UTF8.self)
|
|
guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else {
|
|
throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)")
|
|
}
|
|
|
|
let lsBuffer = BufferWriter()
|
|
let lsExec = try await verifyContainer.exec("verify-no-hello") { config in
|
|
config.arguments = ["ls", "-1", "/data"]
|
|
config.stdout = lsBuffer
|
|
}
|
|
try await lsExec.start()
|
|
status = try await lsExec.wait()
|
|
try await lsExec.delete()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ls /data failed with status \(status)")
|
|
}
|
|
|
|
let lsOutput = String(decoding: lsBuffer.data, as: UTF8.self)
|
|
let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init))
|
|
guard !listedFiles.contains("trim.dat") else {
|
|
throw IntegrationError.assert(msg: "expected cloned /data to not contain trim.dat, got: \(lsOutput)")
|
|
}
|
|
|
|
try await verifyContainer.kill(.kill)
|
|
_ = try await verifyContainer.wait()
|
|
try await verifyContainer.stop()
|
|
} catch {
|
|
try? await verifyContainer.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testUseInitBasic() async throws {
|
|
let id = "test-use-init-basic"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/echo", "hello from init"]
|
|
config.process.stdout = buffer
|
|
config.useInit = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "hello from init\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'hello from init', got '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'")
|
|
}
|
|
}
|
|
|
|
func testUseInitExitCodePropagation() async throws {
|
|
let id = "test-use-init-exit-code"
|
|
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Test exit code 0
|
|
var container = try LinuxContainer("\(id)-success", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/true"]
|
|
config.useInit = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
var status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "expected exit code 0, got \(status.exitCode)")
|
|
}
|
|
|
|
// Test non-zero exit code
|
|
container = try LinuxContainer("\(id)-failure", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/false"]
|
|
config.useInit = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 1 else {
|
|
throw IntegrationError.assert(msg: "expected exit code 1, got \(status.exitCode)")
|
|
}
|
|
|
|
// Test custom exit code
|
|
container = try LinuxContainer("\(id)-custom", rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sh", "-c", "exit 42"]
|
|
config.useInit = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
try await container.create()
|
|
try await container.start()
|
|
status = try await container.wait()
|
|
try await container.stop()
|
|
|
|
guard status.exitCode == 42 else {
|
|
throw IntegrationError.assert(msg: "expected exit code 42, got \(status.exitCode)")
|
|
}
|
|
}
|
|
|
|
func testUseInitSignalForwarding() async throws {
|
|
let id = "test-use-init-signal"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "300"]
|
|
config.useInit = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
|
|
try await container.kill(.term)
|
|
|
|
let status = try await container.wait(timeoutInSeconds: 5)
|
|
try await container.stop()
|
|
|
|
// SIGTERM should result in exit code 128 + 15 = 143
|
|
guard status.exitCode == 143 else {
|
|
throw IntegrationError.assert(msg: "expected exit code 143 (SIGTERM), got \(status.exitCode)")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testUseInitZombieReaping() async throws {
|
|
let id = "test-use-init-zombie-reaping"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
// This script creates an orphaned process that init must reap.
|
|
// The subshell exits immediately, orphaning the sleep process.
|
|
// Init should reap it when it exits.
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"""
|
|
# Create orphans: subshell exits before its children
|
|
(/bin/sleep 0.1 &)
|
|
(/bin/sleep 0.1 &)
|
|
# Wait for orphans to complete
|
|
/bin/sleep 0.3
|
|
# Check for zombie processes (Z state)
|
|
zombies=$(ps -eo stat 2>/dev/null | grep -c '^Z' || echo 0)
|
|
echo "zombie_count:$zombies"
|
|
""",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.useInit = true
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// Should report 0 zombies
|
|
guard output.contains("zombie_count:0") else {
|
|
throw IntegrationError.assert(msg: "expected zero zombies, got: \(output)")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testUseInitWithTerminal() async throws {
|
|
let id = "test-use-init-terminal"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sh", "-c", "tty && echo 'has tty'"]
|
|
config.process.terminal = true
|
|
config.process.stdout = buffer
|
|
config.useInit = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output.contains("has tty") else {
|
|
throw IntegrationError.assert(msg: "expected 'has tty' in output, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testUseInitWithStdin() async throws {
|
|
let id = "test-use-init-stdin"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat"]
|
|
config.process.stdin = StdinBuffer(data: "input through init\n".data(using: .utf8)!)
|
|
config.process.stdout = buffer
|
|
config.useInit = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard String(data: buffer.data, encoding: .utf8) == "input through init\n" else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'input through init', got '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'")
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
@available(macOS 26.0, *)
|
|
func testNetworkingDisabled() async throws {
|
|
let id = "test-networking-disabled"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let network = try VmnetNetwork()
|
|
var manager = try ContainerManager(vmm: bs.vmm, network: network)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs,
|
|
networking: false
|
|
) { config in
|
|
config.process.arguments = ["ls", "-1", "/sys/class/net/"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "ls /sys/class/net/ failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// With networking disabled check we don't have an eth0.
|
|
let interfaces = output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(separator: "\n")
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
guard !interfaces.contains("eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected no 'eth0' interface")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testNetworkingEnabled() async throws {
|
|
let id = "test-networking-enabled"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let network = try VmnetNetwork()
|
|
var manager = try ContainerManager(vmm: bs.vmm, network: network)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
config.process.arguments = ["ls", "-1", "/sys/class/net/"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "ls /sys/class/net/ failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// With networking enabled (default), eth0 should be present alongside lo
|
|
let interfaces = Set(
|
|
output.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.split(separator: "\n")
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
)
|
|
guard interfaces.contains("lo") else {
|
|
throw IntegrationError.assert(msg: "expected 'lo' interface, got: \(interfaces)")
|
|
}
|
|
guard interfaces.contains("eth0") else {
|
|
throw IntegrationError.assert(msg: "expected 'eth0' interface, got: \(interfaces)")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testNetworkingEnabledIPv6() async throws {
|
|
let id = "test-networking-enabled-ipv6"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let network = try VmnetNetwork()
|
|
var manager = try ContainerManager(vmm: bs.vmm, network: network)
|
|
defer {
|
|
try? manager.delete(id)
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try await manager.create(
|
|
id,
|
|
image: bs.image,
|
|
rootfs: bs.rootfs
|
|
) { config in
|
|
config.process.arguments = ["ip", "-6", "addr", "show", "eth0", "scope", "global"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "ip -6 addr show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output.contains("inet6 fd") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected a global-scope IPv6 address on eth0, got: \(output)")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6AddressAdd() async throws {
|
|
let id = "test-ipv6-address"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Pin the v6 prefix so the allocator's first allocation yields fd00::2.
|
|
var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64"))
|
|
defer {
|
|
try? network.releaseInterface(id)
|
|
}
|
|
|
|
guard let interface = try network.createInterface(id) else {
|
|
throw IntegrationError.assert(msg: "failed to create network interface")
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Check that the IPv6 address was assigned to eth0.
|
|
let exec = try await container.exec("check-ipv6") { config in
|
|
config.arguments = ["ip", "-6", "addr", "show", "eth0"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output.contains("fd00::2") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected fd00::2 in output, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6DefaultRoute() async throws {
|
|
let id = "test-ipv6-default-route"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Pin the network's v6 prefix so the gateway is deterministically fd00::1
|
|
// and the allocator's first allocation yields fd00::2.
|
|
var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64"))
|
|
defer {
|
|
try? network.releaseInterface(id)
|
|
}
|
|
|
|
guard let interface = try network.createInterface(id) else {
|
|
throw IntegrationError.assert(msg: "failed to create network interface")
|
|
}
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Inspect IPv6 routes inside the container.
|
|
let exec = try await container.exec("check-v6-route") { config in
|
|
config.arguments = ["ip", "-6", "route", "show"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// The default v6 route must point at the gateway we configured, on eth0.
|
|
guard output.contains("default via fd00::1 dev eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6GatewayOutsideSubnet() async throws {
|
|
let id = "test-ipv6-gateway-outside-subnet"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Address in fd00::/120, gateway in fd01::/120 — subnets don't overlap, so the
|
|
// LinuxContainer wiring must add a /128 link route to the gateway before the
|
|
// default route. The two prefixes are independent so we drive this directly
|
|
// via NATInterface rather than the VmnetNetwork allocator (which always
|
|
// derives the gateway from the network's own prefix).
|
|
let interface = NATInterface(
|
|
ipv4Address: try CIDRv4("192.0.2.2/24"),
|
|
ipv4Gateway: try IPv4Address("192.0.2.1"),
|
|
ipv6Address: try CIDRv6("fd00::2/120"),
|
|
ipv6Gateway: try IPv6Address("fd01::1"))
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let exec = try await container.exec("check-v6-routes") { config in
|
|
config.arguments = ["ip", "-6", "route", "show"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// Both the link-scoped route to the gateway AND the default via that gateway
|
|
// must be present. Without the link route, the kernel would refuse the default.
|
|
// Match the link route on a line that starts with the gateway address (no "via")
|
|
// so it can't be satisfied by a substring of the default-via line.
|
|
let lines = output.split(separator: "\n").map(String.init)
|
|
let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") }
|
|
guard hasLinkRoute else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)")
|
|
}
|
|
guard output.contains("default via fd01::1 dev eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6OnlyDefaultRoute() async throws {
|
|
let id = "test-ipv6-only-default-route"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Construct a NATInterface with a nil IPv4 gateway and a v6 gateway, so
|
|
// LinuxContainer takes the no-v4-gateway branch in setupInterface. The v4
|
|
// address comes from TEST-NET-1; nothing in the test traffics over v4.
|
|
let interface = NATInterface(
|
|
ipv4Address: try CIDRv4("192.0.2.2/24"),
|
|
ipv4Gateway: nil,
|
|
ipv6Address: try CIDRv6("fd00::2/64"),
|
|
ipv6Gateway: try IPv6Address("fd00::1"))
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let exec = try await container.exec("check-v6-route") { config in
|
|
config.arguments = ["ip", "-6", "route", "show"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
guard output.contains("default via fd00::1 dev eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'default via fd00::1 dev eth0' in v6 routes when ipv4Gateway is nil, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6OnlyGatewayOutsideSubnet() async throws {
|
|
let id = "test-ipv6-only-gateway-outside-subnet"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// No v4 gateway AND v6 gateway is outside the v6 subnet. Exercises
|
|
// setupInterface's "no v4 gateway, but v6 link route required before
|
|
// v6 default route" branch — the exact bug the helper extraction fixed.
|
|
let interface = NATInterface(
|
|
ipv4Address: try CIDRv4("192.0.2.2/24"),
|
|
ipv4Gateway: nil,
|
|
ipv6Address: try CIDRv6("fd00::2/120"),
|
|
ipv6Gateway: try IPv6Address("fd01::1"))
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let exec = try await container.exec("check-v6-routes") { config in
|
|
config.arguments = ["ip", "-6", "route", "show"]
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert output to UTF8")
|
|
}
|
|
|
|
// Both the on-link route to the gateway AND the default via it must be present.
|
|
// Without the link route the kernel rejects the default — that was the bug.
|
|
let lines = output.split(separator: "\n").map(String.init)
|
|
let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") }
|
|
guard hasLinkRoute else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)")
|
|
}
|
|
guard output.contains("default via fd01::1 dev eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
func testIPv6DualStack() async throws {
|
|
let id = "test-ipv6-dual-stack"
|
|
let bs = try await bootstrap(id)
|
|
|
|
// Pin the network's v6 prefix so the gateway is deterministically fd00::1
|
|
// and the allocator's first allocation yields fd00::2.
|
|
var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64"))
|
|
defer {
|
|
try? network.releaseInterface(id)
|
|
}
|
|
|
|
guard let interface = try network.createInterface(id) else {
|
|
throw IntegrationError.assert(msg: "failed to create network interface")
|
|
}
|
|
|
|
// Capture the v4 address vmnet allocated so we can assert it ends up on eth0.
|
|
let expectedV4 = interface.ipv4Address.address.description
|
|
|
|
let addrBuffer = BufferWriter()
|
|
let routeBuffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.interfaces = [interface]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// `ip addr show` (no family flag) lists both v4 and v6.
|
|
let addrExec = try await container.exec("check-dual-stack-addr") { config in
|
|
config.arguments = ["ip", "addr", "show", "eth0"]
|
|
config.stdout = addrBuffer
|
|
}
|
|
try await addrExec.start()
|
|
let addrStatus = try await addrExec.wait()
|
|
try await addrExec.delete()
|
|
|
|
guard addrStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip addr show failed with status \(addrStatus)")
|
|
}
|
|
|
|
guard let addrOutput = String(data: addrBuffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert addr output to UTF8")
|
|
}
|
|
|
|
guard addrOutput.contains(expectedV4) else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected v4 address \(expectedV4) on eth0, got: \(addrOutput)")
|
|
}
|
|
guard addrOutput.contains("fd00::2") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected v6 address fd00::2 on eth0, got: \(addrOutput)")
|
|
}
|
|
|
|
// The dual-stack default routes must both be installed.
|
|
let routeExec = try await container.exec("check-dual-stack-route") { config in
|
|
config.arguments = ["ip", "-6", "route", "show"]
|
|
config.stdout = routeBuffer
|
|
}
|
|
try await routeExec.start()
|
|
let routeStatus = try await routeExec.wait()
|
|
try await routeExec.delete()
|
|
|
|
guard routeStatus.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "ip -6 route show failed with status \(routeStatus)")
|
|
}
|
|
|
|
guard let routeOutput = String(data: routeBuffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert route output to UTF8")
|
|
}
|
|
|
|
guard routeOutput.contains("default via fd00::1 dev eth0") else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(routeOutput)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
func testSysctl() async throws {
|
|
let id = "test-container-sysctl"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.sysctl = [
|
|
"net.core.somaxconn": "4096"
|
|
]
|
|
config.process.arguments = ["cat", "/proc/sys/net/core/somaxconn"]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard output == "4096" else {
|
|
throw IntegrationError.assert(
|
|
msg: "sysctl net.core.somaxconn should be '4096', got '\(output ?? "nil")'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testSysctlMultiple() async throws {
|
|
let id = "test-container-sysctl-multiple"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.sysctl = [
|
|
"net.core.somaxconn": "2048",
|
|
"net.ipv4.ip_forward": "1",
|
|
]
|
|
config.process.arguments = [
|
|
"/bin/sh", "-c",
|
|
"cat /proc/sys/net/core/somaxconn && cat /proc/sys/net/ipv4/ip_forward",
|
|
]
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let lines = output?.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
|
|
guard lines == ["2048", "1"] else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected sysctls ['2048', '1'], got '\(output ?? "nil")'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testNoNewPrivileges() async throws {
|
|
let id = "test-no-new-privileges"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat", "/proc/self/status"]
|
|
config.process.noNewPrivileges = true
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
// /proc/self/status contains "NoNewPrivs:\t1" when the bit is set
|
|
guard output.contains("NoNewPrivs:\t1") else {
|
|
throw IntegrationError.assert(msg: "expected NoNewPrivs to be 1, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testNoNewPrivilegesDisabled() async throws {
|
|
let id = "test-no-new-privileges-disabled"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["cat", "/proc/self/status"]
|
|
// noNewPrivileges defaults to false
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
// When noNewPrivileges is not set, NoNewPrivs should be 0
|
|
guard output.contains("NoNewPrivs:\t0") else {
|
|
throw IntegrationError.assert(msg: "expected NoNewPrivs to be 0, got: \(output)")
|
|
}
|
|
}
|
|
|
|
func testWorkingDirCreated() async throws {
|
|
let id = "test-working-dir-created"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let buffer = BufferWriter()
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/pwd"]
|
|
config.process.workingDirectory = "/does/not/exist"
|
|
config.process.stdout = buffer
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
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: "process with non-existent workingDir failed: \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to read stdout")
|
|
}
|
|
|
|
guard output == "/does/not/exist" else {
|
|
throw IntegrationError.assert(msg: "expected cwd '/does/not/exist', got '\(output)'")
|
|
}
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testWorkingDirExecCreated() async throws {
|
|
let id = "test-working-dir-exec-created"
|
|
let bs = try await bootstrap(id)
|
|
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/sleep", "1000"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("cwd-exec") { config in
|
|
config.arguments = ["/bin/pwd"]
|
|
config.workingDirectory = "/a/b/c/d"
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec with non-existent workingDir failed: \(status)")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) else {
|
|
throw IntegrationError.assert(msg: "failed to read stdout")
|
|
}
|
|
|
|
guard output == "/a/b/c/d" else {
|
|
throw IntegrationError.assert(msg: "expected cwd '/a/b/c/d', got '\(output)'")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testNoNewPrivilegesExec() async throws {
|
|
let id = "test-no-new-privileges-exec"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "100"]
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
// Exec a process with noNewPrivileges set
|
|
let buffer = BufferWriter()
|
|
let exec = try await container.exec("nnp-exec") { config in
|
|
config.arguments = ["cat", "/proc/self/status"]
|
|
config.noNewPrivileges = true
|
|
config.stdout = buffer
|
|
}
|
|
|
|
try await exec.start()
|
|
let status = try await exec.wait()
|
|
try await exec.delete()
|
|
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "exec status \(status) != 0")
|
|
}
|
|
|
|
guard let output = String(data: buffer.data, encoding: .utf8) else {
|
|
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
|
|
}
|
|
|
|
guard output.contains("NoNewPrivs:\t1") else {
|
|
throw IntegrationError.assert(msg: "expected NoNewPrivs to be 1 in exec, got: \(output)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
func testVMResourceOverhead() async throws {
|
|
let id = "test-vm-resource-overhead"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["sleep", "infinity"]
|
|
config.cpus = 2
|
|
config.memoryInBytes = 256.mib()
|
|
config.cpuOverhead = 2
|
|
config.memoryOverhead = 1024.mib()
|
|
config.bootLog = bs.bootLog
|
|
}
|
|
|
|
do {
|
|
try await container.create()
|
|
try await container.start()
|
|
|
|
let cpuBuffer = BufferWriter()
|
|
let cpuExec = try await container.exec("check-nproc") { config in
|
|
config.arguments = ["nproc"]
|
|
config.stdout = cpuBuffer
|
|
}
|
|
try await cpuExec.start()
|
|
var status = try await cpuExec.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "nproc status \(status) != 0")
|
|
}
|
|
try await cpuExec.delete()
|
|
|
|
guard let cpuStr = String(data: cpuBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
let cpuCount = Int(cpuStr)
|
|
else {
|
|
throw IntegrationError.assert(msg: "failed to parse nproc output")
|
|
}
|
|
let expectedCpus = 4
|
|
guard cpuCount == expectedCpus else {
|
|
throw IntegrationError.assert(msg: "nproc \(cpuCount) != expected \(expectedCpus)")
|
|
}
|
|
|
|
let memBuffer = BufferWriter()
|
|
let memExec = try await container.exec("check-meminfo") { config in
|
|
config.arguments = ["sh", "-c", "grep MemTotal /proc/meminfo | awk '{print $2}'"]
|
|
config.stdout = memBuffer
|
|
}
|
|
try await memExec.start()
|
|
status = try await memExec.wait()
|
|
guard status.exitCode == 0 else {
|
|
throw IntegrationError.assert(msg: "meminfo status \(status) != 0")
|
|
}
|
|
try await memExec.delete()
|
|
|
|
guard let memStr = String(data: memBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
let memTotalKiB = UInt64(memStr)
|
|
else {
|
|
throw IntegrationError.assert(msg: "failed to parse MemTotal")
|
|
}
|
|
let memTotalBytes = memTotalKiB * 1024
|
|
let expectedMin: UInt64 = 1024.mib()
|
|
guard memTotalBytes > expectedMin else {
|
|
throw IntegrationError.assert(
|
|
msg: "MemTotal \(memTotalBytes) should exceed \(expectedMin)")
|
|
}
|
|
|
|
try await container.kill(.kill)
|
|
try await container.wait()
|
|
try await container.stop()
|
|
} catch {
|
|
try? await container.stop()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Verify that mounts are sorted by destination path depth so that a
|
|
// higher-level mount (e.g. /mnt) doesn't shadow a deeper mount
|
|
// (e.g. /mnt/deep/nested). Both directories are separate virtiofs
|
|
// shares; the sort ensures /mnt is mounted first and /mnt/deep/nested
|
|
// on top of it.
|
|
func testMountsSortedByDepth() async throws {
|
|
let id = "test-mount-sort-depth"
|
|
|
|
let bs = try await bootstrap(id)
|
|
let buffer = BufferWriter()
|
|
|
|
// Create two separate mount directories with distinct files.
|
|
let deepDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
try "deep-content".write(to: deepDir.appendingPathComponent("deep.txt"), atomically: true, encoding: .utf8)
|
|
|
|
let shallowDir = FileManager.default.uniqueTemporaryDirectory(create: true)
|
|
try "shallow-content".write(to: shallowDir.appendingPathComponent("shallow.txt"), atomically: true, encoding: .utf8)
|
|
|
|
// Add deeper mount first, then shallower mount. Without sorting the
|
|
// shallower mount would shadow the deeper one.
|
|
let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in
|
|
config.process.arguments = ["/bin/cat", "/mnt/deep/nested/deep.txt"]
|
|
config.mounts.append(.share(source: deepDir.path, destination: "/mnt/deep/nested"))
|
|
config.mounts.append(.share(source: shallowDir.path, destination: "/mnt"))
|
|
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: "process status \(status) != 0")
|
|
}
|
|
|
|
let value = String(data: buffer.data, encoding: .utf8)
|
|
guard value == "deep-content" else {
|
|
throw IntegrationError.assert(
|
|
msg: "expected 'deep-content' but got '\(value ?? "<nil>")'")
|
|
}
|
|
}
|
|
}
|