From 55a25b2e9bf6a949eea85cdd970a9a89db11d328 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Wed, 25 Feb 2026 23:16:16 -0800 Subject: [PATCH 01/10] Send dockerfile path to container-builder-shime Dockerfile path is used to find specific dockerignore file. --- Sources/ContainerBuild/Builder.swift | 6 ++++ Sources/ContainerCommands/BuildCommand.swift | 36 +++++++++++--------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index e069e39d0..28ea6413d 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -240,6 +240,7 @@ public struct Builder: Sendable { public let contentStore: ContentStore public let buildArgs: [String] public let contextDir: String + public let dockerfilePath: String? public let dockerfile: Data public let labels: [String] public let noCache: Bool @@ -258,6 +259,7 @@ public struct Builder: Sendable { contentStore: ContentStore, buildArgs: [String], contextDir: String, + dockerfilePath: String?, dockerfile: Data, labels: [String], noCache: Bool, @@ -275,6 +277,7 @@ public struct Builder: Sendable { self.contentStore = contentStore self.buildArgs = buildArgs self.contextDir = contextDir + self.dockerfilePath = dockerfilePath self.dockerfile = dockerfile self.labels = labels self.noCache = noCache @@ -319,6 +322,9 @@ extension CallOptions { ("progress", config.terminal != nil ? "tty" : "plain"), ("target", config.target), ] + if let dockerfilePath = config.dockerfilePath { + headers.append(("dockerfile-path", dockerfilePath)) + } for tag in config.tags { headers.append(("tag", tag)) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 6e9f73874..45cccada6 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -204,24 +204,10 @@ extension Application { throw ValidationError("builder is not running") } - let buildFilePath: String - if let file = self.file { - buildFilePath = file - } else { - guard - let resolvedPath = try BuildFile.resolvePath( - contextDir: self.contextDir, - log: log - ) - else { - throw ValidationError("failed to find Dockerfile or Containerfile in the context directory \(self.contextDir)") - } - buildFilePath = resolvedPath - } - + let buildFilePath: String? let buildFileData: Data // Dockerfile should be read from stdin - if file == "-" { + if let file = self.file, file == "-" { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: tempFile) @@ -242,9 +228,24 @@ extension Application { fileHandle.write(chunk) } try fileHandle.close() + buildFilePath = nil buildFileData = try Data(contentsOf: URL(filePath: tempFile.path())) } else { - buildFileData = try Data(contentsOf: URL(filePath: buildFilePath)) + let path = try file ?? BuildFile.resolvePath(contextDir: self.contextDir, log: log) + + guard let path else { + throw ValidationError("failed to find Dockerfile or Containerfile in the context directory \(self.contextDir)") + } + + let absolutePath = URL(filePath: path).path + let contextPath = URL(filePath: contextDir).path + "/" + + guard absolutePath.starts(with: contextPath) else { + throw ValidationError("Build file is not under the context directory \(absolutePath)") + } + + buildFilePath = String(absolutePath.dropFirst(contextPath.count)) + buildFileData = try Data(contentsOf: URL(filePath: path)) } let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) @@ -322,6 +323,7 @@ extension Application { contentStore: RemoteContentStoreClient(), buildArgs: buildArg, contextDir: contextDir, + dockerfilePath: buildFilePath, dockerfile: buildFileData, labels: label, noCache: noCache, From dea1bf792da6a33a359c0c3f0a0140c69737def5 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Thu, 26 Feb 2026 09:21:52 -0800 Subject: [PATCH 02/10] Warn using Dockerfile outside context directory --- Sources/ContainerCommands/BuildCommand.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 45cccada6..76f3bbe6e 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -240,11 +240,13 @@ extension Application { let absolutePath = URL(filePath: path).path let contextPath = URL(filePath: contextDir).path + "/" - guard absolutePath.starts(with: contextPath) else { - throw ValidationError("Build file is not under the context directory \(absolutePath)") + if absolutePath.starts(with: contextPath) { + buildFilePath = String(absolutePath.dropFirst(contextPath.count)) + } else { + print("Build file is out of context, docker specific ignore doesn't work") + buildFilePath = nil } - buildFilePath = String(absolutePath.dropFirst(contextPath.count)) buildFileData = try Data(contentsOf: URL(filePath: path)) } From 492fc4b295f0c0b89291ae6669abb975c93a3242 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Thu, 26 Feb 2026 09:23:23 -0800 Subject: [PATCH 03/10] Add docker ignore tests --- .../Subcommands/Build/CLIBuilderTest.swift | 319 +++++++++++++++++- 1 file changed, 310 insertions(+), 9 deletions(-) diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index 9060201a8..332e96e10 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -365,7 +365,7 @@ extension TestCLIBuildBase { } @Test func testBuildDifferentPaths() throws { - let dockerfileCtxDir: URL = try createTempDir() + let buildContextDir: URL = try createTempDir() let dockerfile: String = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -375,22 +375,17 @@ extension TestCLIBuildBase { RUN cat /root/Test/test.txt """ - let dockerfileCtx: [FileSystemEntry] = [ + let buildContext: [FileSystemEntry] = [ .directory(".git"), .file(".git/FETCH", content: .zeroFilled(size: 1)), - ] - try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx) - - let buildContextDir: URL = try createTempDir() - let buildContext: [FileSystemEntry] = [ .directory("Test"), .file("Test/test.txt", content: .zeroFilled(size: 1)), ] - try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext) + try createContext(tempDir: buildContextDir, dockerfile: dockerfile, context: buildContext) let imageName = "registry.local/build-diff-context:\(UUID().uuidString)" #expect(throws: Never.self) { - try self.buildWithPaths(tags: [imageName], tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir) + try self.build(tags: [imageName], tempDir: buildContextDir) } #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } @@ -751,6 +746,312 @@ extension TestCLIBuildBase { #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") } + // Test 1: Basic .dockerignore + @Test func testDockerIgnoreBasic() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("included.txt", content: .data("This file should be included in the build context.\n".data(using: .utf8)!)), + .file("ignored.txt", content: .data("This file should be ignored by .dockerignore.\n".data(using: .utf8)!)), + .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") + let imageName = "registry.local/dockerignore-basic:\(UUID().uuidString)" + let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-basic-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) + #expect(includedResult.status == 0, "included.txt should be present in the image") + + let ignoredResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/ignored.txt"]) + #expect(ignoredResult.status != 0, "ignored.txt should NOT be present in the image") + } + + // Test 2: Dockerfile-specific ignore file (Dockerfile.dockerignore takes precedence over .dockerignore) + @Test func testDockerIgnoreDockerfileSpecific() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. + // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. + // Dockerfile and its .dockerignore must be co-located; here both live in the context root. + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") + let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" + let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-specific-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) + #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") + + let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) + #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") + } + + // Test 5: Build succeeds when Dockerfile is listed in .dockerignore + @Test func testDockerIgnoreIgnoredDockerfile() async throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // Dockerfile is listed in .dockerignore but build must still succeed. + // Dockerfile lives in the context root so the ignore rule applies to it. + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), + .file("test.txt", content: .data("This file should be included even though Dockerfile is ignored.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let dockerfilePath = contextDir.appendingPathComponent("Dockerfile") + let imageName = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" + let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-ignored-dockerfile" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let dockerfileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/Dockerfile"]) + #expect(dockerfileResult.status != 0, "Dockerfile should NOT be present in the image") + + let dockerignoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/.dockerignore"]) + #expect(dockerignoreResult.status != 0, ".dockerignore should NOT be present in the image") + + let testFileResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/test.txt"]) + #expect(testFileResult.status == 0, "test.txt should be present in the image") + } + + // Test 8: Dockerfile in nested subdirectory; Dockerfile.dockerignore next to it takes precedence over root .dockerignore + @Test func testDockerIgnoreSubdirDockerfile() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // Root .dockerignore ignores included.txt; nested Dockerfile.dockerignore ignores secret.txt + // When Dockerfile is in nested/project/, Dockerfile.dockerignore next to it takes precedence + let context: [FileSystemEntry] = [ + .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), + .file("included.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence).\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), + .file("nested/secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), + .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n".data(using: .utf8)!)), + .file("nested/project/config.txt", content: .data("This config file should be included.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let nestedDockerfile = contextDir.appendingPathComponent("nested/project/Dockerfile") + let imageName = "registry.local/dockerignore-subdir:\(UUID().uuidString)" + let args = ["build", "-f", nestedDockerfile.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-subdir-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) + #expect(includedResult.status == 0, "included.txt should be present (Dockerfile.dockerignore takes precedence over .dockerignore)") + + let secretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/secret.txt"]) + #expect(secretResult.status != 0, "secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") + + let nestedSecretResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/secret.txt"]) + #expect(nestedSecretResult.status != 0, "nested/secret.txt should NOT be present (ignored by Dockerfile.dockerignore)") + + let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.txt"]) + #expect(configResult.status == 0, "nested/project/config.txt should be present") + } + + // Test 9: Custom-named Dockerfile (app1.Dockerfile) uses app1.Dockerfile.dockerignore + @Test func testDockerIgnoreCustomDockerfileName() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // .dockerignore ignores generic.txt; app1.Dockerfile.dockerignore ignores app1-specific.txt + // When building with -f app1.Dockerfile, app1.Dockerfile.dockerignore takes precedence + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), + .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), + .file("app1-specific.txt", content: .data("This file should be ignored by app1.Dockerfile.dockerignore.\n".data(using: .utf8)!)), + .file("generic.txt", content: .data("This file should be included (only in .dockerignore, not app1.Dockerfile.dockerignore).\n".data(using: .utf8)!)), + .file("included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: "", context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let customDockerfile = contextDir.appendingPathComponent("app1.Dockerfile") + let imageName = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" + let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-custom-name-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let app1SpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app1-specific.txt"]) + #expect(app1SpecificResult.status != 0, "app1-specific.txt should NOT be present (ignored by app1.Dockerfile.dockerignore)") + + let genericResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/generic.txt"]) + #expect(genericResult.status == 0, "generic.txt should be present (only in .dockerignore, not app1.Dockerfile.dockerignore)") + + let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) + #expect(includedResult.status == 0, "included.txt should be present") + } + + // Test 10: Custom-named Dockerfile in subdirectory uses its co-located .dockerignore + @Test func testDockerIgnoreCustomNameSubdir() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // Root .dockerignore ignores from-root-ignore.txt + // nested/project/app2.Dockerfile.dockerignore ignores from-app2-ignore.txt + // When building with -f nested/project/app2.Dockerfile, the nested ignore takes precedence + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), + .file("from-root-ignore.txt", content: .data("This file should be included (only in .dockerignore, not app2.Dockerfile.dockerignore).\n".data(using: .utf8)!)), + .file("from-app2-ignore.txt", content: .data("This file should be ignored by app2.Dockerfile.dockerignore.\n".data(using: .utf8)!)), + .file("always-included.txt", content: .data("This file should always be included.\n".data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), + .file("nested/project/config.yaml", content: .data("Config file in project directory.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: "", context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let customDockerfile = contextDir.appendingPathComponent("nested/project/app2.Dockerfile") + let imageName = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" + let args = ["build", "-f", customDockerfile.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-custom-subdir-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let app2IgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-app2-ignore.txt"]) + #expect(app2IgnoreResult.status != 0, "from-app2-ignore.txt should NOT be present (ignored by app2.Dockerfile.dockerignore)") + + let rootIgnoreResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/from-root-ignore.txt"]) + #expect(rootIgnoreResult.status == 0, "from-root-ignore.txt should be present (only in .dockerignore, not app2.Dockerfile.dockerignore)") + + let alwaysIncludedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/always-included.txt"]) + #expect(alwaysIncludedResult.status == 0, "always-included.txt should be present") + + let configResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/nested/project/config.yaml"]) + #expect(configResult.status == 0, "nested/project/config.yaml should be present") + } + + // Test 11: app.Dockerfile coexists with Dockerfile; app.Dockerfile.dockerignore is used, not Dockerfile.dockerignore + @Test func testDockerIgnoreCoexistingDockerfiles() throws { + let tempDir: URL = try createTempDir() + let appDockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + let context: [FileSystemEntry] = [ + .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), + .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), + .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), + .file( + "dockerfile-specific.txt", content: .data("This file should NOT be copied when using Dockerfile, but SHOULD when using app.Dockerfile.\n".data(using: .utf8)!)), + .file("app-specific.txt", content: .data("This file should NOT be copied (ignored by app.Dockerfile.dockerignore).\n".data(using: .utf8)!)), + .file("included.txt", content: .data("This file should be copied.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: "", context: context) + + let contextDir = tempDir.appendingPathComponent("context") + let appDockerfilePath = contextDir.appendingPathComponent("app.Dockerfile") + let imageName = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" + let args = ["build", "-f", appDockerfilePath.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-coexisting-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let appSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/app-specific.txt"]) + #expect(appSpecificResult.status != 0, "app-specific.txt should NOT be present (ignored by app.Dockerfile.dockerignore)") + + let dockerfileSpecificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/dockerfile-specific.txt"]) + #expect(dockerfileSpecificResult.status == 0, "dockerfile-specific.txt should be present (Dockerfile.dockerignore was not used)") + + let includedResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/included.txt"]) + #expect(includedResult.status == 0, "included.txt should be present") + } + @Test func testBuildNoCachePullLatestImage() throws { let tempDir: URL = try createTempDir() let dockerfile = From 7758757b8f35678b01d3f7a23507633e2f7bc245 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Thu, 26 Feb 2026 16:54:40 -0800 Subject: [PATCH 04/10] Support docker specific ignore outside context --- Sources/ContainerBuild/Builder.swift | 6 ++ Sources/ContainerCommands/BuildCommand.swift | 22 +++--- .../Subcommands/Build/CLIBuilderTest.swift | 67 ++++++++++++++++--- 3 files changed, 74 insertions(+), 21 deletions(-) diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 28ea6413d..52d09dcba 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -242,6 +242,7 @@ public struct Builder: Sendable { public let contextDir: String public let dockerfilePath: String? public let dockerfile: Data + public let dockerignore: Data? public let labels: [String] public let noCache: Bool public let platforms: [Platform] @@ -261,6 +262,7 @@ public struct Builder: Sendable { contextDir: String, dockerfilePath: String?, dockerfile: Data, + dockerignore: Data?, labels: [String], noCache: Bool, platforms: [Platform], @@ -279,6 +281,7 @@ public struct Builder: Sendable { self.contextDir = contextDir self.dockerfilePath = dockerfilePath self.dockerfile = dockerfile + self.dockerignore = dockerignore self.labels = labels self.noCache = noCache self.platforms = platforms @@ -325,6 +328,9 @@ extension CallOptions { if let dockerfilePath = config.dockerfilePath { headers.append(("dockerfile-path", dockerfilePath)) } + if let dockerignore = config.dockerignore { + headers.append(("dockerignore", dockerignore.base64EncodedString())) + } for tag in config.tags { headers.append(("tag", tag)) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 76f3bbe6e..12a12457b 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -204,8 +204,9 @@ extension Application { throw ValidationError("builder is not running") } - let buildFilePath: String? let buildFileData: Data + var buildFilePath: String? = nil + var ignoreFileData: Data? = nil // Dockerfile should be read from stdin if let file = self.file, file == "-" { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)") @@ -228,7 +229,6 @@ extension Application { fileHandle.write(chunk) } try fileHandle.close() - buildFilePath = nil buildFileData = try Data(contentsOf: URL(filePath: tempFile.path())) } else { let path = try file ?? BuildFile.resolvePath(contextDir: self.contextDir, log: log) @@ -237,14 +237,15 @@ extension Application { throw ValidationError("failed to find Dockerfile or Containerfile in the context directory \(self.contextDir)") } - let absolutePath = URL(filePath: path).path - let contextPath = URL(filePath: contextDir).path + "/" + let ignoreFileURL = URL(filePath: path + ".dockerignore") + ignoreFileData = try? Data(contentsOf: ignoreFileURL) - if absolutePath.starts(with: contextPath) { - buildFilePath = String(absolutePath.dropFirst(contextPath.count)) - } else { - print("Build file is out of context, docker specific ignore doesn't work") - buildFilePath = nil + if ignoreFileData != nil { + let hiddenDirName = ".\(UUID().uuidString)" + let buildFileName = URL(filePath: path).lastPathComponent + + buildFilePath = "\(hiddenDirName)/\(buildFileName)" + ignoreFileData?.append("\n\(hiddenDirName)".data(using: .utf8) ?? Data()) } buildFileData = try Data(contentsOf: URL(filePath: path)) @@ -319,7 +320,7 @@ extension Application { } return results }() - group.addTask { [terminal, buildArg, contextDir, label, noCache, target, quiet, cacheIn, cacheOut, pull] in + group.addTask { [terminal, buildArg, contextDir, buildFilePath, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), @@ -327,6 +328,7 @@ extension Application { contextDir: contextDir, dockerfilePath: buildFilePath, dockerfile: buildFileData, + dockerignore: ignoreFileData, labels: label, noCache: noCache, platforms: [Platform](platforms), diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index 332e96e10..6e80349eb 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -136,7 +136,7 @@ extension TestCLIBuildBase { ADD . . - RUN cat emptyFile + RUN cat emptyFile RUN cat Test/testempty """ let context: [FileSystemEntry] = [ @@ -154,8 +154,8 @@ extension TestCLIBuildBase { let tempDir: URL = try createTempDir() let dockerfile: String = """ - ARG TAG=unknown - FROM ghcr.io/linuxcontainers/alpine:${TAG} + ARG TAG=unknown + FROM ghcr.io/linuxcontainers/alpine:${TAG} """ try createContext(tempDir: tempDir, dockerfile: dockerfile) let imageName: String = "registry.local/build-arg:\(UUID().uuidString)" @@ -191,7 +191,7 @@ extension TestCLIBuildBase { ARG TAG=3.20 FROM ghcr.io/linuxcontainers/alpine:${TAG} - # stage 2 RUN + # stage 2 RUN FROM ghcr.io/linuxcontainers/alpine:3.20 RUN echo "Hello, World!" > /hello.txt @@ -199,8 +199,8 @@ extension TestCLIBuildBase { FROM ghcr.io/linuxcontainers/alpine:3.20 RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] - # stage 4 - CMD - FROM ghcr.io/linuxcontainers/alpine:3.20 + # stage 4 - CMD + FROM ghcr.io/linuxcontainers/alpine:3.20 CMD ["echo", "Exec default"] # stage 5 - CMD [] @@ -291,9 +291,9 @@ extension TestCLIBuildBase { ADD Test1Source Test1Source ADD Test1Source2 Test1Source2 - RUN cat Test1Source2/test.yaml + RUN cat Test1Source2/test.yaml - # Test2: Test symlinks in nested directories + # Test2: Test symlinks in nested directories FROM ghcr.io/linuxcontainers/alpine:3.20 ADD Test2Source Test2Source @@ -301,7 +301,7 @@ extension TestCLIBuildBase { RUN cat Test2Source2/Test/test.txt - # Test 3: Test symlinks to directories work + # Test 3: Test symlinks to directories work FROM ghcr.io/linuxcontainers/alpine:3.20 ADD Test3Source Test3Source @@ -398,7 +398,7 @@ extension TestCLIBuildBase { ADD . . - RUN cat emptyFile + RUN cat emptyFile RUN cat Test/testempty """ let context: [FileSystemEntry] = [ @@ -822,6 +822,51 @@ extension TestCLIBuildBase { let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") + + let listResult = try run(arguments: ["exec", containerName, "ls", "-a"]) + let listFiles = listResult.output.components(separatedBy: "\n").filter { !$0.isEmpty && $0 != "." && $0 != ".." } + #expect(Set(listFiles) == Set(["Dockerfile", ".dockerignore", "Dockerfile.dockerignore", "general.txt"]), "temporary directory must not be detected") + } + + @Test func testDockerIgnoreOutsideContext() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + COPY . . + """ + // .dockerignore ignores general.txt; Dockerfile.dockerignore ignores specific.txt. + // When both exist, Dockerfile.dockerignore takes precedence, so general.txt is included. + // Dockerfile and its .dockerignore must be co-located; here both live in the context root. + let context: [FileSystemEntry] = [ + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("This file should be included (Dockerfile.dockerignore takes precedence over .dockerignore).\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let dockerignore = "specific.txt\n".data(using: .utf8)! + try dockerignore.write(to: tempDir.appendingPathComponent("Dockerfile.dockerignore"), options: .atomic) + + let contextDir = tempDir.appendingPathComponent("context") + let dockerfilePath = tempDir.appendingPathComponent("Dockerfile") + let imageName = "registry.local/dockerignore-specific:\(UUID().uuidString)" + let args = ["build", "-f", dockerfilePath.path, "-t", imageName, contextDir.path] + let response = try run(arguments: args) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + let containerName = "dockerignore-specific-\(UUID().uuidString)" + try self.doLongRun(name: containerName, image: imageName) + defer { try? self.doStop(name: containerName) } + + let specificResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/specific.txt"]) + #expect(specificResult.status != 0, "specific.txt should NOT be present (ignored by Dockerfile.dockerignore)") + + let generalResult = try run(arguments: ["exec", containerName, "test", "-f", "/app/general.txt"]) + #expect(generalResult.status == 0, "general.txt should be present (only in .dockerignore, not Dockerfile.dockerignore)") } // Test 5: Build succeeds when Dockerfile is listed in .dockerignore @@ -882,7 +927,7 @@ extension TestCLIBuildBase { .file("secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("nested/secret.txt", content: .data("This file should be ignored by Dockerfile.dockerignore.\n".data(using: .utf8)!)), .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n".data(using: .utf8)!)), + .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), .file("nested/project/config.txt", content: .data("This config file should be included.\n".data(using: .utf8)!)), ] try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) From ec0c063f12a33f4cfd59b584865f42bc144cd7da Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Tue, 10 Mar 2026 10:32:16 -0700 Subject: [PATCH 05/10] Move dockerfile check under validate() --- Sources/ContainerCommands/BuildCommand.swift | 43 +++++++++++---- .../Subcommands/Build/CLIBuilderTest.swift | 55 +++++++++++++++++++ 2 files changed, 87 insertions(+), 11 deletions(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 12a12457b..c4c772473 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -72,6 +72,8 @@ extension Application { @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) var file: String? + var dockerfile: String = "-" + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) var label: [String] = [] @@ -208,7 +210,7 @@ extension Application { var buildFilePath: String? = nil var ignoreFileData: Data? = nil // Dockerfile should be read from stdin - if let file = self.file, file == "-" { + if dockerfile == "-" { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: tempFile) @@ -231,24 +233,18 @@ extension Application { try fileHandle.close() buildFileData = try Data(contentsOf: URL(filePath: tempFile.path())) } else { - let path = try file ?? BuildFile.resolvePath(contextDir: self.contextDir, log: log) - - guard let path else { - throw ValidationError("failed to find Dockerfile or Containerfile in the context directory \(self.contextDir)") - } - - let ignoreFileURL = URL(filePath: path + ".dockerignore") + let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore") ignoreFileData = try? Data(contentsOf: ignoreFileURL) if ignoreFileData != nil { let hiddenDirName = ".\(UUID().uuidString)" - let buildFileName = URL(filePath: path).lastPathComponent + let buildFileName = URL(filePath: dockerfile).lastPathComponent buildFilePath = "\(hiddenDirName)/\(buildFileName)" ignoreFileData?.append("\n\(hiddenDirName)".data(using: .utf8) ?? Data()) } - buildFileData = try Data(contentsOf: URL(filePath: path)) + buildFileData = try Data(contentsOf: URL(filePath: dockerfile)) } let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) @@ -418,7 +414,7 @@ extension Application { } } - public func validate() throws { + public mutating func validate() throws { // NOTE: We'll "validate" the Dockerfile later. guard FileManager.default.fileExists(atPath: contextDir) else { throw ValidationError("context dir does not exist \(contextDir)") @@ -428,6 +424,31 @@ extension Application { throw ValidationError("invalid reference \(name)") } } + + switch file { + case "-": + dockerfile = "-" + break + case .some(let filepath): + let fileURL = URL(fileURLWithPath: filepath, relativeTo: .currentDirectory()) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + throw ValidationError("dockerfile does not exist \(filepath)") + } + + dockerfile = fileURL.path + break + case .none: + guard let defaultDockerfile = try BuildFile.resolvePath(contextDir: contextDir) else { + throw ValidationError("dockerfile not found in context dir") + } + + guard FileManager.default.fileExists(atPath: defaultDockerfile) else { + throw ValidationError("dockerfile does not exist \(defaultDockerfile)") + } + + dockerfile = defaultDockerfile + break + } } } } diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift index fe1dad393..a0007d152 100644 --- a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -755,6 +755,10 @@ extension TestCLIBuildBase { // Test 1: Basic .dockerignore @Test func testDockerIgnoreBasic() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -792,6 +796,10 @@ extension TestCLIBuildBase { // Test 2: Dockerfile-specific ignore file (Dockerfile.dockerignore takes precedence over .dockerignore) @Test func testDockerIgnoreDockerfileSpecific() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -836,6 +844,10 @@ extension TestCLIBuildBase { @Test func testDockerIgnoreOutsideContext() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -878,6 +890,10 @@ extension TestCLIBuildBase { // Test 5: Build succeeds when Dockerfile is listed in .dockerignore @Test func testDockerIgnoreIgnoredDockerfile() async throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -919,6 +935,10 @@ extension TestCLIBuildBase { // Test 8: Dockerfile in nested subdirectory; Dockerfile.dockerignore next to it takes precedence over root .dockerignore @Test func testDockerIgnoreSubdirDockerfile() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -967,6 +987,10 @@ extension TestCLIBuildBase { // Test 9: Custom-named Dockerfile (app1.Dockerfile) uses app1.Dockerfile.dockerignore @Test func testDockerIgnoreCustomDockerfileName() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -1012,6 +1036,10 @@ extension TestCLIBuildBase { // Test 10: Custom-named Dockerfile in subdirectory uses its co-located .dockerignore @Test func testDockerIgnoreCustomNameSubdir() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -1062,6 +1090,10 @@ extension TestCLIBuildBase { // Test 11: app.Dockerfile coexists with Dockerfile; app.Dockerfile.dockerignore is used, not Dockerfile.dockerignore @Test func testDockerIgnoreCoexistingDockerfiles() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let appDockerfile = """ FROM ghcr.io/linuxcontainers/alpine:3.20 @@ -1103,8 +1135,31 @@ extension TestCLIBuildBase { #expect(includedResult.status == 0, "included.txt should be present") } + @Test func testNonExistingDockerfile() throws { + let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + + let imageName = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" + + var args = ["build", "-f", "non-existing-path", "-t", imageName, tempDir.path] + var response = try run(arguments: args) + + #expect(response.status != 0) + + args = ["build", "-t", imageName, tempDir.path] + response = try run(arguments: args) + + #expect(response.status != 0) + } + @Test func testBuildNoCachePullLatestImage() throws { let tempDir: URL = try createTempDir() + defer { + try! FileManager.default.removeItem(at: tempDir) + } + let dockerfile = """ FROM \(alpine) From 1fd2d8d0c8b950a28768d704614f4fd5e9431049 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Thu, 12 Mar 2026 12:40:23 -0700 Subject: [PATCH 06/10] Remove DockerfilePath --- Sources/ContainerBuild/Builder.swift | 6 ------ Sources/ContainerCommands/BuildCommand.swift | 15 ++------------- 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 52d09dcba..8508a01fc 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -240,7 +240,6 @@ public struct Builder: Sendable { public let contentStore: ContentStore public let buildArgs: [String] public let contextDir: String - public let dockerfilePath: String? public let dockerfile: Data public let dockerignore: Data? public let labels: [String] @@ -260,7 +259,6 @@ public struct Builder: Sendable { contentStore: ContentStore, buildArgs: [String], contextDir: String, - dockerfilePath: String?, dockerfile: Data, dockerignore: Data?, labels: [String], @@ -279,7 +277,6 @@ public struct Builder: Sendable { self.contentStore = contentStore self.buildArgs = buildArgs self.contextDir = contextDir - self.dockerfilePath = dockerfilePath self.dockerfile = dockerfile self.dockerignore = dockerignore self.labels = labels @@ -325,9 +322,6 @@ extension CallOptions { ("progress", config.terminal != nil ? "tty" : "plain"), ("target", config.target), ] - if let dockerfilePath = config.dockerfilePath { - headers.append(("dockerfile-path", dockerfilePath)) - } if let dockerignore = config.dockerignore { headers.append(("dockerignore", dockerignore.base64EncodedString())) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index c4c772473..e389915d2 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -207,7 +207,6 @@ extension Application { } let buildFileData: Data - var buildFilePath: String? = nil var ignoreFileData: Data? = nil // Dockerfile should be read from stdin if dockerfile == "-" { @@ -234,17 +233,8 @@ extension Application { buildFileData = try Data(contentsOf: URL(filePath: tempFile.path())) } else { let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore") - ignoreFileData = try? Data(contentsOf: ignoreFileURL) - - if ignoreFileData != nil { - let hiddenDirName = ".\(UUID().uuidString)" - let buildFileName = URL(filePath: dockerfile).lastPathComponent - - buildFilePath = "\(hiddenDirName)/\(buildFileName)" - ignoreFileData?.append("\n\(hiddenDirName)".data(using: .utf8) ?? Data()) - } - buildFileData = try Data(contentsOf: URL(filePath: dockerfile)) + ignoreFileData = try? Data(contentsOf: ignoreFileURL) } let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) @@ -316,13 +306,12 @@ extension Application { } return results }() - group.addTask { [terminal, buildArg, contextDir, buildFilePath, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull] in + group.addTask { [terminal, buildArg, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, contextDir: contextDir, - dockerfilePath: buildFilePath, dockerfile: buildFileData, dockerignore: ignoreFileData, labels: label, From f1270349acc05253d2799ca9c69d57368d611f96 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Fri, 13 Mar 2026 08:29:04 -0700 Subject: [PATCH 07/10] Make hidden docker directory inside build context --- Sources/ContainerBuild/Builder.swift | 10 +++++----- Sources/ContainerCommands/BuildCommand.swift | 21 ++++++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index 8508a01fc..63b3506f6 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -241,7 +241,7 @@ public struct Builder: Sendable { public let buildArgs: [String] public let contextDir: String public let dockerfile: Data - public let dockerignore: Data? + public let hiddenDockerDir: String? public let labels: [String] public let noCache: Bool public let platforms: [Platform] @@ -260,7 +260,7 @@ public struct Builder: Sendable { buildArgs: [String], contextDir: String, dockerfile: Data, - dockerignore: Data?, + hiddenDockerDir: String?, labels: [String], noCache: Bool, platforms: [Platform], @@ -278,7 +278,7 @@ public struct Builder: Sendable { self.buildArgs = buildArgs self.contextDir = contextDir self.dockerfile = dockerfile - self.dockerignore = dockerignore + self.hiddenDockerDir = hiddenDockerDir self.labels = labels self.noCache = noCache self.platforms = platforms @@ -322,8 +322,8 @@ extension CallOptions { ("progress", config.terminal != nil ? "tty" : "plain"), ("target", config.target), ] - if let dockerignore = config.dockerignore { - headers.append(("dockerignore", dockerignore.base64EncodedString())) + if let hiddenDockerDir = config.hiddenDockerDir { + headers.append(("hidden-docker-dir", hiddenDockerDir)) } for tag in config.tags { headers.append(("tag", tag)) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index e389915d2..fe34fab38 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -208,6 +208,7 @@ extension Application { let buildFileData: Data var ignoreFileData: Data? = nil + var hiddenDockerDir: String? = nil // Dockerfile should be read from stdin if dockerfile == "-" { let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)") @@ -235,6 +236,22 @@ extension Application { let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore") buildFileData = try Data(contentsOf: URL(filePath: dockerfile)) ignoreFileData = try? Data(contentsOf: ignoreFileURL) + + if let ignoreFileData { + hiddenDockerDir = ".hidden-docker-dir" + let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(hiddenDockerDir!) + + try FileManager.default.createDirectory(at: hiddenDirInContext, withIntermediateDirectories: true) + try buildFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile")) + try ignoreFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile.dockerignore")) + } + } + + defer { + if let hiddenDockerDir { + let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(hiddenDockerDir) + try? FileManager.default.removeItem(at: hiddenDirInContext) + } } let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) @@ -306,14 +323,14 @@ extension Application { } return results }() - group.addTask { [terminal, buildArg, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull] in + group.addTask { [terminal, buildArg, contextDir, hiddenDockerDir, label, noCache, target, quiet, cacheIn, cacheOut, pull] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), buildArgs: buildArg, contextDir: contextDir, dockerfile: buildFileData, - dockerignore: ignoreFileData, + hiddenDockerDir: hiddenDockerDir, labels: label, noCache: noCache, platforms: [Platform](platforms), From f97b6ab4957111ee9f97fee5ffb6539628988ec4 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Fri, 13 Mar 2026 08:42:54 -0700 Subject: [PATCH 08/10] Ignore hidden docker directory --- Sources/ContainerCommands/BuildCommand.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index fe34fab38..e546ce92e 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -237,12 +237,14 @@ extension Application { buildFileData = try Data(contentsOf: URL(filePath: dockerfile)) ignoreFileData = try? Data(contentsOf: ignoreFileURL) - if let ignoreFileData { + if var ignoreFileData { hiddenDockerDir = ".hidden-docker-dir" let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(hiddenDockerDir!) try FileManager.default.createDirectory(at: hiddenDirInContext, withIntermediateDirectories: true) try buildFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile")) + + ignoreFileData.append("\n\(hiddenDockerDir!)".data(using: .utf8) ?? Data()) try ignoreFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile.dockerignore")) } } From 4c1ec2c59d161d37d61a9474fe4e2358cdd3e1e2 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Fri, 13 Mar 2026 11:30:57 -0700 Subject: [PATCH 09/10] Update comment --- Sources/ContainerCommands/BuildCommand.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index e546ce92e..76a140ddf 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -423,7 +423,7 @@ extension Application { } public mutating func validate() throws { - // NOTE: We'll "validate" the Dockerfile later. + // NOTE: Here we check the Dockerfile exists, and set `dockerfile` to point the valid Dockerfile path or stdin guard FileManager.default.fileExists(atPath: contextDir) else { throw ValidationError("context dir does not exist \(contextDir)") } From 952b12ab6bd6a4de08aef87ca25aacd2b7b5be90 Mon Sep 17 00:00:00 2001 From: Jaewon Hur Date: Fri, 13 Mar 2026 13:11:37 -0700 Subject: [PATCH 10/10] Revove force-unwrap --- Sources/ContainerCommands/BuildCommand.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 76a140ddf..82ccd8ce9 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -28,6 +28,8 @@ import TerminalProgress extension Application { public struct BuildCommand: AsyncLoggableCommand { + private static let hiddenDockerDir = ".com.apple.container.dockerfiles" + public init() {} public static var configuration: CommandConfiguration { var config = CommandConfiguration() @@ -238,13 +240,13 @@ extension Application { ignoreFileData = try? Data(contentsOf: ignoreFileURL) if var ignoreFileData { - hiddenDockerDir = ".hidden-docker-dir" - let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(hiddenDockerDir!) + hiddenDockerDir = Self.hiddenDockerDir + let hiddenDirInContext = URL(fileURLWithPath: contextDir).appendingPathComponent(Self.hiddenDockerDir) try FileManager.default.createDirectory(at: hiddenDirInContext, withIntermediateDirectories: true) try buildFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile")) - ignoreFileData.append("\n\(hiddenDockerDir!)".data(using: .utf8) ?? Data()) + ignoreFileData.append("\n\(Self.hiddenDockerDir)".data(using: .utf8) ?? Data()) try ignoreFileData.write(to: hiddenDirInContext.appendingPathComponent("Dockerfile.dockerignore")) } }