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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ DerivedData/
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.vscode/launch.json
/tmp/
54 changes: 53 additions & 1 deletion Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ public struct ComposeDown: AsyncParsableCommand {
@Argument(help: "Specify the services to stop")
var services: [String] = []

@Flag(name: [.customShort("v"), .customLong("volumes")], help: "Remove named volumes declared in the compose file")
var volumes: Bool = false

@OptionGroup
var process: Flags.Process

Expand Down Expand Up @@ -114,7 +117,56 @@ public struct ComposeDown: AsyncParsableCommand {
})
}

try await stopOldStuff(services, remove: false)
// Removing a volume fails while a container still references it, so
// when -v is passed the containers are removed too (matching docker
// compose down, which always removes containers).
try await stopOldStuff(services, remove: volumes)

if volumes {
try await removeNamedVolumes(from: dockerCompose, services: services)
}
}

private func removeNamedVolumes(from dockerCompose: DockerCompose, services: [(serviceName: String, service: Service)]) async throws {
guard let projectName else { return }

var handledKeys: Set<String> = []
for (volumeKey, volumeConfig) in dockerCompose.volumes ?? [:] {
handledKeys.insert(volumeKey)
let (nativeName, isExternal) = resolveNamedVolume(key: volumeKey, config: volumeConfig, projectName: projectName)
if isExternal {
print("Info: Volume '\(volumeKey)' is external and will not be removed.")
continue
}
await removeNamedVolume(key: volumeKey, name: nativeName)
}

// Also remove named volumes that `up` created on demand for service
// references not declared top-level.
for (_, service) in services {
for volume in service.volumes ?? [] {
guard let source = volume.split(separator: ":", maxSplits: 2).map(String.init).first,
isNamedVolumeSource(source), !handledKeys.contains(source)
else { continue }
handledKeys.insert(source)
let (nativeName, _) = resolveNamedVolume(key: source, config: nil, projectName: projectName)
await removeNamedVolume(key: source, name: nativeName)
}
}
Comment on lines +144 to +155
}

private func removeNamedVolume(key: String, name: String) async {
print("Removing volume: \(key) (\(name))")
do {
try await ClientVolume.delete(name: name)
print("Successfully removed volume: \(name)")
} catch {
guard (try? await ClientVolume.inspect(name)) == nil else {
print("Error Removing Volume '\(name)': \(error)")
return
}
print("Warning: Volume '\(name)' not found, skipping.")
}
}

private func stopOldStuff(_ services: [(serviceName: String, service: Service)], remove: Bool) async throws {
Expand Down
70 changes: 52 additions & 18 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
private var fileManager: FileManager { FileManager.default }
private var projectName: String?
private var environmentVariables: [String: String] = [:]
private var namedVolumeNames: [String: String] = [:] // compose volume key -> native volume name
private var containerIps: [String: String] = [:]
private var containerConsoleColors: [String: NamedColor] = [:]

Expand Down Expand Up @@ -170,15 +171,37 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

// Process top-level volumes
// This creates named volumes defined in the docker-compose.yml
// This creates native named volumes defined in the docker-compose.yml
print("\n--- Processing Volumes ---")
let existingVolumes = Set((try? await ClientVolume.list())?.map(\.name) ?? [])
if let volumes = dockerCompose.volumes {
print("\n--- Processing Volumes ---")
for (volumeName, volumeConfig) in volumes {
guard let volumeConfig else { continue }
await createVolumeHardLink(name: volumeName, config: volumeConfig)
for (volumeKey, volumeConfig) in volumes {
let (nativeName, isExternal) = resolveNamedVolume(key: volumeKey, config: volumeConfig, projectName: projectName ?? "")
namedVolumeNames[volumeKey] = nativeName
if isExternal {
print("Info: Volume '\(volumeKey)' is declared as external. Assuming '\(nativeName)' already exists; it will not be created.")
continue
}
try await createNamedVolume(key: volumeKey, name: nativeName, config: volumeConfig, existing: existingVolumes)
}
}

// Create named volumes referenced by services but not declared top-level.
// Docker Compose errors in this case; this tool has historically been
// tolerant, so create them on demand instead.
for (_, service) in services {
for volume in service.volumes ?? [] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)
guard let source = resolvedVolume.split(separator: ":", maxSplits: 2).map(String.init).first,
isNamedVolumeSource(source), namedVolumeNames[source] == nil
else { continue }
let (nativeName, _) = resolveNamedVolume(key: source, config: nil, projectName: projectName ?? "")
print("Info: Volume '\(source)' is referenced by a service but not declared top-level. Creating '\(nativeName)'.")
namedVolumeNames[source] = nativeName
try await createNamedVolume(key: source, name: nativeName, config: nil, existing: existingVolumes)
}
print("--- Volumes Processed ---\n")
}
print("--- Volumes Processed ---\n")

// Process each service defined in the docker-compose.yml
print("\n--- Processing Services ---")
Expand Down Expand Up @@ -324,17 +347,28 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
}

