From d7e6fe4be318e2aee2c4dccae711ffff3667ac66 Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat Date: Tue, 24 Feb 2026 12:29:27 +0100 Subject: [PATCH 1/4] Allow for relative volume host paths --- .../Container-Compose/Commands/ComposeUp.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d274ff9..59e4563 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -673,13 +673,15 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { var isDirectory: ObjCBool = false // Ensure the path is absolute or relative to the current directory for FileManager let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source) + // Normalize the path to remove ./ and ../ components, and convert to absolute path + let normalizedHostPath = URL(fileURLWithPath: fullHostPath).standardizedFileURL.path(percentEncoded: false) - if fileManager.fileExists(atPath: fullHostPath, isDirectory: &isDirectory) { + if fileManager.fileExists(atPath: normalizedHostPath, isDirectory: &isDirectory) { if isDirectory.boolValue { // Host path exists and is a directory, add the volume runCommandArgs.append("-v") - // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + // Use normalized absolute path for container command (container tool requires absolute paths) + runCommandArgs.append("\(normalizedHostPath):\(destination)") } else { // Host path exists but is a file print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") @@ -687,12 +689,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } else { // Host path does not exist, assume it's meant to be a directory and try to create it. do { - try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil) - print("Info: Created missing host directory for volume: \(fullHostPath)") + try fileManager.createDirectory(atPath: normalizedHostPath, withIntermediateDirectories: true, attributes: nil) + print("Info: Created missing host directory for volume: \(normalizedHostPath)") runCommandArgs.append("-v") - runCommandArgs.append("\(source):\(destination)") // Use original source for command argument + // Use normalized absolute path for container command (container tool requires absolute paths) + runCommandArgs.append("\(normalizedHostPath):\(destination)") } catch { - print("Error: Could not create host directory '\(fullHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") + print("Error: Could not create host directory '\(normalizedHostPath)' for volume '\(resolvedVolume)': \(error.localizedDescription). Skipping this volume.") } } } else { From 31d8010660b06e1acc1940470d579889406a980e Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat Date: Tue, 24 Feb 2026 12:29:57 +0100 Subject: [PATCH 2/4] Fix named volume destination path --- Sources/Container-Compose/Commands/ComposeUp.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 59e4563..ad4d4e3 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -703,9 +703,6 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { 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." ) @@ -714,7 +711,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { // Host path exists and is a directory, add the volume runCommandArgs.append("-v") // Reconstruct the volume string without mode, ensuring it's source:destination - runCommandArgs.append("\(volumePath):\(destinationPath)") // Use original source for command argument + runCommandArgs.append("\(volumePath):\(destination)") // Use original source for command argument } return runCommandArgs From 49b974d0ca8c4e77a5c63a72f3cb599a06fb3e5d Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat Date: Wed, 25 Feb 2026 12:55:35 +0100 Subject: [PATCH 3/4] Remove volume mount directory check --- Sources/Container-Compose/Commands/ComposeUp.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index ad4d4e3..664526b 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -677,15 +677,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { let normalizedHostPath = URL(fileURLWithPath: fullHostPath).standardizedFileURL.path(percentEncoded: false) if fileManager.fileExists(atPath: normalizedHostPath, isDirectory: &isDirectory) { - if isDirectory.boolValue { - // Host path exists and is a directory, add the volume - runCommandArgs.append("-v") - // Use normalized absolute path for container command (container tool requires absolute paths) - runCommandArgs.append("\(normalizedHostPath):\(destination)") - } else { - // Host path exists but is a file - print("Warning: Volume mount source '\(source)' is a file. The 'container' tool does not support direct file mounts. Skipping this volume.") - } + // Host path exists (file or directory), add the volume. + runCommandArgs.append("-v") + // Use normalized absolute path for container command (container tool requires absolute paths) + runCommandArgs.append("\(normalizedHostPath):\(destination)") } else { // Host path does not exist, assume it's meant to be a directory and try to create it. do { From 382a28fd27b12e7d549bc80d9805bc764c4dbaf5 Mon Sep 17 00:00:00 2001 From: Joshua Hoogstraat Date: Wed, 25 Feb 2026 12:59:22 +0100 Subject: [PATCH 4/4] Add tests test(compose-up): remove container runtime content checks - keep volume tests focused on compose mount mapping behavior - drop container exec/cat assertions and host content roundtrip checks --- .../ComposeUpTests.swift | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift index 779b789..42fcd42 100644 --- a/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift +++ b/Tests/Container-Compose-DynamicTests/ComposeUpTests.swift @@ -267,6 +267,119 @@ struct ComposeUpTests { #expect(appContainer.configuration.resources.cpus == 1) #expect(appContainer.configuration.resources.memoryInBytes == 512.mib()) } + + @Test("Test compose mounts existing host file as volume") + func testComposeMountsExistingHostFileAsVolume() async throws { + let yaml = """ + version: "3.8" + services: + app: + image: nginx:alpine + volumes: + - ./config.txt:/etc/myconfig.txt + """ + + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + let hostFileURL = project.base.appending(path: "config.txt") + try "single-file-mount".write(to: hostFileURL, atomically: true, encoding: .utf8) + let expectedHostFilePath = hostFileURL.standardizedFileURL.path(percentEncoded: false) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + try await composeUp.run() + + let containers = try await ClientContainer.list() + .filter { $0.configuration.id.contains(project.name) } + + guard let appContainer = containers.first(where: { $0.configuration.id == "\(project.name)-app" }) else { + throw Errors.containerNotFound + } + + #expect( + appContainer.configuration.mounts.contains { + $0.source == expectedHostFilePath && $0.destination == "/etc/myconfig.txt" + }, + "Expected file mount source '\(expectedHostFilePath)' to '/etc/myconfig.txt', found mounts: \(appContainer.configuration.mounts)" + ) + } + + @Test("Test compose normalizes and creates relative directory mount paths") + func testComposeNormalizesAndCreatesRelativeDirectoryMountPaths() async throws { + let yaml = """ + version: "3.8" + services: + app: + image: nginx:alpine + volumes: + - ./mounts/../mounts/rel-data:/mnt/data + """ + + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + let expectedDirectoryPath = project.base.appending(path: "mounts/rel-data") + .standardizedFileURL + .path(percentEncoded: false) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + try await composeUp.run() + + let containers = try await ClientContainer.list() + .filter { $0.configuration.id.contains(project.name) } + + guard let appContainer = containers.first(where: { $0.configuration.id == "\(project.name)-app" }) else { + throw Errors.containerNotFound + } + + #expect( + appContainer.configuration.mounts.contains { + normalizePath($0.source) == normalizePath(expectedDirectoryPath) && $0.destination == "/mnt/data" + }, + "Expected normalized relative mount source '\(expectedDirectoryPath)' to '/mnt/data', found mounts: \(appContainer.configuration.mounts)" + ) + + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: expectedDirectoryPath, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + } + + @Test("Test compose named volume mount preserves full destination path") + func testComposeNamedVolumeMountPreservesFullDestinationPath() async throws { + let destinationPath = "/var/lib/app/data" + let yaml = """ + version: "3.8" + services: + app: + image: nginx:alpine + volumes: + - app_data:\(destinationPath) + volumes: + app_data: + """ + + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + let expectedVolumeSource = URL.homeDirectory + .appending(path: ".containers/Volumes/\(project.name)/app_data") + .path(percentEncoded: false) + + var composeUp = try ComposeUp.parse(["-d", "--cwd", project.base.path(percentEncoded: false)]) + try await composeUp.run() + + let containers = try await ClientContainer.list() + .filter { $0.configuration.id.contains(project.name) } + + guard let appContainer = containers.first(where: { $0.configuration.id == "\(project.name)-app" }) else { + throw Errors.containerNotFound + } + + #expect( + appContainer.configuration.mounts.contains { + normalizePath($0.source) == normalizePath(expectedVolumeSource) && $0.destination == destinationPath + }, + "Expected named volume source '\(expectedVolumeSource)' to mount at '\(destinationPath)', found mounts: \(appContainer.configuration.mounts)" + ) + + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: expectedVolumeSource, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + } enum Errors: Error { case containerNotFound @@ -278,6 +391,10 @@ struct ComposeUpTests { return dict } + + private func normalizePath(_ path: String) -> String { + URL(fileURLWithPath: path).standardizedFileURL.path(percentEncoded: false) + } } struct ContainerDependentTrait: TestScoping, TestTrait, SuiteTrait {