From 066b6770827200299686f7a02a8bed0af1bf737e Mon Sep 17 00:00:00 2001 From: Som Samantray <92726151+SomSamantray@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:43:37 +0530 Subject: [PATCH] fix: resolve bind mounts from compose file directory --- .../Commands/ComposeUp.swift | 2 +- .../Container-Compose/Helper Functions.swift | 4 +- .../ComposeUpTests.swift | 44 +++++++++++++++- .../HelperFunctionsTests.swift | 50 +++++++++++++------ 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 32c4cab..7d4c04a 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -1393,7 +1393,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { private func configVolume(_ volume: String, from dockerCompose: DockerCompose) async throws -> [String] { try composeVolumeToRunArgs( volume, - cwd: cwd, + composeDirectory: composeDirectory, fileManager: fileManager, environmentVariables: environmentVariables, projectName: projectName, diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 8f45cbf..9b40bd1 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -192,7 +192,7 @@ public func composePortToRunArg(_ portSpec: String) -> String { /// Internal so tests can reach it via `@testable import ContainerComposeCore`. func composeVolumeToRunArgs( _ volume: String, - cwd: String, + composeDirectory: String, fileManager: FileManager = .default, environmentVariables: [String: String] = [:], projectName: String?, @@ -217,7 +217,7 @@ func composeVolumeToRunArgs( } if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") { - let fullHostPath = resolvedPath(for: source, relativeTo: URL(fileURLWithPath: cwd, isDirectory: true)) + let fullHostPath = resolvedPath(for: source, relativeTo: URL(fileURLWithPath: composeDirectory, isDirectory: true)) if fileManager.fileExists(atPath: fullHostPath) { args.append("-v") diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 6ed708b..d59b871 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -90,7 +90,49 @@ struct ComposeUpTests { try? await stopInstance(location: tempLocation.deletingLastPathComponent()) } - + + @Test("Relative bind mount resolves from the compose file when up runs from the project root") + func testRelativeBindMountUsesComposeFileDirectory() async throws { + let projectRoot = URL.temporaryDirectory.appending(path: makeContainerName()) + let composeDirectory = projectRoot.appending(path: "deploy") + let dataDirectory = projectRoot.appending(path: "data") + try FileManager.default.createDirectory(at: composeDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + try "mounted".write(to: dataDirectory.appending(path: "marker.txt"), atomically: true, encoding: .utf8) + + let yaml = """ + services: + app: + image: alpine:3.20 + command: ["sleep", "3600"] + volumes: ["../data:/data"] + healthcheck: + test: ["CMD-SHELL", "test \"$(cat /data/marker.txt)\" = mounted"] + interval: "1s" + timeout: "2s" + retries: 5 + """ + try yaml.write(to: composeDirectory.appending(path: "docker-compose.yaml"), atomically: true, encoding: .utf8) + + let arguments = ["--cwd", projectRoot.path, "-f", "deploy/docker-compose.yaml"] + do { + var composeUp = try ComposeUp.parse(["-d"] + arguments) + try await composeUp.run() + + let container = try await ContainerClient().get(id: "\(projectRoot.lastPathComponent)-app") + #expect(container.status == .running) + + var composeDown = try ComposeDown.parse(arguments) + try await composeDown.run() + } catch { + var composeDown = try? ComposeDown.parse(arguments) + try? await composeDown?.run() + try? FileManager.default.removeItem(at: projectRoot) + throw error + } + try? FileManager.default.removeItem(at: projectRoot) + } + // TODO: Reenable // @Test("Test three-tier web application with multiple networks") // func testThreeTierWebAppWithNetworks() async throws { diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index 50c5475..fe53585 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -163,7 +163,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(hostFile.path):/app/config.yaml:ro", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(hostFile.path):/app/config.yaml:ro"]) @@ -177,7 +177,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(hostFile.path):/docker-entrypoint-initdb.d/init.sh", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(hostFile.path):/docker-entrypoint-initdb.d/init.sh"]) @@ -191,7 +191,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(dataDir.path):/app/data", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(dataDir.path):/app/data"]) @@ -205,26 +205,46 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(dataDir.path):/app/data:ro", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(dataDir.path):/app/data:ro"]) } - @Test("Relative file bind mount resolved to absolute path against cwd", .tempDir) - func testRelativeFileMountResolvedAgainstCwd() throws { + @Test("Relative file bind mount resolves to an absolute path against the compose directory", .tempDir) + func testRelativeFileMountResolvedAgainstComposeDirectory() throws { let tmp = TempDirTrait.current let hostFile = tmp.appending(path: "config.yaml") FileManager.default.createFile(atPath: hostFile.path, contents: nil) let result = try composeVolumeToRunArgs( "./config.yaml:/app/config.yaml:ro", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(hostFile.path):/app/config.yaml:ro"]) } + @Test("Relative parent-path bind mount resolves from the compose directory", .tempDir) + func testRelativeParentPathMountResolvesFromComposeDirectory() throws { + let tmp = TempDirTrait.current + let projectDirectory = tmp.appending(path: "project") + let composeDirectory = projectDirectory.appending(path: "deploy") + let dataDirectory = projectDirectory.appending(path: "data") + let markerFile = dataDirectory.appending(path: "marker.txt") + try FileManager.default.createDirectory(at: composeDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: dataDirectory, withIntermediateDirectories: true) + FileManager.default.createFile(atPath: markerFile.path, contents: nil) + + let result = try composeVolumeToRunArgs( + "../data:/data", + composeDirectory: composeDirectory.path, + projectName: "test" + ) + + #expect(result == ["-v", "\(dataDirectory.path):/data"]) + } + @Test("Missing host path is auto-created as a directory", .tempDir) func testMissingHostPathAutoCreated() throws { let tmp = TempDirTrait.current @@ -233,7 +253,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(newDir.path):/app/data", - cwd: tmp.path, + composeDirectory: tmp.path, projectName: "test" ) #expect(result == ["-v", "\(newDir.path):/app/data"]) @@ -244,7 +264,7 @@ struct ComposeVolumeTests { @Test("Invalid volume format returns empty array") func testInvalidFormatReturnsEmpty() throws { - let result = try composeVolumeToRunArgs("nodestination", cwd: "/tmp", projectName: "test") + let result = try composeVolumeToRunArgs("nodestination", composeDirectory: "/tmp", projectName: "test") #expect(result == []) } @@ -252,7 +272,7 @@ struct ComposeVolumeTests { func testNamedVolumeUsesNativeVolumeSyntax() throws { let result = try composeVolumeToRunArgs( "data:/var/lib/postgresql/data", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "test" ) #expect(result == ["-v", "test_data:/var/lib/postgresql/data"]) @@ -262,7 +282,7 @@ struct ComposeVolumeTests { func testNamedVolumeMountsAtDeclaredSingleSegmentDestination() throws { let result = try composeVolumeToRunArgs( "redisdata:/data", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "proj" ) #expect(result == ["-v", "proj_redisdata:/data"]) @@ -272,7 +292,7 @@ struct ComposeVolumeTests { func testNamedVolumePreservesNestedDestinationAndMode() throws { let result = try composeVolumeToRunArgs( "pgdata:/var/lib/postgresql/data:ro", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "proj" ) #expect(result == ["-v", "proj_pgdata:/var/lib/postgresql/data:ro"]) @@ -282,7 +302,7 @@ struct ComposeVolumeTests { func testNamedVolumeUsesExplicitTopLevelName() throws { let result = try composeVolumeToRunArgs( "db-data:/var/lib/postgresql/data:ro", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "test", volumeDefinitions: [ "db-data": Volume(name: "prod-db-data") @@ -295,7 +315,7 @@ struct ComposeVolumeTests { func testNamedVolumeKeepsExternalSourceName() throws { let result = try composeVolumeToRunArgs( "db-data:/var/lib/postgresql/data", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "test", volumeDefinitions: [ "db-data": Volume(external: ExternalVolume(isExternal: true, name: nil)) @@ -308,7 +328,7 @@ struct ComposeVolumeTests { func testNamedVolumeUsesExplicitExternalName() throws { let result = try composeVolumeToRunArgs( "db-data:/var/lib/postgresql/data", - cwd: "/tmp", + composeDirectory: "/tmp", projectName: "test", volumeDefinitions: [ "db-data": Volume(external: ExternalVolume(isExternal: true, name: "shared-db-data"))