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 == []) + } + }