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
16 changes: 10 additions & 6 deletions Sources/MockerKit/Compose/ComposeOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ public actor ComposeOrchestrator {
// Parse port mappings
let ports = try service.ports.map { try PortMapping.parse($0) }

let volumes = try Self.resolveVolumeMounts(service.volumes)
let volumes = try Self.resolveVolumeMounts(service.volumes, projectDir: projectDir)

let config = ContainerConfig(
name: containerName,
Expand Down Expand Up @@ -346,20 +346,24 @@ public actor ComposeOrchestrator {
/// on init.
///
/// Relative paths (`./foo`, `../bar`, `data/dir`) are resolved to absolute paths
/// against the current working directory. This matches Docker Compose behaviour
/// because `mocker compose` is run from the project directory.
static func resolveVolumeMounts(_ volSpecs: [String]) throws -> [VolumeMount] {
/// against `projectDir` (the Compose `--project-directory`, i.e. the directory
/// containing the compose file unless overridden). This matches Docker Compose
/// behaviour, where bind-mount sources are anchored to the project directory
/// rather than the process's current working directory.
static func resolveVolumeMounts(_ volSpecs: [String], projectDir: URL) throws -> [VolumeMount] {
var volumes: [VolumeMount] = []
for volSpec in volSpecs {
var mount = try VolumeMount.parse(volSpec)
if mount.source.isEmpty {
volumes.append(mount)
} else if mount.source.hasPrefix("/") {
volumes.append(mount)
} else if mount.source.hasPrefix("~") {
mount.source = (mount.source as NSString).expandingTildeInPath
volumes.append(mount)
} else if mount.source.hasPrefix(".")
|| mount.source.hasPrefix("~")
|| mount.source.contains("/") {
mount.source = URL(fileURLWithPath: mount.source).path
mount.source = projectDir.appendingPathComponent(mount.source).standardized.path
volumes.append(mount)
}
}
Expand Down
41 changes: 34 additions & 7 deletions Tests/MockerKitTests/ComposeOrchestratorTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import Foundation
import Testing
@testable import MockerKit

@Suite("ComposeOrchestrator Tests")
struct ComposeOrchestratorTests {
/// Default project directory used by tests that don't care where sources anchor.
private static let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

@Test("Service order respects depends_on chain")
func testServiceOrderChain() throws {
Expand Down Expand Up @@ -158,7 +161,7 @@ struct ComposeOrchestratorTests {

@Test("Absolute bind mount included as-is")
func resolveAbsoluteBindMount() throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/host/data:/container/data"])
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/host/data:/container/data"], projectDir: Self.cwd)
#expect(mounts.count == 1)
#expect(mounts[0].source == "/host/data")
#expect(mounts[0].destination == "/container/data")
Expand All @@ -173,7 +176,7 @@ struct ComposeOrchestratorTests {
]
)
func resolveRelativeStyleMounts(spec: String, suffix: String, destination: String) throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts([spec])
let mounts = try ComposeOrchestrator.resolveVolumeMounts([spec], projectDir: Self.cwd)
#expect(mounts.count == 1)
#expect(mounts[0].source.hasPrefix("/"))
#expect(mounts[0].source.hasSuffix(suffix))
Expand All @@ -182,13 +185,13 @@ struct ComposeOrchestratorTests {

@Test("Named volume skipped")
func skipNamedVolume() throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["mydata:/container/data"])
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["mydata:/container/data"], projectDir: Self.cwd)
#expect(mounts.isEmpty)
}

@Test("Anonymous volume included")
func includeAnonymousVolume() throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/container/data"])
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["/container/data"], projectDir: Self.cwd)
#expect(mounts.count == 1)
#expect(mounts[0].source == "")
#expect(mounts[0].destination == "/container/data")
Expand All @@ -202,7 +205,7 @@ struct ComposeOrchestratorTests {
"namedvol:/app/named",
"/app/anon",
"sub/dir:/app/sub",
])
], projectDir: Self.cwd)
#expect(mounts.count == 4) // namedvol skipped
let sources = mounts.map(\.source)
#expect(sources.contains("/abs/path"))
Expand All @@ -213,7 +216,7 @@ struct ComposeOrchestratorTests {

@Test("Read-only relative bind mount preserves ro flag")
func resolveRelativeReadOnly() throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["./data:/container/data:ro"])
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["./data:/container/data:ro"], projectDir: Self.cwd)
#expect(mounts.count == 1)
#expect(mounts[0].readOnly == true)
#expect(mounts[0].destination == "/container/data")
Expand All @@ -222,7 +225,7 @@ struct ComposeOrchestratorTests {

@Test("Home-relative path resolved")
func resolveHomeRelativePath() throws {
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["~/data:/container/data"])
let mounts = try ComposeOrchestrator.resolveVolumeMounts(["~/data:/container/data"], projectDir: Self.cwd)
#expect(mounts.count == 1)
#expect(mounts[0].source.hasPrefix("/"))
#expect(mounts[0].source.contains("data"))
Expand Down Expand Up @@ -450,4 +453,28 @@ struct ComposeOrchestratorTests {
)
#expect(actions.isEmpty)
}

@Test("Relative bind-mount source anchors to project directory, not CWD (issue #60)")
func resolveRelativeBindMountAnchorsToProjectDirectory() throws {
// Use a project directory that is guaranteed to differ from the process CWD.
let projectDir = URL(fileURLWithPath: "/tmp/mocker-issue-60-project")
let mounts = try ComposeOrchestrator.resolveVolumeMounts(
["./data:/container/data"],
projectDir: projectDir
)
#expect(mounts.count == 1)
#expect(mounts[0].source == "/tmp/mocker-issue-60-project/data")
#expect(mounts[0].source != URL(fileURLWithPath: "./data").standardized.path)
}

@Test("Parent-relative bind-mount source anchors to project directory, not CWD (issue #60)")
func resolveParentRelativeBindMountAnchorsToProjectDirectory() throws {
let projectDir = URL(fileURLWithPath: "/tmp/mocker-issue-60-project/nested")
let mounts = try ComposeOrchestrator.resolveVolumeMounts(
["../shared:/container/shared"],
projectDir: projectDir
)
#expect(mounts.count == 1)
#expect(mounts[0].source == "/tmp/mocker-issue-60-project/shared")
}
}
Loading