Skip to content
Merged
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
29 changes: 22 additions & 7 deletions Sources/AppleDockerCLI/DockerShim/DockerCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,28 @@ public struct DockerCommand: Sendable, Equatable {
public let flags: [String: String]
/// Flags that were present without a value (booleans).
public let booleanFlags: Set<String>
/// Every value a flag was given, in order. `flags` keeps only the last one.
public let flagValues: [String: [String]]

public init(
subcommand: String,
subSubcommand: String? = nil,
arguments: [String] = [],
flags: [String: String] = [:],
booleanFlags: Set<String> = []
booleanFlags: Set<String> = [],
flagValues: [String: [String]] = [:]
) {
self.subcommand = subcommand
self.subSubcommand = subSubcommand
self.arguments = arguments
self.flags = flags
self.booleanFlags = booleanFlags
self.flagValues = flagValues.isEmpty ? flags.mapValues { [$0] } : flagValues
}

/// All values of a repeatable flag such as `-f a.yml -f b.yml`, in order.
public func values(_ flag: String) -> [String] {
flagValues[flag] ?? []
}

/// Whether a boolean flag was present.
Expand Down Expand Up @@ -134,7 +143,12 @@ public enum DockerCommandParser {
var subSubcommand: String?
var positionals: [String] = []
var flags: [String: String] = [:]
var flagValues: [String: [String]] = [:]
var booleanFlags: Set<String> = []
func setFlag(_ name: String, _ value: String) {
flags[name] = value
flagValues[name, default: []].append(value)
}

// Docker allows global flags before the subcommand (e.g. `docker -H tcp://... ps`).
// We skip known global flags and treat the first non-flag token as the subcommand.
Expand Down Expand Up @@ -165,10 +179,10 @@ public enum DockerCommandParser {
let (name, inlineValue) = splitFlag(token)
tokens.removeFirst()
if let inlineValue {
flags[name] = inlineValue
setFlag(name, inlineValue)
} else if !isBooleanGlobalFlag(name) {
if let next = tokens.first {
flags[name] = next
setFlag(name, next)
tokens.removeFirst()
} else {
booleanFlags.insert(name)
Expand Down Expand Up @@ -213,11 +227,11 @@ public enum DockerCommandParser {
if token.hasPrefix("-") {
let (name, inlineValue) = splitFlag(token)
if let inlineValue {
flags[name] = inlineValue
setFlag(name, inlineValue)
} else if valueSet.contains(name) {
// Value-taking flag: consume the next token as its value.
if let next = tokens.first {
flags[name] = next
setFlag(name, next)
tokens.removeFirst()
} else {
booleanFlags.insert(name)
Expand All @@ -227,7 +241,7 @@ public enum DockerCommandParser {
} else {
// Unknown flag: default to value-taking (docker convention).
if let next = tokens.first, !next.hasPrefix("-") {
flags[name] = next
setFlag(name, next)
tokens.removeFirst()
} else {
booleanFlags.insert(name)
Expand All @@ -243,7 +257,8 @@ public enum DockerCommandParser {
subSubcommand: subSubcommand,
arguments: positionals,
flags: flags,
booleanFlags: booleanFlags
booleanFlags: booleanFlags,
flagValues: flagValues
)
}

Expand Down
44 changes: 38 additions & 6 deletions Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,38 @@ public struct DockerTranslator: Sendable {
try await runCLI(args)
}

/// Compose files, in order: every `-f`, else `COMPOSE_FILE` (colon-separated),
/// else the standard file names in the current directory, plus its override.
static func composeFiles(_ command: DockerCommand) -> [String] {
let explicit = command.values("f") + command.values("file")
if !explicit.isEmpty { return explicit }
if let fromEnv = ProcessInfo.processInfo.environment["COMPOSE_FILE"], !fromEnv.isEmpty {
return fromEnv.split(separator: ":").map(String.init)
}
let fm = FileManager.default
let base = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"]
.first { fm.fileExists(atPath: $0) } ?? "docker-compose.yml"
var files = [base]
let overrides = ["compose.override.yaml", "compose.override.yml",
"docker-compose.override.yaml", "docker-compose.override.yml"]
if let override = overrides.first(where: { fm.fileExists(atPath: $0) }) {
files.append(override)
}
return files
}

/// Active profiles: every `--profile`, plus `COMPOSE_PROFILES` (comma-separated).
static func composeProfiles(_ command: DockerCommand) -> Set<String> {
func names(_ value: String) -> [String] {
value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
}
var active = Set(command.values("profile").flatMap(names))
if let fromEnv = ProcessInfo.processInfo.environment["COMPOSE_PROFILES"] {
active.formUnion(names(fromEnv))
}
return active
}

/// `docker login SERVER -u USER`. The password comes from stdin with
/// `--password-stdin`, or from a hidden prompt. It is never passed as an argument.
private func login(_ command: DockerCommand) async throws {
Expand Down Expand Up @@ -1195,15 +1227,15 @@ public struct DockerTranslator: Sendable {

// `docker compose -f file.yml up -d` — the file may come from `-f`
// (or `--file`) or default to `./docker-compose.yml`.
let file = command.value("f") ?? command.value("file") ?? "docker-compose.yml"
let files = Self.composeFiles(command)
let parser = ComposeParser()
let project = try parser.parse(fileAt: file)
let project = try parser.parse(files: files, profiles: Self.composeProfiles(command))
let orchestrator = ComposeOrchestrator(service: service)

switch command.subSubcommand {
case "up":
let detached = command.has("d") || command.has("detach")
try await orchestrator.up(project: project, filePath: file, detached: detached)
try await orchestrator.up(project: project, filePath: files[0], detached: detached)
if detached {
for name in try ServiceResolver.resolve(project) {
let containerName = ComposeOrchestrator.containerName(
Expand Down Expand Up @@ -1245,9 +1277,9 @@ public struct DockerTranslator: Sendable {
let data = try encoder.encode(project)
print(String(decoding: data, as: UTF8.self))
case "build":
try await orchestrator.build(project: project, filePath: file)
try await orchestrator.build(project: project, filePath: files[0])
case "create":
try await orchestrator.create(project: project, filePath: file)
try await orchestrator.create(project: project, filePath: files[0])
case "exec":
guard let serviceName = command.arguments.first else {
throw DockerShimError.missingArgument("SERVICE COMMAND")
Expand Down Expand Up @@ -1297,7 +1329,7 @@ public struct DockerTranslator: Sendable {
project: project,
serviceName: serviceName,
command: runArgs,
filePath: file
filePath: files[0]
)
if exitCode != 0 {
throw ExitCode(exitCode)
Expand Down
64 changes: 56 additions & 8 deletions Sources/ComposeEngine/ComposeParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,59 @@ public struct ComposeParser: Sendable {
/// Parse a compose file at `path`, resolving relative paths against the
/// file's directory and loading a sibling `.env` file for interpolation.
public func parse(fileAt path: String) throws -> ComposeProject {
let url = URL(fileURLWithPath: path)
let yaml: String
do {
yaml = try String(contentsOf: url, encoding: .utf8)
} catch {
throw ComposeParseError.unreadableFile(path)
try parse(files: [path])
}

/// Parse one or more compose files as a single project, merged in order
/// like `docker compose -f base.yml -f override.yml`. The first file's
/// directory is the base for relative paths and for the `.env` file.
public func parse(files paths: [String], profiles: Set<String>? = nil) throws -> ComposeProject {
guard let first = paths.first else {
throw ComposeParseError.unreadableFile("(no compose file given)")
}
let env = try loadEnvFile(in: url.deletingLastPathComponent())
return try parse(yaml: yaml, env: env, baseDirectory: url.deletingLastPathComponent().path)
let baseDirectory = URL(fileURLWithPath: first).deletingLastPathComponent()
var merged: Any = [String: Any]()
for path in paths {
let yaml: String
do {
yaml = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
} catch {
throw ComposeParseError.unreadableFile(path)
}
do {
merged = Self.merge(merged, try Yams.load(yaml: yaml) ?? [:])
} catch {
throw ComposeParseError.invalidYAML(error.localizedDescription)
}
}
let env = try loadEnvFile(in: baseDirectory)
return try parse(tree: merged, env: env, baseDirectory: baseDirectory.path, profiles: profiles)
}

/// Deep-merges compose trees. Maps merge key by key, `command` and
/// `entrypoint` are replaced, and other lists accumulate without repeats.
static func merge(_ base: Any, _ override: Any) -> Any {
if let base = base as? [String: Any], let override = override as? [String: Any] {
var result = base
for (key, value) in override {
if key == "command" || key == "entrypoint" {
result[key] = value
} else if let existing = result[key] {
result[key] = merge(existing, value)
} else {
result[key] = value
}
}
return result
}
if let base = base as? [Any], let override = override as? [Any] {
var result = base
for item in override where !result.contains(where: { "\($0)" == "\(item)" }) {
result.append(item)
}
return result
}
return override
}

/// Parse a compose YAML string. `env` supplies interpolation variables
Expand All @@ -83,7 +127,11 @@ public struct ComposeParser: Sendable {
} catch {
throw ComposeParseError.invalidYAML(error.localizedDescription)
}
return try parse(tree: tree, env: env, baseDirectory: baseDirectory, profiles: profiles)
}

/// Parse an already-loaded compose tree (one file, or several merged).
func parse(tree: Any, env: [String: String], baseDirectory: String, profiles: Set<String>?) throws -> ComposeProject {
// Shell environment wins over the .env file, matching docker compose.
var variables = env
for (key, value) in ProcessInfo.processInfo.environment {
Expand Down
70 changes: 70 additions & 0 deletions Tests/ComposeEngineTests/ComposeMultiFileTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// ComposeMultiFileTests — merging several compose files and profile filtering.
//===----------------------------------------------------------------------===//

import XCTest
import Yams
@testable import ComposeEngine

final class ComposeMultiFileTests: XCTestCase {
func testOverrideReplacesScalarsAndCommandAndAccumulatesPorts() throws {
let base: Any = try Yams.load(yaml: """
services:
web:
image: nginx:1
command: ["serve", "--old"]
ports: ["8080:80"]
""") ?? [:]
let override: Any = try Yams.load(yaml: """
services:
web:
image: nginx:2
command: ["serve", "--new"]
ports: ["8080:80", "8443:443"]
""") ?? [:]
let merged = ComposeParser.merge(base, override) as? [String: Any]
let web = (merged?["services"] as? [String: Any])?["web"] as? [String: Any]
XCTAssertEqual(web?["image"] as? String, "nginx:2")
XCTAssertEqual(web?["command"] as? [String], ["serve", "--new"])
XCTAssertEqual(web?["ports"] as? [String], ["8080:80", "8443:443"])
}

func testProfilesOnlyEnableMatchingServices() throws {
let yaml = """
services:
app:
image: alpine
debugger:
image: alpine
profiles: ["debug"]
"""
let without = try ComposeParser().parse(yaml: yaml, profiles: [])
XCTAssertEqual(Set(without.services.keys), ["app"])

let withDebug = try ComposeParser().parse(yaml: yaml, profiles: ["debug"])
XCTAssertEqual(Set(withDebug.services.keys), ["app", "debugger"])
}

func testParseFilesMergesInOrder() throws {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("compose-multi-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }

let base = dir.appendingPathComponent("compose.yaml")
let override = dir.appendingPathComponent("compose.override.yaml")
try """
services:
web:
image: nginx:1
""".write(to: base, atomically: true, encoding: .utf8)
try """
services:
web:
image: nginx:2
""".write(to: override, atomically: true, encoding: .utf8)

let project = try ComposeParser().parse(files: [base.path, override.path])
XCTAssertEqual(project.services["web"]?.image, "nginx:2")
}
}
Loading