diff --git a/Sources/Container-Compose/Commands/ComposeBuild.swift b/Sources/Container-Compose/Commands/ComposeBuild.swift index 5abde194..fbfa90fd 100644 --- a/Sources/Container-Compose/Commands/ComposeBuild.swift +++ b/Sources/Container-Compose/Commands/ComposeBuild.swift @@ -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, ]) diff --git a/Sources/Container-Compose/Commands/ComposeDown.swift b/Sources/Container-Compose/Commands/ComposeDown.swift index 30240c65..4dec7540 100644 --- a/Sources/Container-Compose/Commands/ComposeDown.swift +++ b/Sources/Container-Compose/Commands/ComposeDown.swift @@ -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 { @@ -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 @@ -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)") diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 3a874f08..ffcc3e4e 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 containerNames: [String: String] = [:] // service name -> resolved container name private var containerIps: [String: String] = [:] private var containerConsoleColors: [String: NamedColor] = [:] @@ -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 @@ -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) @@ -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() @@ -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)") @@ -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) @@ -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 @@ -693,8 +683,13 @@ 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 ?? [:] { @@ -702,7 +697,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable { } // 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 { diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 371d135b..9eff14d0 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -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 `-` 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. @@ -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( diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index be2ec08f..b2707701 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -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()