From 62a0614891c9739c583d76207245c3ece46f298a Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Thu, 22 Jan 2026 11:26:49 -0800 Subject: [PATCH] LinuxContainer/LinuxPod: Single file support This adds support for single file (virtiofs based) mounts to the two main container types. This is transparent, and doesn't need to be setup by a user, so there is no visible API change. Virtiofs does not support single file mounts today, and it would be less than ideal from a security standpoint to just expose the parent directory of the file to the vm and then bind mount in the file, so we chose the following: 1. Create a temp directory the container types will manage. 2. Hardlink in the file we want. 3. Share that tempdir into the vm. 4. Finally bind mount in the file into the container. The main goal I wanted for this is to leave the logic out of the `VirtualMachineInstance`. I really want this to just be a little dance the container types do, and leave the vm out of it. Because of that, most of the logic is shoved in a new `FileMountContext` type that does the dirty tricks of rewriting what the user asked for to be a bind mount from a temporary holding spot we'll use in the guest for these temp directories. One of the downsides to this is today the tempdirs are solely on the main volume, so cross volume will need to copy the file to the tempdir, so writes won't get written back. Co-authored-by: Dean Coulstock Co-authored-by: Jaewon Hur --- Sources/Containerization/FileMount.swift | 241 ++++++++++++++++++ Sources/Containerization/LinuxContainer.swift | 37 ++- Sources/Containerization/LinuxPod.swift | 42 ++- Sources/Integration/ContainerTests.swift | 229 +++++++++++++++++ Sources/Integration/PodTests.swift | 50 ++++ Sources/Integration/Suite.swift | 5 + 6 files changed, 598 insertions(+), 6 deletions(-) create mode 100644 Sources/Containerization/FileMount.swift diff --git a/Sources/Containerization/FileMount.swift b/Sources/Containerization/FileMount.swift new file mode 100644 index 00000000..c61aadeb --- /dev/null +++ b/Sources/Containerization/FileMount.swift @@ -0,0 +1,241 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +import ContainerizationError +import ContainerizationOCI +import Foundation + +/// Manages single-file mounts by transforming them into virtiofs directory shares +/// plus bind mounts. +/// +/// Since virtiofs only supports sharing directories, mounting a single file without +/// exposing the other potential files in that directory needs a little bit of a "hack". +/// The one we've landed on is: +/// +/// 1. Creating a temporary directory containing a hardlink to the file +/// 2. Sharing that directory via virtiofs to a holding location in the guest +/// 3. Bind mounting the specific file from the holding location to the final destination +/// +/// This type handles all three steps transparently. +struct FileMountContext: Sendable { + /// Metadata for a single prepared file mount. + struct PreparedMount: Sendable { + /// Original file path on host + let hostFilePath: String + /// Where the user wants the file in the container + let containerDestination: String + /// Just the filename + let filename: String + /// Temp directory containing the hardlinked file + let tempDirectory: URL + /// The virtiofs tag (hash of temp dir path). Used to find the AttachedFilesystem + let tag: String + /// Mount options from the original mount + let options: [String] + /// Where we mounted the share in the guest (set after mountHoldingDirectories) + var guestHoldingPath: String? + } + + /// Prepared file mounts for this context + var preparedMounts: [PreparedMount] + + /// The transformed mounts to pass to the VM (files replaced with directory shares) + private(set) var transformedMounts: [Mount] + + private init() { + self.preparedMounts = [] + self.transformedMounts = [] + } + + /// Returns true if there are any file mounts that need handling. + var hasFileMounts: Bool { + !preparedMounts.isEmpty + } + + /// Returns the set of virtiofs tags for file mount holding directories. + /// These should be filtered out from OCI spec mounts since we mount them + /// separately under /run. + var holdingDirectoryTags: Set { + Set(preparedMounts.map { $0.tag }) + } +} + +extension FileMountContext { + /// Prepare mounts for a container, detecting file mounts and transforming them. + /// + /// This method stats each virtiofs mount source. If it's a regular file rather than + /// a directory, it creates a temporary directory with a hardlink to the file and + /// substitutes a directory share for the original mount. + /// + /// - Parameter mounts: The original mounts from the container config + /// - Returns: A FileMountContext containing transformed mounts and tracking info + static func prepare(mounts: [Mount]) throws -> FileMountContext { + var context = FileMountContext() + var transformed: [Mount] = [] + + for mount in mounts { + // Only virtiofs mounts can be files + guard case .virtiofs(let runtimeOpts) = mount.runtimeOptions else { + transformed.append(mount) + continue + } + + // Stat the source to see if it's a file + let fm = FileManager.default + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: mount.source, isDirectory: &isDirectory) else { + // Doesn't exist. Let the normal flow handle the error + transformed.append(mount) + continue + } + + if isDirectory.boolValue { + // It's a directory, pass through unchanged + transformed.append(mount) + continue + } + + // It's a file, so prepare it. + let prepared = try context.prepareFileMount(mount: mount, runtimeOptions: runtimeOpts) + + // Create a regular directory share for the temp directory. + // The destination here is unused. We'll mount it ourselves to a location under /run. + let directoryShare = Mount.share( + source: prepared.tempDirectory.path, + destination: "/.file-mount-holding", + options: mount.options.filter { $0 != "bind" }, + runtimeOptions: runtimeOpts + ) + transformed.append(directoryShare) + } + + context.transformedMounts = transformed + return context + } + + private mutating func prepareFileMount( + mount: Mount, + runtimeOptions: [String] + ) throws -> PreparedMount { + let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath() + let sourceURL = URL(fileURLWithPath: mount.source) + let filename = sourceURL.lastPathComponent + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("containerization-file-mounts") + .appendingPathComponent(UUID().uuidString) + + try FileManager.default.createDirectory( + at: tempDir, + withIntermediateDirectories: true + ) + + // Hardlink the file (falls back to copy if cross-filesystem) + let destURL = tempDir.appendingPathComponent(filename) + do { + try FileManager.default.linkItem(at: resolvedSource, to: destURL) + } catch { + // Hardlink failed. Fall back to copy + try FileManager.default.copyItem(at: resolvedSource, to: destURL) + } + + let tag = try hashMountSource(source: tempDir.path) + + let prepared = PreparedMount( + hostFilePath: mount.source, + containerDestination: mount.destination, + filename: filename, + tempDirectory: tempDir, + tag: tag, + options: mount.options, + guestHoldingPath: nil + ) + + preparedMounts.append(prepared) + return prepared + } +} + +extension FileMountContext { + /// Mount the holding directories in the guest for all file mounts. + /// - Parameters: + /// - vmMounts: The AttachedFilesystem array from the VM for this container + /// - agent: The VM agent for RPCs + mutating func mountHoldingDirectories( + vmMounts: [AttachedFilesystem], + agent: any VirtualMachineAgent + ) async throws { + for i in preparedMounts.indices { + let prepared = preparedMounts[i] + + // Find the attached filesystem by matching the virtiofs tag + guard + let attached = vmMounts.first(where: { + $0.type == "virtiofs" && $0.source == prepared.tag + }) + else { + throw ContainerizationError( + .notFound, + message: "could not find attached filesystem for file mount \(prepared.hostFilePath)" + ) + } + + let guestPath = "/run/file-mounts/\(prepared.tag)" + try await agent.mkdir(path: guestPath, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: attached.source, + destination: guestPath, + options: [] + )) + + preparedMounts[i].guestHoldingPath = guestPath + } + } +} + +extension FileMountContext { + /// Get the bind mounts to append to the OCI spec. + func ociBindMounts() -> [ContainerizationOCI.Mount] { + preparedMounts.compactMap { prepared in + guard let guestPath = prepared.guestHoldingPath else { + return nil + } + + return ContainerizationOCI.Mount( + type: "none", + source: "\(guestPath)/\(prepared.filename)", + destination: prepared.containerDestination, + options: ["bind"] + prepared.options + ) + } + } +} + +extension FileMountContext { + /// Clean up temp directories. + func cleanup() { + let fm = FileManager.default + for prepared in preparedMounts { + try? fm.removeItem(at: prepared.tempDirectory) + } + } +} + +#endif diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 33b6c245..cc67ba4d 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -126,6 +126,7 @@ public final class LinuxContainer: Container, Sendable { struct CreatedState: Sendable { let vm: any VirtualMachineInstance let relayManager: UnixSocketRelayManager + var fileMountContext: FileMountContext } struct StartedState: Sendable { @@ -133,12 +134,14 @@ public final class LinuxContainer: Container, Sendable { let process: LinuxProcess let relayManager: UnixSocketRelayManager var vendedProcesses: [String: LinuxProcess] + let fileMountContext: FileMountContext init(_ state: CreatedState, process: LinuxProcess) { self.vm = state.vm self.relayManager = state.relayManager self.process = process self.vendedProcesses = [:] + self.fileMountContext = state.fileMountContext } init(_ state: PausedState) { @@ -146,6 +149,7 @@ public final class LinuxContainer: Container, Sendable { self.relayManager = state.relayManager self.process = state.process self.vendedProcesses = state.vendedProcesses + self.fileMountContext = state.fileMountContext } } @@ -154,12 +158,14 @@ public final class LinuxContainer: Container, Sendable { let relayManager: UnixSocketRelayManager let process: LinuxProcess var vendedProcesses: [String: LinuxProcess] + let fileMountContext: FileMountContext init(_ state: StartedState) { self.vm = state.vm self.relayManager = state.relayManager self.process = state.process self.vendedProcesses = state.vendedProcesses + self.fileMountContext = state.fileMountContext } } @@ -417,11 +423,16 @@ extension LinuxContainer { ProcessInfo.processInfo.physicalMemory ) + // Prepare file mounts. This transforms single-file mounts into directory shares. + let fileMountContext = try FileMountContext.prepare(mounts: self.config.mounts) + // This is dumb, but alas. + let fileMountContextHolder = Mutex(fileMountContext) + let vmConfig = VMConfiguration( cpus: self.cpus, memoryInBytes: vmMemory, interfaces: self.interfaces, - mountsByID: [self.id: [modifiedRootfs] + self.config.mounts], + mountsByID: [self.id: [modifiedRootfs] + fileMountContext.transformedMounts], bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -442,6 +453,17 @@ extension LinuxContainer { rootfs.destination = Self.guestRootfsPath(self.id) try await agent.mount(rootfs) + // Mount file mount holding directories under /run. + if fileMountContext.hasFileMounts { + let containerMounts = vm.mounts[self.id] ?? [] + var ctx = fileMountContextHolder.withLock { $0 } + try await ctx.mountHoldingDirectories( + vmMounts: containerMounts, + agent: agent + ) + fileMountContextHolder.withLock { $0 = ctx } + } + // Start up our friendly unix socket relays. for socket in self.config.sockets { try await self.relayUnixSocket( @@ -473,7 +495,7 @@ extension LinuxContainer { } } - state = .created(.init(vm: vm, relayManager: relayManager)) + state = .created(.init(vm: vm, relayManager: relayManager, fileMountContext: fileMountContextHolder.withLock { $0 })) } catch { try? await relayManager.stopAll() try? await vm.stop() @@ -492,8 +514,14 @@ extension LinuxContainer { do { var spec = self.generateRuntimeSpec() // We don't need the rootfs, nor do OCI runtimes want it included. + // Also filter out file mount holding directories. We'll mount those separately under /run. let containerMounts = createdState.vm.mounts[self.id] ?? [] - spec.mounts = containerMounts.dropFirst().map { $0.to } + let holdingTags = createdState.fileMountContext.holdingDirectoryTags + spec.mounts = + containerMounts.dropFirst() + .filter { !holdingTags.contains($0.source) } + .map { $0.to } + + createdState.fileMountContext.ociBindMounts() let stdio = IOUtil.setup( portAllocator: self.hostVsockPorts, @@ -641,6 +669,9 @@ extension LinuxContainer { firstError = firstError ?? error } + // Clean up file mount temporary directories. + startedState.fileMountContext.cleanup() + do { try await vm.stop() state = .stopped diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 0f33305f..bc6fe42a 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -83,6 +83,7 @@ public final class LinuxPod: Sendable { let config: ContainerConfiguration var state: ContainerState var process: LinuxProcess? + var fileMountContext: FileMountContext enum ContainerState: Sendable { case registered @@ -273,12 +274,16 @@ extension LinuxPod { var config = ContainerConfiguration() try configuration(&config) + // Prepare file mounts - transforms single-file mounts into directory shares. + let fileMountContext = try FileMountContext.prepare(mounts: config.mounts) + state.containers[id] = PodContainer( id: id, rootfs: rootfs, config: config, state: .registered, - process: nil + process: nil, + fileMountContext: fileMountContext ) } } @@ -293,11 +298,12 @@ extension LinuxPod { // Build mountsByID for all containers. // Strip "ro" from rootfs options - we handle readonly via the OCI spec's // root.readonly field and remount in vmexec after setup is complete. + // Use transformedMounts from fileMountContext (file mounts become directory shares). var mountsByID: [String: [Mount]] = [:] for (id, container) in state.containers { var modifiedRootfs = container.rootfs modifiedRootfs.options.removeAll(where: { $0 == "ro" }) - mountsByID[id] = [modifiedRootfs] + container.config.mounts + mountsByID[id] = [modifiedRootfs] + container.fileMountContext.transformedMounts } let vmConfig = VMConfiguration( @@ -317,6 +323,7 @@ extension LinuxPod { let containers = state.containers let shareProcessNamespace = self.config.shareProcessNamespace let pauseProcessHolder = Mutex(nil) + let fileMountContextUpdates = Mutex<[String: FileMountContext]>([:]) try await vm.withAgent { agent in try await agent.standardSetup() @@ -383,6 +390,19 @@ extension LinuxPod { try await agent.mount(rootfs) } + // Mount file mount holding directories under /run for each container. + for (id, container) in containers { + if container.fileMountContext.hasFileMounts { + var ctx = container.fileMountContext + let containerMounts = vm.mounts[id] ?? [] + try await ctx.mountHoldingDirectories( + vmMounts: containerMounts, + agent: agent + ) + fileMountContextUpdates.withLock { $0[id] = ctx } + } + } + // Start up unix socket relays for each container for (_, container) in containers { for socket in container.config.sockets { @@ -422,6 +442,12 @@ extension LinuxPod { state.pauseProcess = pauseProcessHolder.withLock { $0 } + // Apply file mount context updates. + let updates = fileMountContextUpdates.withLock { $0 } + for (id, ctx) in updates { + state.containers[id]?.fileMountContext = ctx + } + // Transition all containers to created state for id in state.containers.keys { state.containers[id]?.state = .created @@ -460,8 +486,14 @@ extension LinuxPod { do { var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs) // We don't need the rootfs, nor do OCI runtimes want it included. + // Also filter out file mount holding directories - we mount those separately under /run. let containerMounts = createdState.vm.mounts[containerID] ?? [] - spec.mounts = containerMounts.dropFirst().map { $0.to } + let holdingTags = container.fileMountContext.holdingDirectoryTags + spec.mounts = + containerMounts.dropFirst() + .filter { !holdingTags.contains($0.source) } + .map { $0.to } + + container.fileMountContext.ociBindMounts() // Configure namespaces for the container var namespaces: [LinuxNamespace] = [ @@ -615,6 +647,10 @@ extension LinuxPod { try? await process.delete() container.process = nil container.state = .stopped + + // Clean up file mount temporary directories. + container.fileMountContext.cleanup() + state.containers[containerID] = container } } diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 4cdb1f79..893fbe9b 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -2088,4 +2088,233 @@ extension IntegrationSuite { throw error } } + + 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(SIGKILL) + 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(SIGKILL) + 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(SIGKILL) + 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 + } + } } diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 17e977f0..ab67ed21 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -872,4 +872,54 @@ extension IntegrationSuite { throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)") } } + + func testPodSingleFileMount() async throws { + let id = "test-pod-single-file-mount" + + let bs = try await bootstrap(id) + + // Create a temp file with known content + let testContent = "Hello from pod single file mount!" + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("pod-config.txt") + try testContent.write(to: hostFile, atomically: true, encoding: .utf8) + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + } + + let buffer = BufferWriter() + try await pod.addContainer("container1", rootfs: bs.rootfs) { 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 + } + + do { + try await pod.create() + try await pod.startContainer("container1") + + let status = try await pod.waitContainer("container1") + try await pod.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 pod.stop() + throw error + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index ce8d6215..94f6c145 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -331,6 +331,10 @@ struct IntegrationSuite: AsyncParsableCommand { Test("stdin binary data", testStdinBinaryData), Test("stdin multiple chunks", testStdinMultipleChunks), Test("stdin very large", testStdinVeryLarge), + Test("container single file mount", testSingleFileMount), + Test("container single file mount read-only", testSingleFileMountReadOnly), + Test("container single file mount write-back", testSingleFileMountWriteBack), + Test("container single file mount symlink", testSingleFileMountSymlink), // Pods Test("pod single container", testPodSingleContainer), @@ -350,6 +354,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod shared PID namespace", testPodSharedPIDNamespace), Test("pod read-only rootfs", testPodReadOnlyRootfs), Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured), + Test("pod single file mount", testPodSingleFileMount), ] + macOS26Tests() let filteredTests: [Test]