Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 55 additions & 77 deletions Sources/Containerization/FileMount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,21 @@ 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 {
/// Original file path on host
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]
Expand Down Expand Up @@ -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<String> = []

for mount in mounts {
// Only virtiofs mounts can be files
Expand All @@ -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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add the comment back about this path being a dummy value?

options: mount.options.filter { $0 != "bind" },
runtimeOptions: runtimeOpts
)
transformed.append(directoryShare)
}
}

context.transformedMounts = transformed
Expand All @@ -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
Expand All @@ -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<String> = []

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
}
Expand All @@ -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)
}
}
}
6 changes: 0 additions & 6 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -802,9 +799,6 @@ extension LinuxContainer {
}
}

// Clean up file mount temporary directories.
fileMountContext.cleanUp()

do {
try await vm.stop()
state = .stopped
Expand Down
3 changes: 0 additions & 3 deletions Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
11 changes: 5 additions & 6 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
55 changes: 55 additions & 0 deletions docs/single-file-mounts.md
Original file line number Diff line number Diff line change
@@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy or hard link?

dedicated directory on the host and mount that directory instead. This gives you full
control over what is visible to the guest.
Loading