Skip to content
Closed
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
27 changes: 18 additions & 9 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project>_<source>`, 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
Expand Down
35 changes: 35 additions & 0 deletions Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
// `<project>_<source>` 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 == [])
}

}