From 6f8b203c45ba95a61a6bfebe44b2a6f81d16ee9d Mon Sep 17 00:00:00 2001 From: w1zdun <19057733+w1zdun@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:28:30 +0200 Subject: [PATCH 1/3] fix(build): interpolate variables in build context and dockerfile paths A build context like 'context: ${REPOS_PATH:-..}/autopay_mock' was used literally, failing with "context dir does not exist .../${REPOS_PATH:-..}/...". Both paths now go through resolveVariable (and tilde/.. standardization via resolvedPath) in 'up' and 'build', matching how build args, ports, and volumes are already interpolated. --- .../Commands/ComposeBuild.swift | 11 ++++-- .../Commands/ComposeUp.swift | 11 ++++-- .../Container-Compose/Helper Functions.swift | 17 +++++++++ .../HelperFunctionsTests.swift | 36 +++++++++++++++++++ 4 files changed, 69 insertions(+), 6 deletions(-) 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/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 3a874f08..ead686cf 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -693,8 +693,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 +707,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..e18b28cb 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -152,6 +152,23 @@ 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) +} + /// 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..75cfc57a 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -32,6 +32,42 @@ 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("Resolve explicit relative paths against base URL") func testResolvedPathRelativeSegments() throws { let baseURL = URL(fileURLWithPath: "/tmp/project/compose/compose.yml").deletingLastPathComponent() From 15adc4d292efd570a7a80fcce833ef25a319a07e Mon Sep 17 00:00:00 2001 From: w1zdun <19057733+w1zdun@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:49:43 +0200 Subject: [PATCH 2/3] fix(run): interpolate variables in container_name and use it consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit container_name like '${ENERCARE_CONTAINER:-enercare-dev}' was used literally, producing an invalid container name. Additionally, 'up' ignored container_name entirely when waiting for a service to become running, resolving its IP, and stopping old containers — it always used the '-' pattern, so services with an explicit container_name timed out in waitUntilServiceIsRunning despite starting fine. Container names are now resolved once via resolveContainerName (explicit name with interpolation, or the default pattern) and reused across run/wait/IP/stop in both 'up' and 'down'. ComposeDown now loads the .env file so interpolation sees the same variables as 'up'. --- .../Commands/ComposeDown.swift | 19 +++++++----- .../Commands/ComposeUp.swift | 25 +++++++++------- .../Container-Compose/Helper Functions.swift | 10 +++++++ .../HelperFunctionsTests.swift | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+), 18 deletions(-) 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 ead686cf..8cb9dca7 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) diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index e18b28cb..5016171a 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. diff --git a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift index 75cfc57a..0ea60ed2 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -68,6 +68,35 @@ struct HelperFunctionsTests { #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("Resolve explicit relative paths against base URL") func testResolvedPathRelativeSegments() throws { let baseURL = URL(fileURLWithPath: "/tmp/project/compose/compose.yml").deletingLastPathComponent() From 45e43c9a9e60d535395a94801692fbae270e9e94 Mon Sep 17 00:00:00 2001 From: w1zdun <19057733+w1zdun@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:46:30 +0200 Subject: [PATCH 3/3] fix(run): interpolate variables in service environment values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service-level 'environment:' values like SERVICE_ID=${SERVICE_ID:-12345} were passed to containers literally — the merge logic skipped any value containing '${' (keeping a stale env-file value or inserting the literal) and the fill-in step only handled exact ${VAR} whole-value references with no defaults and no process environment. Values now go through resolveVariable, matching how ports, volumes, build args, and the project name are interpolated, and service env consistently overrides env-file values (Compose semantics). --- .../Commands/ComposeUp.swift | 19 ++--------- .../Container-Compose/Helper Functions.swift | 17 ++++++++++ .../HelperFunctionsTests.swift | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 8cb9dca7..ffcc3e4e 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -480,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 diff --git a/Sources/Container-Compose/Helper Functions.swift b/Sources/Container-Compose/Helper Functions.swift index 5016171a..9eff14d0 100644 --- a/Sources/Container-Compose/Helper Functions.swift +++ b/Sources/Container-Compose/Helper Functions.swift @@ -179,6 +179,23 @@ func resolveBuildPaths( 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 0ea60ed2..b2707701 100644 --- a/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift +++ b/Tests/Container-Compose-StaticTests/HelperFunctionsTests.swift @@ -97,6 +97,40 @@ struct HelperFunctionsTests { #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()