diff --git a/Sources/Mocker/Commands/Build.swift b/Sources/Mocker/Commands/Build.swift index 77c61de..1074bd5 100644 --- a/Sources/Mocker/Commands/Build.swift +++ b/Sources/Mocker/Commands/Build.swift @@ -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 diff --git a/Sources/Mocker/Commands/Compose.swift b/Sources/Mocker/Commands/Compose.swift index e113fdd..d997f6e 100644 --- a/Sources/Mocker/Commands/Compose.swift +++ b/Sources/Mocker/Commands/Compose.swift @@ -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 ) diff --git a/Sources/MockerKit/Compose/ComposeOrchestrator.swift b/Sources/MockerKit/Compose/ComposeOrchestrator.swift index 41c13e8..f51369c 100644 --- a/Sources/MockerKit/Compose/ComposeOrchestrator.swift +++ b/Sources/MockerKit/Compose/ComposeOrchestrator.swift @@ -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 ) diff --git a/Sources/MockerKit/Image/ImageManager.swift b/Sources/MockerKit/Image/ImageManager.swift index b84288a..4717849 100644 --- a/Sources/MockerKit/Image/ImageManager.swift +++ b/Sources/MockerKit/Image/ImageManager.swift @@ -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)") diff --git a/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift b/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift index e4e3c96..a831d57 100644 --- a/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift +++ b/Tests/MockerKitTests/ImageManagerBuildArgsTests.swift @@ -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")) + } +} diff --git a/Tests/MockerTests/CLITests.swift b/Tests/MockerTests/CLITests.swift index 49c80bd..6a47317 100644 --- a/Tests/MockerTests/CLITests.swift +++ b/Tests/MockerTests/CLITests.swift @@ -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) + } }