From dbe3336b200d17e796059be032ef9a9dd4a3bdda Mon Sep 17 00:00:00 2001 From: Kyle Browning Date: Sat, 27 Jun 2026 12:46:40 -0700 Subject: [PATCH] Map named volumes to native container volumes (fixes #96) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The named-volume branch of `composeVolumeToRunArgs` bind-mounted a host directory and mounted it at the *parent* of the declared destination (`destination.deletingLastPathComponent()`). Two consequences: 1. A single-segment destination collapsed to `/`, so e.g. `redisdata:/data` mounted the volume over the container's root filesystem and shadowed the image's entrypoint — `redis` failed to start with "failed to find target executable docker-entrypoint.sh". 2. Even for nested destinations the host bind mount uses virtiofs, which the guest cannot `chown`. Images whose entrypoint chowns its data dir (postgres, redis, …) died with "chown: Operation not permitted". `container` 1.0.0 supports named volumes natively and auto-creates them on first `run`, so map a Compose named volume straight to a `container` named volume (`_`) mounted at the declared destination. Native volumes are block devices the guest owns, so chown works, and the destination is no longer rewritten. Verified end-to-end: a redis service with `redisdata:/data` now reaches "Ready to accept connections" where it previously crashed on startup. Adds tests covering the native mapping, the #96 root-mount regression, nested destinations with a mode, and the missing-project-name case. Co-Authored-By: Claude --- .../Container-Compose/Helper Functions.swift | 27 +++++++++----- .../HelperFunctionsTests.swift | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index ff8ce48f..7c22b883 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -198,19 +198,28 @@ func composeVolumeToRunArgs( } } else { guard let projectName else { return [] } - let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(source)") - let volumePath = volumeUrl.path(percentEncoded: false) - let destinationUrl = URL(fileURLWithPath: destination).deletingLastPathComponent() - let destinationPath = destinationUrl.path(percentEncoded: false) - print( - "Warning: Volume source '\(source)' appears to be a named volume reference. The 'container' tool does not support named volume references in 'container run -v' command. Linking to \(volumePath) instead." - ) - try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true) + // Named volume. As of `container` 1.0.0 these are supported natively + // (`container volume`) and are auto-created on first `run`, so we map a + // Compose named volume straight to a `container` named volume. + // + // We deliberately do NOT fall back to bind-mounting a host directory: + // 1. Bind mounts use virtiofs, whose mount the guest cannot `chown`. + // Images whose entrypoint chowns its data dir (postgres, redis, …) + // then die with "chown: Operation not permitted". Native volumes are + // block devices the guest owns, so chown works. + // 2. The previous host-dir mapping stripped the destination to its parent + // (`destination.deletingLastPathComponent()`), so `redisdata:/data` + // mounted over `/` and shadowed the image's root filesystem (hiding its + // entrypoint). See issue #96. + // + // The volume is namespaced as `_`, mirroring Docker + // Compose, so stacks don't collide on shared volume names. + let volumeName = "\(projectName)_\(source)" args.append("-v") let modeStr = mode.map { ":\($0)" } ?? "" - args.append("\(volumePath):\(destinationPath)\(modeStr)") + args.append("\(volumeName):\(destination)\(modeStr)") } return args diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index be2ec08f..a4569986 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -220,4 +220,39 @@ struct ComposeVolumeTests { #expect(result == []) } + // MARK: Named volumes (issue #96) + + @Test("Named volume maps to a native, project-namespaced container volume") + func testNamedVolumeMapsToNativeVolume() throws { + let result = try composeVolumeToRunArgs("redisdata:/data", cwd: "/tmp", projectName: "proj") + // `_` native volume mounted at the declared destination. + #expect(result == ["-v", "proj_redisdata:/data"]) + } + + @Test("Named volume mounts at its declared destination, not the parent") + func testNamedVolumeMountsAtDestinationNotParent() throws { + // Regression for #96: a single-segment destination must NOT collapse to + // `/` (which mounted the volume over the image root and hid its entrypoint). + let result = try composeVolumeToRunArgs("redisdata:/data", cwd: "/tmp", projectName: "proj") + let mount = result.last ?? "" + #expect(!mount.hasSuffix(":/")) + #expect(mount.hasSuffix(":/data")) + } + + @Test("Named volume preserves nested destination and mode") + func testNamedVolumeNestedDestinationWithMode() throws { + let result = try composeVolumeToRunArgs( + "pgdata:/var/lib/postgresql/data:ro", + cwd: "/tmp", + projectName: "proj" + ) + #expect(result == ["-v", "proj_pgdata:/var/lib/postgresql/data:ro"]) + } + + @Test("Named volume without a project name is skipped") + func testNamedVolumeWithoutProjectNameSkipped() throws { + let result = try composeVolumeToRunArgs("redisdata:/data", cwd: "/tmp", projectName: nil) + #expect(result == []) + } + }