From 8939d36057fbd69ce84446a5a67efcd2cc10bd60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Wed, 21 Oct 2026 01:04:18 -0400 Subject: [PATCH] feat(compose): multiple compose files and profiles - docker compose -f a.yml -f b.yml: every -f is kept (the parser now records all values of a flag) and the files are merged in order at the YAML tree level. Maps merge key by key, command and entrypoint are replaced, other lists accumulate without repeats. - Without -f: COMPOSE_FILE (colon-separated), else compose.yaml or docker-compose.yml, plus the matching .override file when present. - --profile (repeatable) and COMPOSE_PROFILES enable services that declare profiles; other profiled services are left out. - Parser: DockerCommand.values(_:) returns every occurrence of a flag. --- .../DockerShim/DockerCommand.swift | 29 ++++++-- .../DockerShim/DockerTranslator.swift | 44 ++++++++++-- Sources/ComposeEngine/ComposeParser.swift | 64 ++++++++++++++--- .../ComposeMultiFileTests.swift | 70 +++++++++++++++++++ 4 files changed, 186 insertions(+), 21 deletions(-) create mode 100644 Tests/ComposeEngineTests/ComposeMultiFileTests.swift diff --git a/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift b/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift index 65fef03..f459836 100644 --- a/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift +++ b/Sources/AppleDockerCLI/DockerShim/DockerCommand.swift @@ -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 + /// 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 = [] + booleanFlags: Set = [], + 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. @@ -128,7 +137,12 @@ public enum DockerCommandParser { var subSubcommand: String? var positionals: [String] = [] var flags: [String: String] = [:] + var flagValues: [String: [String]] = [:] var booleanFlags: Set = [] + 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. @@ -159,10 +173,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) @@ -207,11 +221,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) @@ -221,7 +235,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) @@ -237,7 +251,8 @@ public enum DockerCommandParser { subSubcommand: subSubcommand, arguments: positionals, flags: flags, - booleanFlags: booleanFlags + booleanFlags: booleanFlags, + flagValues: flagValues ) } diff --git a/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift b/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift index ffa58ef..999842f 100644 --- a/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift +++ b/Sources/AppleDockerCLI/DockerShim/DockerTranslator.swift @@ -467,6 +467,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 { + 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 prune` — remove all stopped containers. private func prune(_ command: DockerCommand) async throws { let containers = try await service.listContainers(filters: .all) @@ -1126,15 +1158,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( @@ -1174,9 +1206,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") @@ -1226,7 +1258,7 @@ public struct DockerTranslator: Sendable { project: project, serviceName: serviceName, command: runArgs, - filePath: file + filePath: files[0] ) if exitCode != 0 { throw ExitCode(exitCode) diff --git a/Sources/ComposeEngine/ComposeParser.swift b/Sources/ComposeEngine/ComposeParser.swift index a09c191..753cb66 100644 --- a/Sources/ComposeEngine/ComposeParser.swift +++ b/Sources/ComposeEngine/ComposeParser.swift @@ -60,15 +60,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? = 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 @@ -80,7 +124,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?) throws -> ComposeProject { // Shell environment wins over the .env file, matching docker compose. var variables = env for (key, value) in ProcessInfo.processInfo.environment { diff --git a/Tests/ComposeEngineTests/ComposeMultiFileTests.swift b/Tests/ComposeEngineTests/ComposeMultiFileTests.swift new file mode 100644 index 0000000..ff76377 --- /dev/null +++ b/Tests/ComposeEngineTests/ComposeMultiFileTests.swift @@ -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") + } +}