diff --git a/Sources/Containerization/FileMount.swift b/Sources/Containerization/FileMount.swift index d0d6cea4..68112f76 100644 --- a/Sources/Containerization/FileMount.swift +++ b/Sources/Containerization/FileMount.swift @@ -21,15 +21,9 @@ 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. +/// Since virtiofs only supports sharing directories, mounting a single file requires +/// sharing the file's parent directory via virtiofs and then bind mounting the specific +/// file from that share to the final destination in the container. struct FileMountContext: Sendable { /// Metadata for a single prepared file mount. struct PreparedMount: Sendable { @@ -37,11 +31,11 @@ struct FileMountContext: Sendable { let hostFilePath: String /// Where the user wants the file in the container let containerDestination: String - /// Just the filename + /// Just the filename (after resolving symlinks) 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 + /// The parent directory containing the file (after resolving symlinks) + let parentDirectory: URL + /// The virtiofs tag (hash of parent dir path). Used to find the AttachedFilesystem let tag: String /// Mount options from the original mount let options: [String] @@ -77,14 +71,16 @@ 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. + /// a directory, it shares the file's parent directory via virtiofs and records the + /// metadata needed to bind mount the specific file later. /// /// - 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] = [] + // Track parent directories we've already added a share for to avoid duplicates. + var sharedParentTags: Set = [] for mount in mounts { // Only virtiofs mounts can be files @@ -111,15 +107,17 @@ extension FileMountContext { // 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) + // Only add the directory share once per unique parent directory. + if !sharedParentTags.contains(prepared.tag) { + sharedParentTags.insert(prepared.tag) + let directoryShare = Mount.share( + source: prepared.parentDirectory.path, + destination: "/.file-mount-holding", + options: mount.options.filter { $0 != "bind" }, + runtimeOptions: runtimeOpts + ) + transformed.append(directoryShare) + } } context.transformedMounts = transformed @@ -131,34 +129,15 @@ extension FileMountContext { 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 filename = resolvedSource.lastPathComponent + let parentDirectory = resolvedSource.deletingLastPathComponent() + let tag = try hashMountSource(source: parentDirectory.path) let prepared = PreparedMount( hostFilePath: mount.source, containerDestination: mount.destination, filename: filename, - tempDirectory: tempDir, + parentDirectory: parentDirectory, tag: tag, options: mount.options, guestHoldingPath: nil @@ -178,30 +157,39 @@ extension FileMountContext { vmMounts: [AttachedFilesystem], agent: any VirtualMachineAgent ) async throws { + // Track which tags we've already mounted to avoid duplicate mounts + // when multiple files share the same parent directory. + var mountedTags: Set = [] + 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: [] - )) + + if !mountedTags.contains(prepared.tag) { + // 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)" + ) + } + + try await agent.mkdir(path: guestPath, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: attached.source, + destination: guestPath, + options: [] + )) + + mountedTags.insert(prepared.tag) + } preparedMounts[i].guestHoldingPath = guestPath } @@ -225,13 +213,3 @@ extension FileMountContext { } } } - -extension FileMountContext { - /// Clean up temp directories. - func cleanUp() { - let fm = FileManager.default - for prepared in preparedMounts { - try? fm.removeItem(at: prepared.tempDirectory) - } - } -} diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index e4596667..8fa95a45 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -711,18 +711,15 @@ extension LinuxContainer { let vm: any VirtualMachineInstance let relayManager: UnixSocketRelayManager - let fileMountContext: FileMountContext let startedState = try? state.startedState("stop") if let startedState { vm = startedState.vm relayManager = startedState.relayManager - fileMountContext = startedState.fileMountContext } else { let createdState = try state.createdState("stop") vm = createdState.vm relayManager = createdState.relayManager - fileMountContext = createdState.fileMountContext } var firstError: Error? @@ -802,9 +799,6 @@ extension LinuxContainer { } } - // Clean up file mount temporary directories. - fileMountContext.cleanUp() - do { try await vm.stop() state = .stopped diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 563c07ef..a37ce062 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -715,9 +715,6 @@ extension LinuxPod { container.process = nil container.state = .stopped - // Clean up file mount temporary directories. - container.fileMountContext.cleanUp() - state.containers[containerID] = container } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 0ff387c0..85341144 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -353,11 +353,10 @@ struct IntegrationSuite: AsyncParsableCommand { Test("stdin binary data", testStdinBinaryData), Test("stdin multiple chunks", testStdinMultipleChunks), Test("stdin very large", testStdinVeryLarge), - // FIXME: reenable when single file mount issues resolved - //Test("container single file mount", testSingleFileMount), - //Test("container single file mount read-only", testSingleFileMountReadOnly), - //Test("container single file mount write-back", testSingleFileMountWriteBack), - //Test("container single file mount symlink", testSingleFileMountSymlink), + Test("container single file mount", testSingleFileMount), + Test("container single file mount read-only", testSingleFileMountReadOnly), + Test("container single file mount write-back", testSingleFileMountWriteBack), + Test("container single file mount symlink", testSingleFileMountSymlink), Test("container rlimit open files", testRLimitOpenFiles), Test("container rlimit multiple", testRLimitMultiple), Test("container rlimit exec", testRLimitExec), @@ -398,7 +397,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), + Test("pod single file mount", testPodSingleFileMount), Test("pod container hosts config", testPodContainerHostsConfig), Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS), Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts), diff --git a/docs/single-file-mounts.md b/docs/single-file-mounts.md new file mode 100644 index 00000000..34544f71 --- /dev/null +++ b/docs/single-file-mounts.md @@ -0,0 +1,55 @@ +# Single File Mounts + +In Containerization, what is analogous to bind mounts goes over virtiofs. virtiofs can only +share directories, not individual files. To support mounting a single file from the host into +a container, Containerization shares the file's parent directory via virtiofs and then bind +mounts the specific file to its final destination inside the container. + +## How it works + +1. **Detection**: During mount preparation, each virtiofs mount source is stat'd. If it's a + regular file (not a directory), it enters the single-file mount path. Symlinks are + resolved to the real file first. + +2. **Parent directory share**: The file's parent directory is shared via virtiofs into the + guest VM. If multiple single-file mounts reference files in the same parent directory, + only one virtiofs share is created. + +3. **Guest holding mount**: After the VM starts, the parent directory share is mounted to a + holding location in the guest. + +4. **Bind mount**: When the container starts, a bind mount is created from + the holding location to the requested destination path inside the container. + +### Example + +Mounting `/Users/dev/config/app.toml` to `/etc/app.toml` in the container: + +``` +Host: /Users/dev/config/ (shared via virtiofs) +Guest VM: /temporary/holding/spot/ (virtiofs mount of parent dir) +Container: /etc/app.toml (bind mount of /temporary/holding/spot/app.toml) +``` + +## Trade-offs + +Sharing the parent directory means that sibling files in that directory are visible to the +guest VM at the holding mount point under `/run`. The bind mount into the container only +exposes the specific file requested, but the full parent directory contents are accessible +from inside the VM itself. This is a deliberate trade-off for reliability. Prior attempts +at supporting single file mounts using temporary directories with hardlinks were fragile +across filesystem boundaries and with certain host filesystem configurations. + +## Alternatives to single file mounts + +If exposing the parent directory to the guest VM is not acceptable for your use case, you +can avoid single-file mounts entirely: + +- **Mount the whole directory**: Instead of mounting a single file, mount the directory that + contains it. This is functionally equivalent (the directory is shared either way) but makes + the behavior explicit and gives the container access to the full directory at the + destination path. + +- **Stage files into a dedicated directory**: Copy or link the files you need into a + dedicated directory on the host and mount that directory instead. This gives you full + control over what is visible to the guest.