From a94fbc1d6d6623f19fb13bc6fb1464ca523488be Mon Sep 17 00:00:00 2001 From: us Date: Thu, 11 Jun 2026 14:30:56 +0300 Subject: [PATCH] fix(compose): forward build.target/args and honor image+build (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `compose up --build` ignored `build.target` and `build.args` because the compose parser never read those fields, so the orchestrator could not pass them to `container build` — the whole Dockerfile was built instead of stopping at the target stage. Parse both (args supports map and list forms, preserving explicit empty values) and forward them from both `compose up` and `compose build`. A service declaring both `image:` and `build:` previously pulled the `image:` reference from a registry (401) instead of building. Per the Compose spec it must build from context and tag the result with the `image:` name; pull only when there is no build config. The decision is extracted into a pure `ComposeService.resolveImageSource` for testing. Also plumb `--build`/`--no-build` from the CLI into the orchestrator so `--build` forces a rebuild instead of being silently ignored. --- Sources/Mocker/Commands/Compose.swift | 8 +- Sources/MockerKit/Compose/ComposeFile.swift | 86 ++++++++++++++++++- .../Compose/ComposeOrchestrator.swift | 45 +++++++--- Tests/MockerKitTests/ComposeFileTests.swift | 68 +++++++++++++++ .../ComposeOrchestratorTests.swift | 80 +++++++++++++++++ 5 files changed, 268 insertions(+), 19 deletions(-) diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index 37d9d89..c6979aa 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -224,7 +224,7 @@ struct ComposeUp: AsyncParsableCommand { ) let totalResources = composeFile.networks.count + composeFile.volumes.count + composeFile.services.count - let events = try await orchestrator.up(composeFile: composeFile, detach: detach) + let events = try await orchestrator.up(composeFile: composeFile, detach: detach, build: build, noBuild: noBuild) ComposeFormatter.printEvents(events, total: totalResources) } } @@ -571,10 +571,12 @@ struct ComposeBuildCommand: AsyncParsableCommand { guard let buildConfig = service.build else { continue } let tag = service.image ?? "\(name):latest" if !quiet { print("Building \(name)...") } + // Compose-file `build.args` first, then CLI `--build-arg` so CLI wins on conflict. _ = try await manager.build( tag: tag, context: buildConfig.context, dockerfile: buildConfig.dockerfile ?? "Dockerfile", - noCache: noCache, buildArgs: buildArg + noCache: noCache, buildArgs: buildConfig.argList + buildArg, + target: buildConfig.target ) if !quiet { print("Successfully built \(name)") } } @@ -1203,7 +1205,7 @@ struct ComposeCreate: AsyncParsableCommand { volumeManager: volumeManager ) - let events = try await orchestrator.up(composeFile: composeFile, detach: true) + let events = try await orchestrator.up(composeFile: composeFile, detach: true, build: build, noBuild: noBuild) ComposeFormatter.printEvents(events, total: events.count) } } diff --git a/Sources/MockerKit/Compose/ComposeFile.swift b/Sources/MockerKit/Compose/ComposeFile.swift index 6065dbd..8227320 100644 --- a/Sources/MockerKit/Compose/ComposeFile.swift +++ b/Sources/MockerKit/Compose/ComposeFile.swift @@ -216,7 +216,9 @@ public struct ComposeService: Sendable { } else if let buildDict = buildVal as? [String: Any] { build = ComposeBuild( context: buildDict["context"] as? String ?? ".", - dockerfile: buildDict["dockerfile"] as? String + dockerfile: buildDict["dockerfile"] as? String, + target: buildDict["target"] as? String, + args: parseBuildArgs(buildDict["args"]) ) } } @@ -238,6 +240,35 @@ public struct ComposeService: Sendable { ) } + /// Default tag for an image built from this service's `build` config. + public func buildTag(projectName: String) -> String { + image ?? "\(projectName)-\(name):latest" + } + + /// Decide how this service's image should be obtained, per the Compose spec. + /// + /// When `build:` is present (and not disabled via `--no-build`) the image is + /// built from the Dockerfile context and tagged with `image:` if specified — + /// it is never pulled. Only when there is no buildable config does `image:` + /// trigger a registry pull. This is a pure function so it can be unit-tested + /// without the container runtime. + public func resolveImageSource(projectName: String, noBuild: Bool = false) -> ComposeImageSource { + if let build, !noBuild { + return .build(tag: buildTag(projectName: projectName), build: build) + } else if let image { + return .pull(image: image) + } else { + return .none + } + } + + /// Whether an existing image matches `tag` (repository suffix + tag), used to + /// skip rebuilds when `--build` was not requested. + public static func imageMatches(_ info: ImageInfo, tag: String) -> Bool { + guard let ref = try? ImageReference.parse(tag) else { return false } + return info.repository.hasSuffix(ref.repository) && info.tag == ref.tag + } + private static func parseEnvironment(_ value: Any?) -> [String: String] { var env: [String: String] = [:] if let dict = value as? [String: Any] { @@ -253,6 +284,29 @@ public struct ComposeService: Sendable { return env } + /// Parse `build.args`, accepting both the map form (`KEY: value`) and the + /// list form (`- KEY=value`). A bare `KEY` (no `=`) inherits the value from + /// the host environment, matching Docker Compose semantics. An explicit empty + /// value (`KEY=`) is preserved as an empty string rather than dropped. + static func parseBuildArgs(_ value: Any?) -> [String: String] { + var args: [String: String] = [:] + if let dict = value as? [String: Any] { + for (k, v) in dict { args[k] = "\(v)" } + } else if let list = value as? [Any] { + for item in list { + let str = "\(item)" + if let eq = str.firstIndex(of: "=") { + let key = String(str[str.startIndex.. [String] { if let list = value as? [String] { return list @@ -275,16 +329,42 @@ public struct ComposeService: Sendable { } /// Build configuration for a compose service. -public struct ComposeBuild: Sendable { +public struct ComposeBuild: Sendable, Equatable { public var context: String public var dockerfile: String? + /// Target stage to build (maps to `container build --target `). + public var target: String? + /// Build-time ARG values declared under `build.args` in the compose file. + public var args: [String: String] - public init(context: String, dockerfile: String? = nil) { + public init( + context: String, + dockerfile: String? = nil, + target: String? = nil, + args: [String: String] = [:] + ) { self.context = context self.dockerfile = dockerfile + self.target = target + self.args = args + } + + /// Build args formatted as `KEY=VALUE` strings for the `container build --build-arg` flag. + public var argList: [String] { + args.map { "\($0.key)=\($0.value)" } } } +/// How a service's image should be obtained during `compose up`. +public enum ComposeImageSource: Sendable, Equatable { + /// Pull `image` from a registry. + case pull(image: String) + /// Build from the Dockerfile context and tag the result with `tag`. + case build(tag: String, build: ComposeBuild) + /// Nothing to do (no image and no build config). + case none +} + /// Network definition in a compose file. public struct ComposeNetwork: Sendable { public var name: String diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 0768e4e..41c13e8 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -34,7 +34,15 @@ public actor ComposeOrchestrator { } /// Start all services defined in a compose file. - public func up(composeFile: ComposeFile, detach: Bool = false) async throws -> [ComposeEvent] { + /// - Parameters: + /// - build: force-build images even if they already exist (compose `--build`). + /// - noBuild: never build, pull `image:` instead (compose `--no-build`). + public func up( + composeFile: ComposeFile, + detach: Bool = false, + build: Bool = false, + noBuild: Bool = false + ) async throws -> [ComposeEvent] { var events: [ComposeEvent] = [] // Create networks @@ -59,7 +67,7 @@ public actor ComposeOrchestrator { for serviceName in order { guard let service = composeFile.services[serviceName] else { continue } - let info = try await startService(service, detach: detach) + let info = try await startService(service, detach: detach, forceBuild: build, noBuild: noBuild) let containerName = "\(projectName)-\(service.name)-1" startedContainers.append((serviceName: serviceName, info: info)) events.append(.containerStarted(containerName)) @@ -193,26 +201,37 @@ public actor ComposeOrchestrator { } } - private func startService(_ service: ComposeService, detach: Bool) async throws -> ContainerInfo { + private func startService( + _ service: ComposeService, + detach: Bool, + forceBuild: Bool = false, + noBuild: Bool = false + ) async throws -> ContainerInfo { let containerName = "\(projectName)-\(service.name)-1" - // Pull or build image - if let image = service.image { + // Decide whether to build or pull. Per the Compose spec, a service with + // both `image:` and `build:` is built and tagged with `image:` — not pulled. + switch service.resolveImageSource(projectName: projectName, noBuild: noBuild) { + case .pull(let image): _ = try await imageManager.pull(image) - } else if let build = service.build { - let tag = "\(projectName)-\(service.name):latest" - // Only build if image doesn't already exist (like `docker compose up` without --build) - let existingImages = try await imageManager.list() - let imageExists = existingImages.contains { img in - img.tag == "latest" && img.repository.hasSuffix("\(projectName)-\(service.name)") + case .build(let tag, let build): + // Skip the rebuild only when `--build` wasn't requested and the image exists. + var shouldBuild = forceBuild + if !shouldBuild { + let existingImages = try await imageManager.list() + shouldBuild = !existingImages.contains { ComposeService.imageMatches($0, tag: tag) } } - if !imageExists { + if shouldBuild { _ = try await imageManager.build( tag: tag, context: build.context, - dockerfile: build.dockerfile ?? "Dockerfile" + dockerfile: build.dockerfile ?? "Dockerfile", + buildArgs: build.argList, + target: build.target ) } + case .none: + break } let imageName = service.image ?? "\(projectName)-\(service.name):latest" diff --git a/Tests/MockerKitTests/ComposeFileTests.swift b/Tests/MockerKitTests/ComposeFileTests.swift index 8014ae1..f58b001 100644 --- a/Tests/MockerKitTests/ComposeFileTests.swift +++ b/Tests/MockerKitTests/ComposeFileTests.swift @@ -136,6 +136,74 @@ struct ComposeFileTests { #expect(compose.services["app"]?.build?.dockerfile == "Dockerfile.dev") } + @Test("Parse build.target (issue #14)") + func parseBuildTarget() throws { + let yaml = """ + services: + app: + build: + context: . + target: base + """ + + let compose = try ComposeFile.parse(yaml) + #expect(compose.services["app"]?.build?.target == "base") + } + + @Test("Parse build.args map form") + func parseBuildArgsMap() throws { + let yaml = """ + services: + app: + build: + context: . + args: + REQUIRED_TOKEN: secret + BUILD_ENV: prod + """ + + let compose = try ComposeFile.parse(yaml) + let args = compose.services["app"]?.build?.args ?? [:] + #expect(args["REQUIRED_TOKEN"] == "secret") + #expect(args["BUILD_ENV"] == "prod") + } + + @Test("Parse build.args list form, preserving explicit empty value") + func parseBuildArgsList() throws { + // After variable substitution, `${REQUIRED_TOKEN-}` resolves to an empty + // value — the key must still be present with an empty string, not dropped. + let yaml = """ + services: + app: + build: + context: . + args: + - BUILD_ENV=prod + - REQUIRED_TOKEN= + """ + + let compose = try ComposeFile.parse(yaml) + let args = compose.services["app"]?.build?.args ?? [:] + #expect(args["BUILD_ENV"] == "prod") + #expect(args["REQUIRED_TOKEN"] == "") + } + + @Test("Parse service with both image and build (issue #14 comment)") + func parseImageAndBuild() throws { + let yaml = """ + services: + app: + image: repro-app + build: + context: . + target: base + """ + + let compose = try ComposeFile.parse(yaml) + #expect(compose.services["app"]?.image == "repro-app") + #expect(compose.services["app"]?.build?.target == "base") + } + @Test("findDefault returns nil when no compose file exists in empty directory") func findDefaultNoFile() throws { let dir = try makeTempDir() diff --git a/Tests/MockerKitTests/ComposeOrchestratorTests.swift b/Tests/MockerKitTests/ComposeOrchestratorTests.swift index 7740edb..5a2f2c4 100644 --- a/Tests/MockerKitTests/ComposeOrchestratorTests.swift +++ b/Tests/MockerKitTests/ComposeOrchestratorTests.swift @@ -69,4 +69,84 @@ struct ComposeOrchestratorTests { #expect(filtered.services["db"] != nil) #expect(filtered.services["api"] == nil) } + + // MARK: - Image source resolution (issue #14) + + @Test("image only resolves to pull") + func resolveImageOnly() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: nginx:latest + """).services["app"]! + #expect(svc.resolveImageSource(projectName: "proj") == .pull(image: "nginx:latest")) + } + + @Test("build only resolves to build with synthesized tag") + func resolveBuildOnly() throws { + let svc = try ComposeFile.parse(""" + services: + app: + build: + context: . + target: base + """).services["app"]! + let source = svc.resolveImageSource(projectName: "proj") + guard case .build(let tag, let build) = source else { + Issue.record("expected .build, got \(source)") + return + } + #expect(tag == "proj-app:latest") + #expect(build.target == "base") + } + + @Test("image + build resolves to build, tagged with image name (issue #14)") + func resolveImageAndBuild() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: repro-app + build: + context: . + target: base + """).services["app"]! + let source = svc.resolveImageSource(projectName: "proj") + guard case .build(let tag, _) = source else { + Issue.record("expected .build (not pull) when image + build are both set; got \(source)") + return + } + // image: is used as the tag, NOT pulled from a registry. + #expect(tag == "repro-app") + } + + @Test("image + build with --no-build falls back to pull") + func resolveImageAndBuildNoBuild() throws { + let svc = try ComposeFile.parse(""" + services: + app: + image: repro-app + build: + context: . + """).services["app"]! + #expect(svc.resolveImageSource(projectName: "proj", noBuild: true) == .pull(image: "repro-app")) + } + + @Test("empty service resolves to none") + func resolveNone() throws { + let svc = ComposeService( + name: "app", image: nil, build: nil, command: [], environment: [:], + ports: [], volumes: [], networks: [], dependsOn: [], restart: nil, + labels: [:], hostname: nil, workingDir: nil + ) + #expect(svc.resolveImageSource(projectName: "proj") == .none) + } + + @Test("imageMatches compares repository suffix and tag") + func imageMatching() { + let img = ImageInfo(id: "abc", repository: "proj-app", tag: "latest") + #expect(ComposeService.imageMatches(img, tag: "proj-app:latest")) + #expect(ComposeService.imageMatches(img, tag: "proj-app")) // implicit :latest + #expect(!ComposeService.imageMatches(img, tag: "proj-app:v2")) + #expect(!ComposeService.imageMatches(img, tag: "other-app:latest")) + } }