diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 9108900..03fdc63 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -103,25 +103,37 @@ public struct ComposeDown: AsyncParsableCommand { }) } - try await stopOldStuff(services.map({ $0.serviceName }), remove: false) + try await stopOldStuff(services, remove: false) } - private func stopOldStuff(_ services: [String], remove: Bool) async throws { + private func stopOldStuff(_ services: [(serviceName: String, service: Service)], remove: Bool) async throws { guard let projectName else { return } - let containers = services.map { "\(projectName)-\($0)" } - for container in containers { - print("Stopping container: \(container)") - guard let container = try? await ClientContainer.get(id: container) else { continue } + for (serviceName, service) in services { + // Respect explicit container_name, otherwise use default pattern + let containerName: String + if let explicitContainerName = service.container_name { + containerName = explicitContainerName + } else { + containerName = "\(projectName)-\(serviceName)" + } + + print("Stopping container: \(containerName)") + guard let container = try? await ClientContainer.get(id: containerName) else { + print("Warning: Container '\(containerName)' not found, skipping.") + continue + } do { try await container.stop() + print("Successfully stopped container: \(containerName)") } catch { print("Error Stopping Container: \(error)") } if remove { do { try await container.delete() + print("Successfully removed container: \(containerName)") } catch { print("Error Removing Container: \(error)") } diff --git a/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift new file mode 100644 index 0000000..d983bfc --- /dev/null +++ b/Tests/Container-Compose-DynamicTests/ComposeDownTests.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerAPIClient +import ContainerCommands +import Foundation +import TestHelpers +import Testing + +@testable import ContainerComposeCore + +@Suite("Compose Down Tests", .containerDependent, .serialized) +struct ComposeDownTests { + + @Test("What goes up must come down - two containers") + func testUpAndDownComplex() async throws { + let yaml = DockerComposeYamlFiles.dockerComposeYaml1 + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse([ + "-d", "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeUp.run() + + var containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(project.name) + }) + + #expect( + containers.count == 2, + "Expected 2 containers for \(project.name), found \(containers.count)") + + #expect(containers.filter({ $0.status == .running }).count == 2, "Expected 2 running containers for \(project.name), found \(containers.filter({ $0.status == .running }).count)") + + var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + try await composeDown.run() + + containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(project.name) + }) + + #expect( + containers.count == 2, + "Expected 2 containers for \(project.name), found \(containers.count)") + + #expect(containers.filter({ $0.status == .stopped}).count == 2, "Expected 2 stopped containers for \(project.name), found \(containers.filter({ $0.status == .stopped }).count)") + } + + @Test("What goes up must come down - container_name") + func testUpAndDownContainerName() async throws { + // Create a new temporary UUID to use as a container name, otherwise we might conflict with + // existing containers on the system + let containerName = UUID().uuidString + + let yaml = DockerComposeYamlFiles.dockerComposeYaml9(containerName: containerName) + let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml) + + var composeUp = try ComposeUp.parse([ + "-d", "--cwd", project.base.path(percentEncoded: false), + ]) + try await composeUp.run() + + var containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(containerName) + }) + + #expect( + containers.count == 1, + "Expected 1 container with the name \(containerName), found \(containers.count)") + #expect( + containers.filter({ $0.status == .running}).count == 1, + "Expected container \(containerName) to be running, found status: \(containers.map(\.status))" + ) + + var composeDown = try ComposeDown.parse(["--cwd", project.base.path(percentEncoded: false)]) + try await composeDown.run() + + containers = try await ClientContainer.list() + .filter({ + $0.configuration.id.contains(containerName) + }) + + #expect( + containers.count == 1, + "Expected 1 container with the name \(containerName), found \(containers.count)") + #expect( + containers.filter({ $0.status == .stopped }).count == 1, + "Expected container \(containerName) to be stopped, found status: \(containers.map(\.status))" + ) + } + + enum Errors: Error { + case containerNotFound + } + +} diff --git a/Tests/TestHelpers/DockerComposeYamlFiles.swift b/Tests/TestHelpers/DockerComposeYamlFiles.swift index 43e9b12..f1539b7 100644 --- a/Tests/TestHelpers/DockerComposeYamlFiles.swift +++ b/Tests/TestHelpers/DockerComposeYamlFiles.swift @@ -14,10 +14,12 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import Foundation + public struct DockerComposeYamlFiles { public static let dockerComposeYaml1 = """ version: '3.8' - + services: wordpress: image: wordpress:latest @@ -42,16 +44,16 @@ public struct DockerComposeYamlFiles { MYSQL_ROOT_PASSWORD: rootpassword volumes: - db_data:/var/lib/mysql - + volumes: wordpress_data: db_data: """ - + public static let dockerComposeYaml2 = """ version: '3.8' name: webapp - + services: nginx: image: nginx:alpine @@ -90,18 +92,18 @@ public struct DockerComposeYamlFiles { image: redis:alpine networks: - backend - + volumes: db-data: - + networks: frontend: backend: """ - + public static let dockerComposeYaml3 = """ version: '3.8' - + services: api-gateway: image: traefik:v2.10 @@ -134,10 +136,10 @@ public struct DockerComposeYamlFiles { environment: POSTGRES_PASSWORD: postgres """ - + public static let dockerComposeYaml4 = """ version: '3.8' - + services: app: build: @@ -152,10 +154,10 @@ public struct DockerComposeYamlFiles { - "3000:3000" command: npm run dev """ - + public static let dockerComposeYaml5 = """ version: '3.8' - + services: app: image: myapp:latest @@ -164,19 +166,19 @@ public struct DockerComposeYamlFiles { target: /etc/app/config.yml secrets: - db_password - + configs: app_config: external: true - + secrets: db_password: external: true """ - + public static let dockerComposeYaml6 = """ version: '3.8' - + services: web: image: nginx:latest @@ -197,10 +199,10 @@ public struct DockerComposeYamlFiles { timeout: 5s retries: 5 """ - + public static let dockerComposeYaml7 = """ version: '3.8' - + services: frontend: image: frontend:latest @@ -219,31 +221,69 @@ public struct DockerComposeYamlFiles { db: image: postgres:14 """ - + public static let dockerComposeYaml8 = """ - version: '3.8' - - services: - web: - image: nginx:alpine - ports: - - "8082:80" - depends_on: - - app - - app: - image: python:3.12-alpine - depends_on: - - db - command: python -m http.server 8000 - environment: - DATABASE_URL: postgres://postgres:postgres@db:5432/appdb - - db: - image: postgres:14 - environment: - POSTGRES_DB: appdb - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - """ + version: '3.8' + + services: + web: + image: nginx:alpine + ports: + - "8082:80" + depends_on: + - app + + app: + image: python:3.12-alpine + depends_on: + - db + command: python -m http.server 8000 + environment: + DATABASE_URL: postgres://postgres:postgres@db:5432/appdb + + db: + image: postgres:14 + environment: + POSTGRES_DB: appdb + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + """ + + public static func dockerComposeYaml9(containerName: String) -> String { + return """ + version: '3.8' + services: + web: + image: nginx:alpine + container_name: \(containerName) + """ + } + + /// Represents a temporary Docker Compose project copied to a temporary location for testing. + public struct TemporaryProject { + /// The URL of the temporary docker-compose.yaml file. + public let url: URL + + /// The base directory containing the temporary docker-compose.yaml file. + public let base: URL + + /// The project name derived from the temporary directory name. + public let name: String + } + + /// Copies the provided Docker Compose YAML content to a temporary location and returns a + /// TemporaryProject. + /// - Parameter yaml: The Docker Compose YAML content to copy. + /// - Returns: A TemporaryProject containing the URL and project name. + public static func copyYamlToTemporaryLocation(yaml: String) throws -> TemporaryProject { + let tempLocation = URL.temporaryDirectory.appending( + path: "Container-Compose_Tests_\(UUID().uuidString)/docker-compose.yaml") + let tempBase = tempLocation.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: tempBase, withIntermediateDirectories: true) + try yaml.write(to: tempLocation, atomically: false, encoding: .utf8) + let projectName = tempBase.lastPathComponent + + return TemporaryProject(url: tempLocation, base: tempBase, name: projectName) + } + }