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
12 changes: 11 additions & 1 deletion Sources/Container-Compose/Codable Structs/Build.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,17 @@ public struct Build: Codable, Hashable {
let keyedContainer = try decoder.container(keyedBy: CodingKeys.self)
self.context = try keyedContainer.decode(String.self, forKey: .context)
self.dockerfile = try keyedContainer.decodeIfPresent(String.self, forKey: .dockerfile)
self.args = try keyedContainer.decodeIfPresent([String: String].self, forKey: .args)
// `args:` accepts both forms per the Compose spec:
// args: args:
// KEY: value - KEY=value
// The list form previously threw `typeMismatch` and crashed `up` (#136).
if let asMap = try? keyedContainer.decodeIfPresent([String: String].self, forKey: .args) {
self.args = asMap
} else if let asList = try? keyedContainer.decodeIfPresent([String].self, forKey: .args) {
self.args = parseComposeKeyValueList(asList)
} else {
self.args = nil
}
}
}

Expand Down
12 changes: 1 addition & 11 deletions Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -369,17 +369,7 @@ public struct Service: Codable, Hashable {
/// the host doesn't define it, falls
/// back to an empty string)
static func parseEnvironmentList(_ entries: [String]) -> [String: String] {
var dict: [String: String] = [:]
for entry in entries {
if let eqIdx = entry.firstIndex(of: "=") {
let key = String(entry[..<eqIdx])
let value = String(entry[entry.index(after: eqIdx)...])
dict[key] = value
} else {
dict[entry] = ProcessInfo.processInfo.environment[entry] ?? ""
}
}
return dict
parseComposeKeyValueList(entries)
}

/// Returns the services in topological order based on `depends_on` relationships.
Expand Down
14 changes: 12 additions & 2 deletions Sources/Container-Compose/Commands/ComposeBuild.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable {
@Flag(name: .long, help: "Do not use cache when building")
var noCache: Bool = false

@Option(
name: .customLong("build-arg"),
help: "Set a build-time variable (KEY=VALUE), repeatable. Overrides a matching arg from the compose file. A bare KEY takes its value from the environment."
)
var buildArgs: [String] = []

@OptionGroup
var logging: Flags.Logging

Expand Down Expand Up @@ -91,8 +97,12 @@ public struct ComposeBuild: AsyncParsableCommand, @unchecked Sendable {
let contextURL = URL(fileURLWithPath: buildConfig.context, relativeTo: URL(fileURLWithPath: composeDirectory))
var commands = [contextURL.path]

for (key, value) in buildConfig.args ?? [:] {
commands.append(contentsOf: ["--build-arg", "\(key)=\(resolveVariable(value, with: environmentVariables))"])
// Compose-file args are interpolated against the env; CLI `--build-arg`
// values are passed through as-is (the shell already expanded them) and
// win on key conflicts.
let fileArgs = (buildConfig.args ?? [:]).mapValues { resolveVariable($0, with: environmentVariables) }
for (key, value) in mergedBuildArgs(fileArgs: fileArgs, cliArgs: buildArgs) {
commands.append(contentsOf: ["--build-arg", "\(key)=\(value)"])
}

commands.append(contentsOf: [
Expand Down
33 changes: 33 additions & 0 deletions Sources/Container-Compose/Helper Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,39 @@ public func resolveVariable(_ value: String, with envVars: [String: String]) ->
return resolvedValue
}

/// Parses the Compose "list form" of `KEY=VALUE` entries — shared by
/// `environment:` and `build.args:` — into the same `[String: String]` shape the
/// map form produces. Handles two cases:
/// - `KEY=value` → `KEY: value` (split on the FIRST `=`; later `=` chars stay
/// in the value, so values like
/// `postgres://u:p@h/db?sslmode=require`
/// round-trip correctly)
/// - `KEY` → `KEY: <process env value, or "">` (Compose's
/// "inherit from host" shorthand)
public func parseComposeKeyValueList(_ entries: [String]) -> [String: String] {
var dict: [String: String] = [:]
for entry in entries {
if let eqIdx = entry.firstIndex(of: "=") {
let key = String(entry[..<eqIdx])
let value = String(entry[entry.index(after: eqIdx)...])
dict[key] = value
} else {
dict[entry] = ProcessInfo.processInfo.environment[entry] ?? ""
}
}
return dict
}

/// Merges compose-file `build.args` with CLI `--build-arg` entries. CLI values
/// take precedence on key conflicts (parity with `docker compose build`).
/// - Parameters:
/// - fileArgs: args from the compose file (values already interpolated).
/// - cliArgs: raw `--build-arg` entries (`KEY=VALUE`, or bare `KEY` inherited
/// from the environment).
func mergedBuildArgs(fileArgs: [String: String], cliArgs: [String]) -> [String: String] {
fileArgs.merging(parseComposeKeyValueList(cliArgs)) { _, cli in cli }
}

/// Derives a project name from the current working directory. It replaces any '.' characters with
/// '_' to ensure compatibility with container naming conventions.
///
Expand Down
62 changes: 59 additions & 3 deletions Tests/Container-Compose-StaticTests/BuildConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,72 @@ struct BuildConfigurationTests {
NODE_VERSION: "18"
ENV: "production"
"""

let decoder = YAMLDecoder()
let build = try decoder.decode(Build.self, from: yaml)

#expect(build.context == ".")
#expect(build.args?["NODE_VERSION"] == "18")
#expect(build.args?["ENV"] == "production")
}

// Compose allows `build.args` in list form (`- KEY=VALUE`), not only the
// map form above. Previously the list form threw
// `DecodingError.typeMismatch` and crashed the whole `up` (see #136).
@Test("Parse build args in list form (KEY=VALUE)")
func parseBuildArgsListForm() throws {
let yaml = """
context: nginx
args:
- DIR=development
"""

let decoder = YAMLDecoder()
let build = try decoder.decode(Build.self, from: yaml)

#expect(build.context == "nginx")
#expect(build.args?["DIR"] == "development")
}



// Exact repro from #136: a service using the list form of `build.args`
// (alongside other fields) used to crash the whole `up` at decode time.
@Test("Regression #136: full service with list-form build args decodes")
func regressionListFormBuildArgsInService() throws {
let yaml = """
services:
nginx:
container_name: proxy
build:
context: nginx
args:
- DIR=development
"""

let decoder = YAMLDecoder()
let compose = try decoder.decode(DockerCompose.self, from: yaml)
let build = try #require(compose.services["nginx"]??.build)

#expect(build.context == "nginx")
#expect(build.args?["DIR"] == "development")
}

// A build-arg value that itself contains `=` must split on the FIRST `=` only,
// so the remainder is preserved verbatim.
@Test("List-form build arg value keeps internal '=' characters")
func listFormBuildArgValueKeepsEquals() throws {
let yaml = """
context: .
args:
- DATABASE_URL=postgres://u:p@h/db?sslmode=require
"""

let decoder = YAMLDecoder()
let build = try decoder.decode(Build.self, from: yaml)

#expect(build.args?["DATABASE_URL"] == "postgres://u:p@h/db?sslmode=require")
}

@Test("Service with build configuration")
func serviceWithBuildConfiguration() throws {
let yaml = """
Expand Down
24 changes: 24 additions & 0 deletions Tests/Container-Compose-StaticTests/ComposeBuildParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,28 @@ struct ComposeBuildParsingTests {
let cmd = try ComposeBuild.parse(["-f", "my-compose.yaml"])
#expect(cmd.projectOptions.composeFileOptions.composeFilename == "my-compose.yaml")
}

@Test("ComposeBuild command accepts repeated --build-arg flags")
func composeBuildCommandAcceptsBuildArgFlags() throws {
let cmd = try ComposeBuild.parse(["--build-arg", "A=1", "--build-arg", "B=2"])
#expect(cmd.buildArgs == ["A=1", "B=2"])
}

@Test("ComposeBuild command defaults build-args to empty")
func composeBuildCommandDefaultsBuildArgsToEmpty() throws {
let cmd = try ComposeBuild.parse([])
#expect(cmd.buildArgs.isEmpty)
}

@Test("CLI --build-arg overrides a matching compose-file arg")
func cliBuildArgOverridesFileArg() {
let merged = mergedBuildArgs(fileArgs: ["TOKEN": "from-file"], cliArgs: ["TOKEN=from-cli"])
#expect(merged["TOKEN"] == "from-cli")
}

@Test("CLI and file build-args that don't collide are both kept")
func cliAndFileBuildArgsMerge() {
let merged = mergedBuildArgs(fileArgs: ["ENV": "production"], cliArgs: ["TOKEN=abc123"])
#expect(merged == ["ENV": "production", "TOKEN": "abc123"])
}
}
Loading