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
8 changes: 5 additions & 3 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)") }
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
86 changes: 83 additions & 3 deletions Sources/MockerKit/Compose/ComposeFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
)
}
}
Expand All @@ -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] {
Expand All @@ -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..<eq])
let val = String(str[str.index(after: eq)...])
args[key] = val
} else {
args[str] = ProcessInfo.processInfo.environment[str] ?? ""
}
}
}
return args
}

private static func parseDependsOn(_ value: Any?) -> [String] {
if let list = value as? [String] {
return list
Expand All @@ -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 <stage>`).
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
Expand Down
45 changes: 32 additions & 13 deletions Sources/MockerKit/Compose/ComposeOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions Tests/MockerKitTests/ComposeFileTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
80 changes: 80 additions & 0 deletions Tests/MockerKitTests/ComposeOrchestratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
}
Loading