Skip to content
Merged
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
24 changes: 18 additions & 6 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
112 changes: 112 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeDownTests.swift
Original file line number Diff line number Diff line change
@@ -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
}

}
128 changes: 84 additions & 44 deletions Tests/TestHelpers/DockerComposeYamlFiles.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -134,10 +136,10 @@ public struct DockerComposeYamlFiles {
environment:
POSTGRES_PASSWORD: postgres
"""

public static let dockerComposeYaml4 = """
version: '3.8'

services:
app:
build:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -197,10 +199,10 @@ public struct DockerComposeYamlFiles {
timeout: 5s
retries: 5
"""

public static let dockerComposeYaml7 = """
version: '3.8'

services:
frontend:
image: frontend:latest
Expand All @@ -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)
}

}