private func createVolumeHardLink(name volumeName: String, config volumeConfig: Volume) async {
guard let projectName else { return }
let actualVolumeName = volumeConfig.name ?? volumeName // Use explicit name or key as name

let volumeUrl = URL.homeDirectory.appending(path: ".containers/Volumes/\(projectName)/\(actualVolumeName)")
let volumePath = volumeUrl.path(percentEncoded: false)

print(
"Warning: Volume source '\(actualVolumeName)' 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)
private func createNamedVolume(key: String, name: String, config: Volume?, existing: Set<String>) async throws {
guard !existing.contains(name) else {
print("Volume '\(key)' (\(name)) already exists")
return
}
print("Creating volume: \(key) (Actual name: \(name))")
do {
_ = try await ClientVolume.create(
name: name,
driver: config?.driver ?? "local",
driverOpts: config?.driver_opts ?? [:],
labels: config?.labels ?? [:]
)
print("Volume '\(key)' created")
} catch {
// Tolerate races with the pre-check: an already-existing volume is success.
if (try? await ClientVolume.inspect(name)) != nil {
print("Volume '\(key)' (\(name)) already exists")
return
}
throw error
}
}

private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws {
Expand Down Expand Up @@ -740,7 +774,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

private func configVolume(_ volume: String) async throws -> [String] {
try composeVolumeToRunArgs(volume, cwd: cwd, fileManager: fileManager, environmentVariables: environmentVariables, projectName: projectName)
try composeVolumeToRunArgs(volume, cwd: cwd, fileManager: fileManager, environmentVariables: environmentVariables, namedVolumeNames: namedVolumeNames)
}
}

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

/// Returns `true` when a volume source refers to a named volume rather than a
/// host path (bind mount). Shared by the args builder and the volume pre-scan
/// in `ComposeUp` so the classification can never drift between call sites.
func isNamedVolumeSource(_ source: String) -> Bool {
!(source.contains("/") || source.starts(with: ".") || source.starts(with: ".."))
}

/// Resolves the actual native volume name for a top-level compose volume entry,
/// following Docker Compose naming rules:
/// - `external`: use the external name (or the key verbatim); never created by this tool.
/// - explicit top-level `name:`: used verbatim.
/// - otherwise: `<projectName>_<key>`.
/// - Parameters:
/// - key: The volume key from the top-level `volumes:` mapping (or a service-level reference).
/// - config: The top-level volume configuration, if declared.
/// - projectName: The resolved compose project name.
/// - Returns: The native volume name and whether the volume is external (pre-existing).
func resolveNamedVolume(key: String, config: Volume?, projectName: String) -> (name: String, isExternal: Bool) {
if let external = config?.external, external.isExternal {
return (external.name ?? key, true)
}
if let explicitName = config?.name {
return (explicitName, false)
}
return ("\(projectName)_\(key)", false)
}

/// 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?
namedVolumeNames: [String: String] = [:]
) throws -> [String] {
let resolvedVolume = resolveVariable(volume, with: environmentVariables)
var args: [String] = []
Expand All @@ -174,42 +201,34 @@ func composeVolumeToRunArgs(
let destination = components[1]
let mode = components.count == 3 ? components[2] : nil

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

if source.contains("/") || source.starts(with: ".") || source.starts(with: "..") {
if !isNamedVolumeSource(source) {
let fullHostPath = resolvedPath(for: source, relativeTo: URL(fileURLWithPath: cwd, isDirectory: true))

if fileManager.fileExists(atPath: fullHostPath) {
args.append("-v")
args.append(bindMountArg(source: fullHostPath))
args.append(mountArg(source: fullHostPath))
} 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: fullHostPath))
args.append(mountArg(source: fullHostPath))
} 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)

// Named volume reference. Map the compose key to the native volume
// name; the caller is responsible for creating the volume and seeding
// the mapping. Fall back to the verbatim source if unmapped.
let nativeName = namedVolumeNames[source] ?? source
args.append("-v")
let modeStr = mode.map { ":\($0)" } ?? ""
args.append("\(volumePath):\(destinationPath)\(modeStr)")
args.append(mountArg(source: nativeName))
}

return args
Expand Down
22 changes: 22 additions & 0 deletions Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,25 @@ struct ComposeBuildParsingTests {
#expect(cmd.composeFileOptions.composeFilename == "my-compose.yaml")
}
}

@Suite("Compose Down Parsing Tests")
struct ComposeDownParsingTests {

@Test("ComposeDown command parses -v flag")
func composeDownCommandParsesShortVolumesFlag() throws {
let cmd = try ComposeDown.parse(["-v"])
#expect(cmd.volumes == true)
}

@Test("ComposeDown command parses --volumes flag")
func composeDownCommandParsesLongVolumesFlag() throws {
let cmd = try ComposeDown.parse(["--volumes"])
#expect(cmd.volumes == true)
}

@Test("ComposeDown command defaults volumes to false")
func composeDownCommandDefaultsVolumesToFalse() throws {
let cmd = try ComposeDown.parse([])
#expect(cmd.volumes == false)
}
}
Loading
Loading