Skip to content
Open
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
65 changes: 1 addition & 64 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -686,70 +686,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

private func configVolume(_ volume: String) async throws -> [String] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)

var runCommandArgs: [String] = []

// Parse the volume string: destination[:mode]
let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)

guard components.count >= 2 else {
print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
return []
}

let source = components[0]
let destination = components[1]

// Check if the source looks like a host path (contains '/' or starts with '.')
// This heuristic helps distinguish bind mounts from named volume references.
if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") {
// This is likely a bind mount (local path to container path)
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.")
}
} 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)")
runCommandArgs.append("-v")
runCommandArgs.append("\(source):\(destination)") // Use original source for command argument
} catch {
print("Error: Could not create host directory '\(fullHostPath)' 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."
)
try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true)

// 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
}

return runCommandArgs
try composeVolumeToRunArgs(volume, cwd: cwd, fileManager: fileManager, environmentVariables: environmentVariables, projectName: projectName)
}
}

Expand Down
62 changes: 62 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,68 @@ public func composePortToRunArg(_ portSpec: String) -> String {
}
}

/// Converts a Docker Compose `volumes:` entry into the `--volume` arguments for `container run`.
/// Internal so tests can reach it via `@testable import ContainerComposeCore`.
func composeVolumeToRunArgs(
_ volume: String,
cwd: String,
fileManager: FileManager = .default,
environmentVariables: [String: String] = [:],
projectName: String?
) throws -> [String] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)
var args: [String] = []

let components = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init)
guard components.count >= 2 else {
print("Warning: Volume entry '\(resolvedVolume)' has an invalid format (expected 'source:destination'). Skipping.")
return []
}

let source = components[0]
let destination = components[1]
let mode = components.count == 3 ? components[2] : nil

func bindMountArg(source: String) -> String {
if let mode { return "\(source):\(destination):\(mode)" }
return "\(source):\(destination)"
}

if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") {
let fullHostPath = (source.starts(with: "/") || source.starts(with: "~")) ? source : (cwd + "/" + source)

if fileManager.fileExists(atPath: fullHostPath) {
args.append("-v")
args.append(bindMountArg(source: source))
} else {
do {
try fileManager.createDirectory(atPath: fullHostPath, withIntermediateDirectories: true, attributes: nil)
print("Info: Created missing host directory for volume: \(fullHostPath)")
args.append("-v")
args.append(bindMountArg(source: source))
} catch {
print("Error: Could not create host directory '\(fullHostPath)' 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."
)
try fileManager.createDirectory(atPath: volumePath, withIntermediateDirectories: true)

args.append("-v")
args.append("\(volumePath):\(destinationPath)")
}

return args
}

extension String: @retroactive Error {}

/// A structure representing the result of a command-line process execution.
Expand Down
103 changes: 103 additions & 0 deletions Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,106 @@ struct HelperFunctionsTests {
}

}

@Suite("Compose Volume Tests")
struct ComposeVolumeTests {

private func makeTempDir() throws -> URL {
let tmp = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp
}

@Test("Single-file bind mount with :ro mode is forwarded")
func testFileMountWithMode() throws {
let tmp = try makeTempDir()
let hostFile = tmp.appending(path: "config.yaml")
FileManager.default.createFile(atPath: hostFile.path, contents: nil)

let result = try composeVolumeToRunArgs(
"\(hostFile.path):/app/config.yaml:ro",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "\(hostFile.path):/app/config.yaml:ro"])
}

@Test("Single-file bind mount without mode is forwarded")
func testFileMountNoMode() throws {
let tmp = try makeTempDir()
let hostFile = tmp.appending(path: "init.sh")
FileManager.default.createFile(atPath: hostFile.path, contents: nil)

let result = try composeVolumeToRunArgs(
"\(hostFile.path):/docker-entrypoint-initdb.d/init.sh",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "\(hostFile.path):/docker-entrypoint-initdb.d/init.sh"])
}

@Test("Directory bind mount is forwarded")
func testDirectoryMount() throws {
let tmp = try makeTempDir()
let dataDir = tmp.appending(path: "data")
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)

let result = try composeVolumeToRunArgs(
"\(dataDir.path):/app/data",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "\(dataDir.path):/app/data"])
}

@Test("Directory bind mount with :ro mode preserves mode")
func testDirectoryMountWithMode() throws {
let tmp = try makeTempDir()
let dataDir = tmp.appending(path: "data")
try FileManager.default.createDirectory(at: dataDir, withIntermediateDirectories: true)

let result = try composeVolumeToRunArgs(
"\(dataDir.path):/app/data:ro",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "\(dataDir.path):/app/data:ro"])
}

@Test("Relative file bind mount resolved against cwd")
func testRelativeFileMountResolvedAgainstCwd() throws {
let tmp = try makeTempDir()
FileManager.default.createFile(atPath: tmp.appending(path: "config.yaml").path, contents: nil)

let result = try composeVolumeToRunArgs(
"./config.yaml:/app/config.yaml:ro",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "./config.yaml:/app/config.yaml:ro"])
}

@Test("Missing host path is auto-created as a directory")
func testMissingHostPathAutoCreated() throws {
let tmp = try makeTempDir()
let newDir = tmp.appending(path: "new-volume")
#expect(!FileManager.default.fileExists(atPath: newDir.path))

let result = try composeVolumeToRunArgs(
"\(newDir.path):/app/data",
cwd: tmp.path,
projectName: "test"
)
#expect(result == ["-v", "\(newDir.path):/app/data"])
var isDir: ObjCBool = false
#expect(FileManager.default.fileExists(atPath: newDir.path, isDirectory: &isDir))
#expect(isDir.boolValue)
}

@Test("Invalid volume format returns empty array")
func testInvalidFormatReturnsEmpty() throws {
let result = try composeVolumeToRunArgs("nodestination", cwd: "/tmp", projectName: "test")
#expect(result == [])
}

}