Skip to content

fix(up): forward single-file bind mounts to container run#94

Open
csaller wants to merge 3 commits into
Mcrich23:mainfrom
csaller:fix/single-file-bind-mounts
Open

fix(up): forward single-file bind mounts to container run#94
csaller wants to merge 3 commits into
Mcrich23:mainfrom
csaller:fix/single-file-bind-mounts

Conversation

@csaller
Copy link
Copy Markdown

@csaller csaller commented May 8, 2026

Closes #93.

The bug

configVolume in ComposeUp.swift treated file vs. directory host sources differently: if the host side of a bind mount resolved to a file, the mount was skipped and only a "The 'container' tool does not support direct file mounts" warning was printed. In practice that warning is often masked by the image-fetch/progress output, so users see a container that silently starts with volumes: entries missing.

But Apple container (0.12.3+) does support file-to-file bind mounts. This works today, straight from the CLI:

container run --rm \
  --volume /host/config.yaml:/app/config.yaml:ro \
  myimage

So the gate in container-compose is no longer (and, based on field use, seems to never have been) correct. Common compose idioms like ./config.yaml:/app/config.yaml:ro or ./init.sh:/docker-entrypoint-initdb.d/init.sh have to be worked around by moving the file into a directory that's already mounted.

The fix

  • When the host path exists, forward the bind mount regardless of whether it's a file or directory — both are supported by container --volume.
  • Preserve the optional mode component (:ro, etc.) when rebuilding the --volume argument. Previously the mode was stripped even for directory mounts, so :ro had no effect there either.

Diff is small and localized to the bind-mount branch of configVolume. The named-volume branch is untouched.

Verification

Minimal reproducer with one directory mount and one file mount:

# docker-compose.yml
services:
  repro:
    image: docker.io/library/alpine:3.20
    command: ["sh","-c","ls -la /app; cat /app/hello.txt; cat /app/data/marker.txt"]
    volumes:
      - ./data:/app/data
      - ./hello.txt:/app/hello.txt:ro

Before (0.11.0): container inspect shows only the directory mount; file mount is absent from configuration.mounts; cat /app/hello.txtNo such file or directory.

After this patch:

[
  { "source": ".../data/",       "destination": "/app/data",       "options": [],     "type": {"virtiofs": {}} },
  { "source": ".../hello.txt",   "destination": "/app/hello.txt",  "options": ["ro"], "type": {"virtiofs": {}} }
]

Container logs:

-rw-r--r-- 1 root root 36 May  8 18:38 hello.txt
hello from a single-file bind mount
hello from a directory bind mount

The :ro on the file mount is now propagated (options: ["ro"]), matching compose semantics.

Test status

Ran swift test. The only failures are in the pre-existing dynamic suite ("What goes up must come down - two containers", "Test WordPress with MySQL compose file", "Test compose with complex dependency chain") — all of those use compose YAMLs with only named volumes (wordpress_data, db_data) and hit the known named-volume bootstrap issue tracked in #52. They reproduce on main without this patch; no code path touched by this change is exercised by them.

I didn't add a unit test because configVolume is a private instance method that pulls from several ComposeUp fields (cwd, fileManager, environmentVariables, projectName). Happy to extract a pure helper and add a static test in a follow-up if you'd prefer that shape.

Test plan

  • docker-compose.yml with a single-file bind mount (e.g. ./config.yaml:/app/config.yaml:ro) brings up a container that can read the file.
  • docker-compose.yml with a directory bind mount continues to work.
  • container inspect <name> shows both mounts in configuration.mounts, with options: ["ro"] preserved for :ro entries.
  • Missing host directories are still auto-created (unchanged behavior).

Apple `container` (0.12.3+) accepts `--volume host/file:container/file[:mode]`
for single-file bind mounts, but `configVolume` was branching on
`isDirectory` and silently dropping the mount (with only a warning) when
the host side resolved to a file. Common compose idioms like
`./config.yaml:/app/config.yaml:ro` or
`./init.sh:/docker-entrypoint-initdb.d/init.sh` therefore never reached
the container.

Forward the bind mount for both file and directory sources, and preserve
the optional mode component (e.g. `:ro`) when rebuilding the
`--volume` argument — previously the mode was stripped even for directory
mounts.

Closes Mcrich23#93
@Mcrich23
Copy link
Copy Markdown
Owner

Mcrich23 commented May 8, 2026

Hi @csaller. Thank you for your PR. I plan to look at it early next week when I have some time (if I don't send something, please ping me!)

For the test, go ahead and add it. Using an @testable import should give you access to any private variables and methods for your evaluation.

Extract configVolume's bind-mount logic into a public free function
composeVolumeToRunArgs so it can be tested without spinning up a real
container runtime. Add 7 static tests covering: single-file mount with
:ro mode (the bug case), single-file mount without mode, directory mount,
directory mount with mode, relative path resolution, missing host path
auto-creation, and invalid format.
@csaller
Copy link
Copy Markdown
Author

csaller commented May 8, 2026

Hi @Mcrich23, thanks! I just added the unit test and they are passing locally, also a test build worked exactly as expected with no regressions detected. Lmk if you need everything else from me. Other than that, have a great weekend!

The function only needs to be reachable from the test target via
@testable import ContainerComposeCore — there's no reason to expose it
as part of the public API surface.
@csaller
Copy link
Copy Markdown
Author

csaller commented May 22, 2026

Hi @Mcrich23! pinging you on this again

@Mcrich23
Copy link
Copy Markdown
Owner

Oh my gosh, I totally forgot about this. Thank you. Will review tonight!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comment on lines +183 to +193
let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source)

if fileManager.fileExists(atPath: fullHostPath) {
args.append("-v")
args.append(bindMountArg(source: source))
} else {
do {
try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(fullHostPath)")
args.append("-v")
args.append(bindMountArg(source: source))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Even though we are passing it via command and container should handle it just fine, passing an absolute path will help reduce bugs.

Comment on lines +209 to +212

args.append("-v")
args.append("\(volumePath):\(destinationPath)")
}
Comment on lines +106 to +110
private func makeTempDir() throws -> URL {
let tmp = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Maybe make a test Trait for this?

Copy link
Copy Markdown
Owner

@Mcrich23 Mcrich23 left a comment

Choose a reason for hiding this comment

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

I accidentally approved the PR earlier. If you could please address the issues copilot raised, I would be happy to merge this!

Comment on lines +106 to +110
private func makeTempDir() throws -> URL {
let tmp = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Maybe make a test Trait for this?

Comment on lines +183 to +193
let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source)

if fileManager.fileExists(atPath: fullHostPath) {
args.append("-v")
args.append(bindMountArg(source: source))
} else {
do {
try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(fullHostPath)")
args.append("-v")
args.append(bindMountArg(source: source))
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Even though we are passing it via command and container should handle it just fine, passing an absolute path will help reduce bugs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

volumes: entries that bind-mount a single file are silently dropped

3 participants