Skip to content
Closed
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
33 changes: 14 additions & 19 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -673,36 +673,31 @@ 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 {
guard let projectName else { return [] }
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."
)
Expand All @@ -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
Expand Down
117 changes: 117 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down