diff --git a/.gitignore b/.gitignore index 88185914..949c2b40 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ DerivedData/ .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc .vscode/launch.json +/tmp/ diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 30240c65..96beab6e 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -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 @@ -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 = [] + 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) + } + } + } + + 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 { diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 4b68ec6d..f00d74de 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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] = [:] @@ -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 ---") @@ -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) 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 { @@ -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) } } diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 371d135b..6c73e27f 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -152,6 +152,33 @@ 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: `_`. +/// - 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( @@ -159,7 +186,7 @@ func composeVolumeToRunArgs( cwd: String, fileManager: FileManager = .default, environmentVariables: [String: String] = [:], - projectName: String? + namedVolumeNames: [String: String] = [:] ) throws -> [String] { let resolvedVolume = resolveVariable(volume, with: environmentVariables) var args: [String] = [] @@ -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 diff --git a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift index 1355fde9..62e70594 100644 --- a/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift +++ b/Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift @@ -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) + } +} diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index be2ec08f..6ee408a9 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -135,8 +135,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(hostFile.path):/app/config.yaml:ro", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(hostFile.path):/app/config.yaml:ro"]) } @@ -149,8 +148,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(hostFile.path):/docker-entrypoint-initdb.d/init.sh", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(hostFile.path):/docker-entrypoint-initdb.d/init.sh"]) } @@ -163,8 +161,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(dataDir.path):/app/data", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(dataDir.path):/app/data"]) } @@ -177,8 +174,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(dataDir.path):/app/data:ro", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(dataDir.path):/app/data:ro"]) } @@ -191,8 +187,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "./config.yaml:/app/config.yaml:ro", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(hostFile.path):/app/config.yaml:ro"]) } @@ -205,8 +200,7 @@ struct ComposeVolumeTests { let result = try composeVolumeToRunArgs( "\(newDir.path):/app/data", - cwd: tmp.path, - projectName: "test" + cwd: tmp.path ) #expect(result == ["-v", "\(newDir.path):/app/data"]) var isDir: ObjCBool = false @@ -216,8 +210,90 @@ struct ComposeVolumeTests { @Test("Invalid volume format returns empty array") func testInvalidFormatReturnsEmpty() throws { - let result = try composeVolumeToRunArgs("nodestination", cwd: "/tmp", projectName: "test") + let result = try composeVolumeToRunArgs("nodestination", cwd: "/tmp") #expect(result == []) } + @Test("Named volume is mapped to its native volume name", .tempDir) + func testNamedVolumeMappedToNativeName() throws { + let tmp = TempDirTrait.current + let result = try composeVolumeToRunArgs( + "db_data:/var/lib/mysql", + cwd: tmp.path, + namedVolumeNames: ["db_data": "myproj_db_data"] + ) + // Destination must be verbatim — not its parent directory. + #expect(result == ["-v", "myproj_db_data:/var/lib/mysql"]) + // The named-volume branch must not touch the filesystem. + let contents = try FileManager.default.contentsOfDirectory(atPath: tmp.path) + #expect(contents.isEmpty) + } + + @Test("Named volume preserves mode suffix") + func testNamedVolumePreservesMode() throws { + let result = try composeVolumeToRunArgs( + "db_data:/var/lib/mysql:ro", + cwd: "/tmp", + namedVolumeNames: ["db_data": "myproj_db_data"] + ) + #expect(result == ["-v", "myproj_db_data:/var/lib/mysql:ro"]) + } + + @Test("Unmapped named volume falls back to verbatim source") + func testUnmappedNamedVolumeFallsBackVerbatim() throws { + let result = try composeVolumeToRunArgs("cache:/data", cwd: "/tmp") + #expect(result == ["-v", "cache:/data"]) + } + +} + +@Suite("Named Volume Resolution Tests") +struct NamedVolumeResolutionTests { + + @Test("Key without config resolves to project-prefixed name") + func testKeyOnly() { + let result = resolveNamedVolume(key: "db_data", config: nil, projectName: "myproj") + #expect(result.name == "myproj_db_data") + #expect(result.isExternal == false) + } + + @Test("Key with empty config resolves to project-prefixed name") + func testEmptyConfig() { + let result = resolveNamedVolume(key: "db_data", config: Volume(), projectName: "myproj") + #expect(result.name == "myproj_db_data") + #expect(result.isExternal == false) + } + + @Test("Explicit top-level name is used verbatim") + func testExplicitName() { + let result = resolveNamedVolume(key: "db_data", config: Volume(name: "custom-volume"), projectName: "myproj") + #expect(result.name == "custom-volume") + #expect(result.isExternal == false) + } + + @Test("External volume uses key verbatim and is never created") + func testExternalBool() { + let config = Volume(external: ExternalVolume(isExternal: true, name: nil)) + let result = resolveNamedVolume(key: "shared_data", config: config, projectName: "myproj") + #expect(result.name == "shared_data") + #expect(result.isExternal == true) + } + + @Test("External volume with explicit name uses that name") + func testExternalWithName() { + let config = Volume(external: ExternalVolume(isExternal: true, name: "shared")) + let result = resolveNamedVolume(key: "shared_data", config: config, projectName: "myproj") + #expect(result.name == "shared") + #expect(result.isExternal == true) + } + + @Test("Named volume source classification") + func testIsNamedVolumeSource() { + #expect(isNamedVolumeSource("db_data")) + #expect(!isNamedVolumeSource("./data")) + #expect(!isNamedVolumeSource("../data")) + #expect(!isNamedVolumeSource("/abs/path")) + #expect(!isNamedVolumeSource("a/b")) + } + }