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
6 changes: 6 additions & 0 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,10 @@ public struct Service: Codable, Hashable {

return sorted
}

/// Returns the container name, preferring an explicit `container_name` over the default pattern.
public func containerName(projectName: String, serviceName: String) -> String {
if let explicit = container_name { return explicit }
return "\(projectName)-\(serviceName)"
}
}
8 changes: 1 addition & 7 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,7 @@ public struct ComposeDown: AsyncParsableCommand {
guard let projectName else { return }

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)"
}
let containerName = service.containerName(projectName: projectName, serviceName: serviceName)

print("Stopping container: \(containerName)")

Expand Down
40 changes: 17 additions & 23 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

// Stop Services
try await stopOldStuff(services.map({ $0.serviceName }), remove: true)
try await stopOldStuff(services, remove: true)

// Process top-level networks
// This creates named networks defined in the docker-compose.yml
Expand Down Expand Up @@ -246,10 +246,10 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
return (entrypointFlag, positional)
}

private func getIPForRunningService(_ serviceName: String) async throws -> String? {
private func getIPForRunningService(_ service: Service, serviceName: String) async throws -> String? {
guard let projectName else { return nil }

let containerName = "\(projectName)-\(serviceName)"
let containerName = service.containerName(projectName: projectName, serviceName: serviceName)

let client = ContainerClient()
let container = try await client.get(id: containerName)
Expand All @@ -263,13 +263,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

/// Repeatedly checks `container list -a` until the given container is listed as `running`.
/// - Parameters:
/// - containerName: The exact name of the container (e.g. "Assignment-Manager-API-db").
/// - service: The Service definition (needed to read `container_name`).
/// - serviceName: The service key in `docker-compose.yml`.
/// - timeout: Max seconds to wait before failing.
/// - interval: How often to poll (in seconds).
/// - Returns: `true` if the container reached "running" state within the timeout.
private func waitUntilServiceIsRunning(_ serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws {
private func waitUntilServiceIsRunning(_ service: Service, serviceName: String, timeout: TimeInterval = 30, interval: TimeInterval = 0.5) async throws {
guard let projectName else { return }
let containerName = "\(projectName)-\(serviceName)"
let containerName = service.containerName(projectName: projectName, serviceName: serviceName)

let deadline = Date().addingTimeInterval(timeout)
let client = ContainerClient()
Expand All @@ -289,14 +290,14 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
])
}

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)")
for (serviceName, service) in services {
let containerName = service.containerName(projectName: projectName, serviceName: serviceName)
print("Stopping container: \(containerName)")
let client = ContainerClient()
guard let container = try? await client.get(id: container) else { continue }
guard let container = try? await client.get(id: containerName) else { continue }

do {
try await client.stop(id: container.id)
Expand All @@ -315,8 +316,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

// MARK: Compose Top Level Functions

private mutating func updateEnvironmentWithServiceIP(_ serviceName: String) async throws {
let ip = try await getIPForRunningService(serviceName)
private mutating func updateEnvironmentWithServiceIP(_ service: Service, serviceName: String) async throws {
let ip = try await getIPForRunningService(service, serviceName: serviceName)
self.containerIps[serviceName] = ip
for (key, value) in environmentVariables.map({ ($0, $1) }) where value == serviceName {
self.environmentVariables[key] = ip ?? value
Expand Down Expand Up @@ -438,14 +439,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

// Determine container name
let containerName: String
if let explicitContainerName = service.container_name {
containerName = explicitContainerName
print("Info: Using explicit container_name: \(containerName)")
} else {
// Default container name based on project and service name
containerName = "\(projectName)-\(serviceName)"
}
let containerName = service.containerName(projectName: projectName, serviceName: serviceName)
runCommandArgs.append("--name")
runCommandArgs.append(containerName)

Expand Down Expand Up @@ -638,8 +632,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

do {
try await waitUntilServiceIsRunning(serviceName)
try await updateEnvironmentWithServiceIP(serviceName)
try await waitUntilServiceIsRunning(service, serviceName: serviceName)
try await updateEnvironmentWithServiceIP(service, serviceName: serviceName)
} catch {
print(error)
}
Expand Down
25 changes: 25 additions & 0 deletions Tests/Container-Compose-DynamicTests/ComposeUpTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,31 @@ struct ComposeUpTests {
try? await stopInstance(location: upProject.base)
}

@Test("up with explicit container_name starts the right container")
func testUpWithExplicitContainerName() async throws {
let explicitName = "container-compose-test-\(makeContainerName())"
let yaml = DockerComposeYamlFiles.dockerComposeYaml9(containerName: explicitName)
let project = try DockerComposeYamlFiles.copyYamlToTemporaryLocation(yaml: yaml)

var composeUp = try ComposeUp.parse([
"-d", "--cwd", project.base.path(percentEncoded: false),
])
try await composeUp.run()

let client = ContainerClient()
let container = try? await client.get(id: explicitName)
#expect(
container != nil,
"Expected container \(explicitName) to exist after up, but get returned nil"
)
#expect(
container?.status == .running,
"Expected container \(explicitName) to be running, got \(container?.status ?? .unknown)"
)

try? await stopInstance(location: project.base)
}

enum Errors: Error {
case containerNotFound
}
Expand Down
40 changes: 40 additions & 0 deletions Tests/Container-Compose-StaticTests/ServiceNamingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// 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 Testing
import Foundation
@testable import ContainerComposeCore

@Suite("Service container naming")
struct ServiceNamingTests {

@Test("Uses explicit container_name when set")
func explicitContainerNameWins() {
let service = Service(image: "nginx", container_name: "my-custom-name")
#expect(
service.containerName(projectName: "any-project", serviceName: "any-service")
== "my-custom-name"
)
}

@Test("Falls back to projectName-serviceName when container_name unset")
func defaultPatternWhenUnset() {
let service = Service(image: "nginx")
#expect(
service.containerName(projectName: "hermes", serviceName: "jiaxi") == "hermes-jiaxi"
)
}
}