diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index d274ff9..664526b 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -673,26 +673,24 @@ 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) - - if fileManager.fileExists(atPath: fullHostPath, 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 - } 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.") - } + // 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: normalizedHostPath, isDirectory: &isDirectory) { + // 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 { - 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 { @@ -700,9 +698,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." ) @@ -711,7 +706,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 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 {