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
2 changes: 1 addition & 1 deletion Sources/Mocker/Commands/Build.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct Build: AsyncParsableCommand {
var tag: String

@Option(name: .shortAndLong, help: "Name of the Dockerfile")
var file: String = "Dockerfile"
var file: String?

@Flag(name: .long, help: "Do not use cache when building")
var noCache = false
Expand Down
8 changes: 6 additions & 2 deletions Sources/Mocker/Commands/Compose.swift
Original file line number Diff line number Diff line change
Expand Up @@ -580,10 +580,14 @@ 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.
let dockerfilePath = ImageManager.composeDockerfilePath(
context: buildConfig.context,
dockerfile: buildConfig.dockerfile ?? "Dockerfile",
cwd: FileManager.default.currentDirectoryPath
)
_ = try await manager.build(
tag: tag, context: buildConfig.context,
dockerfile: buildConfig.dockerfile ?? "Dockerfile",
dockerfile: dockerfilePath,
noCache: noCache, buildArgs: buildConfig.argList + buildArg,
target: buildConfig.target
)
Expand Down
7 changes: 6 additions & 1 deletion Sources/MockerKit/Compose/ComposeOrchestrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,10 +222,15 @@ public actor ComposeOrchestrator {
shouldBuild = !existingImages.contains { ComposeService.imageMatches($0, tag: tag) }
}
if shouldBuild {
let dockerfilePath = ImageManager.composeDockerfilePath(
context: build.context,
dockerfile: build.dockerfile ?? "Dockerfile",
cwd: FileManager.default.currentDirectoryPath
)
_ = try await imageManager.build(
tag: tag,
context: build.context,
dockerfile: build.dockerfile ?? "Dockerfile",
dockerfile: dockerfilePath,
buildArgs: build.argList,
target: build.target
)
Expand Down
39 changes: 29 additions & 10 deletions Sources/MockerKit/Image/ImageManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,20 +241,39 @@ public actor ImageManager {
return args
}

static func resolveContextPath(context: String, cwd: String) -> String {
guard !context.hasPrefix("/") else { return context }
return URL(fileURLWithPath: cwd).appendingPathComponent(context).standardized.path
}

static func resolveDockerfilePath(context: String, dockerfile: String?, cwd: String) -> String {
guard let dockerfile else {
return URL(fileURLWithPath: context).appendingPathComponent("Dockerfile").standardized.path
}
if dockerfile.hasPrefix("/") {
return dockerfile
}
return URL(fileURLWithPath: cwd).appendingPathComponent(dockerfile).standardized.path
}

/// Resolve a Compose service's `build.dockerfile` to an absolute path using Docker Compose semantics:
/// relative `dockerfile` is relative to `build.context`, not the CWD.
public static func composeDockerfilePath(context: String, dockerfile: String, cwd: String) -> String {
let absContext = resolveContextPath(context: context, cwd: cwd)
return resolveDockerfilePath(context: absContext, dockerfile: dockerfile, cwd: absContext)
}

/// Build an image from a Dockerfile using the `container` CLI.
/// - Parameter platforms: pass multiple values to build a multi-arch manifest list (e.g. `["linux/amd64", "linux/arm64"]`).
/// - Parameter builder: optional named builder instance forwarded to `container build --builder`,
/// enabling a remote BuildKit node for exotic architectures (apple/container#1496).
public func build(tag: String, context: String, dockerfile: String = "Dockerfile", noCache: Bool = false, buildArgs: [String] = [], platforms: [String] = [], target: String? = nil, labels: [String] = [], quiet: Bool = false, progress: String? = nil, output: [String] = [], builder: String? = nil) async throws -> ImageInfo {
let contextURL: URL
if context.hasPrefix("/") {
contextURL = URL(fileURLWithPath: context)
} else {
contextURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
.appendingPathComponent(context)
.standardized
}
let dockerfilePath = contextURL.appendingPathComponent(dockerfile).path
public func build(tag: String, context: String, dockerfile: String? = nil, noCache: Bool = false, buildArgs: [String] = [], platforms: [String] = [], target: String? = nil, labels: [String] = [], quiet: Bool = false, progress: String? = nil, output: [String] = [], builder: String? = nil) async throws -> ImageInfo {
let absContext = Self.resolveContextPath(context: context, cwd: FileManager.default.currentDirectoryPath)
let dockerfilePath = Self.resolveDockerfilePath(
context: absContext,
dockerfile: dockerfile,
cwd: FileManager.default.currentDirectoryPath
)

guard FileManager.default.fileExists(atPath: dockerfilePath) else {
throw MockerError.buildError("Dockerfile not found at \(dockerfilePath)")
Expand Down
143 changes: 143 additions & 0 deletions Tests/MockerKitTests/ImageManagerBuildArgsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,146 @@ struct ImageManagerBuildArgsTests {
return false
}
}

@Suite("ImageManager Compose dockerfile path resolver")
struct ImageManagerComposeDockerfilePathTests {

// Regression: context=./app, cwd=/project, dockerfile=default → must be /project/app/Dockerfile, NOT /project/Dockerfile
@Test("default dockerfile resolves relative to context, not CWD (regression guard)")
func composeDefaultDockerfileResolvesRelativeToContext() {
let result = ImageManager.composeDockerfilePath(
context: "./app",
dockerfile: "Dockerfile",
cwd: "/project"
)
#expect(result == "/project/app/Dockerfile")
#expect(result != "/project/Dockerfile")
}

// relative dockerfile resolves relative to context, not CWD
@Test("relative dockerfile resolves relative to context")
func composeRelativeDockerfileResolvesRelativeToContext() {
let result = ImageManager.composeDockerfilePath(
context: "./app",
dockerfile: "Dockerfile.prod",
cwd: "/project"
)
#expect(result == "/project/app/Dockerfile.prod")
}

// absolute dockerfile is returned verbatim regardless of context
@Test("absolute dockerfile is returned verbatim")
func composeAbsoluteDockerfileVerbatim() {
let result = ImageManager.composeDockerfilePath(
context: "./app",
dockerfile: "/custom/path/Dockerfile",
cwd: "/project"
)
#expect(result == "/custom/path/Dockerfile")
}

// context already absolute — still resolves dockerfile relative to it
@Test("absolute context with relative dockerfile resolves correctly")
func composeAbsoluteContextRelativeDockerfile() {
let result = ImageManager.composeDockerfilePath(
context: "/project/app",
dockerfile: "Dockerfile.dev",
cwd: "/project"
)
#expect(result == "/project/app/Dockerfile.dev")
}

// context == CWD — common case, must also be correct
@Test("when context equals CWD relative dockerfile resolves to context/dockerfile")
func composeContextEqualsCWD() {
let result = ImageManager.composeDockerfilePath(
context: ".",
dockerfile: "Dockerfile",
cwd: "/project"
)
#expect(result == "/project/Dockerfile")
}
}

@Suite("ImageManager Dockerfile path resolver")
struct ImageManagerDockerfileResolverTests {

// Scenario 1 (spec): absolute -f is used verbatim — devcontainer regression case
@Test("absolute -f path is returned verbatim")
func resolveAbsoluteUsedVerbatim() {
let result = ImageManager.resolveDockerfilePath(
context: "/var/work/build-1234",
dockerfile: "/var/work/build-1234/Dockerfile.buildContent",
cwd: "/some/unrelated/cwd"
)
#expect(result == "/var/work/build-1234/Dockerfile.buildContent")
}

// Scenario 1 negative assertion: absolute -f must NOT be doubled onto context
@Test("absolute -f is not concatenated onto context")
func resolveAbsoluteNotDoubled() {
let result = ImageManager.resolveDockerfilePath(
context: "/var/work/build-1234",
dockerfile: "/var/work/build-1234/Dockerfile.buildContent",
cwd: "/some/unrelated/cwd"
)
#expect(!result.contains("/var/work/build-1234/var/work/build-1234"))
}

// Scenario 3 (spec): relative -f resolved against CWD, not context
@Test("relative -f resolves against CWD when CWD differs from context")
func resolveRelativeCWDNotContext() {
let result = ImageManager.resolveDockerfilePath(
context: "/srv/builds/ctx",
dockerfile: "dockerfiles/Dockerfile.prod",
cwd: "/home/user/project"
)
#expect(result == "/home/user/project/dockerfiles/Dockerfile.prod")
}

// Scenario 4 (spec): relative -f where CWD equals context — no regression for common case
@Test("relative -f where CWD equals context produces same result as before")
func resolveRelativeCWDEqualsContext() {
let result = ImageManager.resolveDockerfilePath(
context: "/home/user/myapp",
dockerfile: "Dockerfile.dev",
cwd: "/home/user/myapp"
)
#expect(result == "/home/user/myapp/Dockerfile.dev")
}

// Scenario 5 (spec): nil -f uses context root
@Test("nil dockerfile resolves to context/Dockerfile")
func resolveNilUsesContextRoot() {
let result = ImageManager.resolveDockerfilePath(
context: "/srv/builds/ctx",
dockerfile: nil,
cwd: "/some/cwd"
)
#expect(result == "/srv/builds/ctx/Dockerfile")
}

// Scenario 6 (spec): missing abs path — resolver returns correct non-doubled string
@Test("missing absolute path resolves to the verbatim path string")
func resolveMissingAbsolutePathString() {
let result = ImageManager.resolveDockerfilePath(
context: "/any/context",
dockerfile: "/nonexistent/path/Dockerfile",
cwd: "/any/cwd"
)
#expect(result == "/nonexistent/path/Dockerfile")
#expect(!result.contains("/any/context/nonexistent"))
}

// Scenario 7 (spec): missing default path — nil + context → context/Dockerfile, not doubled
@Test("missing default dockerfile resolves to context/Dockerfile without doubling")
func resolveMissingDefaultPathString() {
let result = ImageManager.resolveDockerfilePath(
context: "/some/context",
dockerfile: nil,
cwd: "/any/cwd"
)
#expect(result == "/some/context/Dockerfile")
#expect(!result.contains("/some/context/some/context"))
}
}
12 changes: 12 additions & 0 deletions Tests/MockerTests/CLITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,4 +210,16 @@ struct CLITests {
let byName = try ContainerLs.parse(["--filter", "name=db"]).options
#expect(byName.filtered(containers).map(\.id) == ["b"])
}

@Test("Build -f flag parses to the provided path")
func buildFileOptionParsesPath() throws {
let command = try Build.parse(["-f", "/abs/path/Dockerfile", "-t", "x:1", "."])
#expect(command.file == "/abs/path/Dockerfile")
}

@Test("Build -f flag defaults to nil when omitted")
func buildFileOptionDefaultsNil() throws {
let command = try Build.parse(["-t", "x:1", "."])
#expect(command.file == nil)
}
}
Loading