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]