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
11 changes: 8 additions & 3 deletions Sources/Container-Compose/Commands/ComposeBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,20 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable {

// Per Compose spec: `context` is relative to the compose file's directory,
// and `dockerfile` is relative to the resolved `context` (not the compose dir).
let contextURL = URL(fileURLWithPath: buildConfig.context, relativeTo: URL(fileURLWithPath: composeDirectory))
var commands = [contextURL.path]
let buildPaths = resolveBuildPaths(
context: buildConfig.context,
dockerfile: buildConfig.dockerfile,
composeDirectory: composeDirectory,
environmentVariables: environmentVariables
)
var commands = [buildPaths.contextPath]

for (key, value) in buildConfig.args ?? [:] {
commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"])
}

commands.append(contentsOf: [
"--file", URL(fileURLWithPath: buildConfig.dockerfile ?? "Dockerfile", relativeTo: contextURL).path,
"--file", buildPaths.dockerfilePath,
"--tag", imageTag,
])

Expand Down
19 changes: 12 additions & 7 deletions Sources/Container-Compose/Commands/ComposeDown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,14 @@ public struct ComposeDown: AsyncParsableCommand {
return cwdURL.appending(path: Self.supportedComposeFilenames[0]).path
}

private var envFilePath: String {
let envFile = process.envFile.first ?? ".env"
return resolvedPath(for: envFile, relativeTo: cwdURL)
}

private var fileManager: FileManager { FileManager.default }
private var projectName: String?
private var environmentVariables: [String: String] = [:]

public mutating func run() async throws {

Expand All @@ -89,6 +95,9 @@ public struct ComposeDown: AsyncParsableCommand {
let dockerComposeString = String(data: yamlData, encoding: .utf8)!
let dockerCompose = try YAMLDecoder().decode(DockerCompose.self, from: dockerComposeString)

// Load environment variables from .env file
environmentVariables = loadEnvFile(path: envFilePath)

// Determine project name for container naming
if let name = dockerCompose.name {
projectName = name
Expand Down Expand Up @@ -121,13 +130,9 @@ 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)"
}
// Respect explicit container_name (with variable interpolation), otherwise use default pattern
let containerName = resolveContainerName(
explicit: service.container_name, projectName: projectName, serviceName: serviceName, envVars: environmentVariables)

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

Expand Down
55 changes: 25 additions & 30 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 containerNames: [String: String] = [:] // service name -> resolved container name
private var containerIps: [String: String] = [:]
private var containerConsoleColors: [String: NamedColor] = [:]

Expand Down Expand Up @@ -157,7 +158,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 @@ -250,7 +251,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
private func getIPForRunningService(_ serviceName: String) async throws -> String? {
guard let projectName else { return nil }

let containerName = "\(projectName)-\(serviceName)"
let containerName = containerNames[serviceName] ?? "\(projectName)-\(serviceName)"

let client = ContainerClient()
let container = try await client.get(id: containerName)
Expand All @@ -267,7 +268,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
/// - 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 {
guard let projectName else { return }
let containerName = "\(projectName)-\(serviceName)"
let containerName = containerNames[serviceName] ?? "\(projectName)-\(serviceName)"

let deadline = Date().addingTimeInterval(timeout)
let client = ContainerClient()
Expand All @@ -287,9 +288,13 @@ 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)" }
// Respect explicit container_name (with variable interpolation), like ComposeDown does.
let containers = services.map {
resolveContainerName(
explicit: $0.service.container_name, projectName: projectName, serviceName: $0.serviceName, envVars: environmentVariables)
}

for container in containers {
print("Stopping container: \(container)")
Expand Down Expand Up @@ -436,14 +441,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

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

Expand Down Expand Up @@ -477,22 +480,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
}

if let serviceEnv = service.environment {
combinedEnv.merge(serviceEnv) { (old, new) in
guard !new.contains("${") else {
return old
}
return new
} // Service env overrides .env files
}

// Fill in variables
combinedEnv = combinedEnv.mapValues({ value in
guard value.contains("${") else { return value }

let variableName = String(value.replacingOccurrences(of: "${", with: "").dropLast())
return combinedEnv[variableName] ?? value
})
// Service env overrides env files; its values are variable-interpolated
// per Compose semantics (e.g. SERVICE_ID=${SERVICE_ID:-12345}).
combinedEnv = mergeServiceEnvironment(base: combinedEnv, serviceEnvironment: service.environment, envVars: environmentVariables)

// Fill in IPs
combinedEnv = combinedEnv.mapValues({ value in
Expand Down Expand Up @@ -693,16 +683,21 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

// Per Compose spec: `context` is relative to the compose file's directory,
// and `dockerfile` is relative to the resolved `context` (not the compose dir).
let contextURL = URL(fileURLWithPath: buildConfig.context, relativeTo: URL(fileURLWithPath: composeDirectory))
var commands = [contextURL.path]
let buildPaths = resolveBuildPaths(
context: buildConfig.context,
dockerfile: buildConfig.dockerfile,
composeDirectory: composeDirectory,
environmentVariables: environmentVariables
)
var commands = [buildPaths.contextPath]

// Add build arguments
for (key, value) in buildConfig.args ?? [:] {
commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"])
}

// Add Dockerfile path
commands.append(contentsOf: ["--file", URL(fileURLWithPath: buildConfig.dockerfile ?? "Dockerfile", relativeTo: contextURL).path])
commands.append(contentsOf: ["--file", buildPaths.dockerfilePath])

// Add caching options
if noCache {
Expand Down
44 changes: 44 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,16 @@ public func deriveProjectName(cwd: String) -> String {
return projectName
}

/// Resolves the container name for a service. An explicit `container_name`
/// (with variable interpolation, e.g. `${NAME:-fallback}`) takes precedence;
/// otherwise the default `<projectName>-<serviceName>` pattern is used.
func resolveContainerName(explicit: String?, projectName: String, serviceName: String, envVars: [String: String] = [:]) -> String {
if let explicit {
return resolveVariable(explicit, with: envVars)
}
return "\(projectName)-\(serviceName)"
}

/// Converts Docker Compose port specification into a container run -p format.
/// Handles various formats: "PORT", "HOST:PORT", "IP:HOST:PORT", and optional protocol.
/// - Parameter portSpec: The port specification string from docker-compose.yml.
Expand Down Expand Up @@ -152,6 +162,40 @@ public func composePortToRunArg(_ portSpec: String) -> String {
}
}

/// Resolves a build's context directory and Dockerfile to absolute paths,
/// interpolating variables (`${VAR}`, `${VAR:-default}`) in both. Per the
/// Compose spec, `context` is relative to the compose file's directory and
/// `dockerfile` is relative to the resolved context.
func resolveBuildPaths(
context: String,
dockerfile: String?,
composeDirectory: String,
environmentVariables: [String: String] = [:]
) -> (contextPath: String, dockerfilePath: String) {
let resolvedContext = resolveVariable(context, with: environmentVariables)
let contextPath = resolvedPath(for: resolvedContext, relativeTo: URL(fileURLWithPath: composeDirectory, isDirectory: true))
let resolvedDockerfile = resolveVariable(dockerfile ?? "Dockerfile", with: environmentVariables)
let dockerfilePath = resolvedPath(for: resolvedDockerfile, relativeTo: URL(fileURLWithPath: contextPath, isDirectory: true))
return (contextPath, dockerfilePath)
}

/// Merges a service's `environment:` values over the base environment,
/// following Compose semantics: env-file values are literal, while
/// service-level values are variable-interpolated (`${VAR}`, `${VAR:-default}`,
/// resolved from the .env file and the process environment) and override the
/// files.
func mergeServiceEnvironment(
base: [String: String],
serviceEnvironment: [String: String]?,
envVars: [String: String]
) -> [String: String] {
var combined = base
for (key, value) in serviceEnvironment ?? [:] {
combined[key] = resolveVariable(value, with: envVars)
}
return combined
}

/// 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(
Expand Down
99 changes: 99 additions & 0 deletions Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,105 @@ struct HelperFunctionsTests {
#expect(projectName == "_devcontainers")
}

@Test("Build context with variable default is interpolated and resolved")
func testBuildContextVariableInterpolated() throws {
// Regression: `build.context: ${REPOS_PATH:-..}/webapp` was used
// literally, producing "context dir does not exist .../${REPOS_PATH:-..}/webapp".
let result = resolveBuildPaths(
context: "${CC_TEST_REPOS_PATH_UNSET:-..}/webapp",
dockerfile: nil,
composeDirectory: "/tmp/project/env"
)
#expect(result.contextPath == "/tmp/project/webapp")
#expect(result.dockerfilePath == "/tmp/project/webapp/Dockerfile")
}

@Test("Build context variable resolved from env file")
func testBuildContextVariableFromEnvFile() throws {
let result = resolveBuildPaths(
context: "${CC_TEST_REPOS_PATH_UNSET:-..}/webapp",
dockerfile: "docker/Dockerfile.dev",
composeDirectory: "/tmp/project/env",
environmentVariables: ["CC_TEST_REPOS_PATH_UNSET": "/srv/repos"]
)
#expect(result.contextPath == "/srv/repos/webapp")
#expect(result.dockerfilePath == "/srv/repos/webapp/docker/Dockerfile.dev")
}

@Test("Literal build context stays relative to compose directory")
func testLiteralBuildContext() throws {
let result = resolveBuildPaths(
context: ".",
dockerfile: nil,
composeDirectory: "/tmp/project/env"
)
#expect(result.contextPath == "/tmp/project/env")
#expect(result.dockerfilePath == "/tmp/project/env/Dockerfile")
}

@Test("Container name with variable default is interpolated")
func testContainerNameVariableInterpolated() throws {
// Regression: `container_name: ${WEB_CONTAINER:-web-dev}` was
// used literally, producing an invalid container name.
let result = resolveContainerName(
explicit: "${CC_TEST_CONTAINER_UNSET:-web-dev}", projectName: "myproj", serviceName: "web")
#expect(result == "web-dev")
}

@Test("Container name variable resolved from env file")
func testContainerNameVariableFromEnvFile() throws {
let result = resolveContainerName(
explicit: "${CC_TEST_CONTAINER_UNSET:-web-dev}", projectName: "myproj", serviceName: "web",
envVars: ["CC_TEST_CONTAINER_UNSET": "web-worktree"])
#expect(result == "web-worktree")
}

@Test("Literal explicit container name is used verbatim")
func testLiteralExplicitContainerName() throws {
let result = resolveContainerName(explicit: "my-container", projectName: "myproj", serviceName: "web")
#expect(result == "my-container")
}

@Test("Default container name is project-service")
func testDefaultContainerName() throws {
let result = resolveContainerName(explicit: nil, projectName: "myproj", serviceName: "web")
#expect(result == "myproj-web")
}

@Test("Service environment value with variable default is interpolated")
func testServiceEnvDefaultInterpolated() throws {
// Regression: SERVICE_ID=${SERVICE_ID:-12345} reached
// the container as a literal string.
let result = mergeServiceEnvironment(
base: [:],
serviceEnvironment: ["SERVICE_ID": "${CC_TEST_SERVICE_ID_UNSET:-12345}"],
envVars: [:]
)
#expect(result["SERVICE_ID"] == "12345")
}

@Test("Service environment value resolved from env file")
func testServiceEnvFromEnvFile() throws {
let result = mergeServiceEnvironment(
base: ["DATABASE_HOST": "db"],
serviceEnvironment: ["DATABASE_HOST": "${DATABASE_HOST_X}", "EXTRA": "literal"],
envVars: ["DATABASE_HOST_X": "db-resolved"]
)
#expect(result["DATABASE_HOST"] == "db-resolved")
#expect(result["EXTRA"] == "literal")
}

@Test("Service environment overrides base env-file values")
func testServiceEnvOverridesBase() throws {
let result = mergeServiceEnvironment(
base: ["MODE": "from-file", "KEEP": "kept"],
serviceEnvironment: ["MODE": "from-service"],
envVars: [:]
)
#expect(result["MODE"] == "from-service")
#expect(result["KEEP"] == "kept")
}

@Test("Resolve explicit relative paths against base URL")
func testResolvedPathRelativeSegments() throws {
let baseURL = URL(fileURLWithPath: "/tmp/project/compose/compose.yml").deletingLastPathComponent()
Expand Down