From 5b07ba584a623e6cec46a3b4f2d40c9428c3c098 Mon Sep 17 00:00:00 2001 From: us Date: Sat, 4 Jul 2026 13:46:48 +0300 Subject: [PATCH] fix(compose): anchor bind-mount sources to --project-directory --- .../Compose/ComposeOrchestrator.swift | 16 +++++--- .../ComposeOrchestratorTests.swift | 41 +++++++++++++++---- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index ca49123..3f37783 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -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, @@ -346,9 +346,11 @@ 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) @@ -356,10 +358,12 @@ public actor ComposeOrchestrator { 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) } } diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index c49f33f..2e2a611 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -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 { @@ -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") @@ -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)) @@ -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") @@ -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")) @@ -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") @@ -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")) @@ -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") + } }