Fix docker ignore (#1273)

This PR fixes `container build` to use docker specific ignore (#1169).
This PR relies on apple/container-builder-shim#68.

When docker specific ignore file is detected, it creates a hidden docker
directory (i.e., `.hidden-docker-dir`) under the build context, and
places Dockerfile and ignore file under there. This hidden directory is
passed to the builder shim and used by buildkit daemon to correctly
figure out the docker specific ignore file.

Docker specific ignore file is detected regardless of whether i) they
are under nested directory, ii) outside build context, iii) using custom
names. If docker specific ignore is not provided, hidden directory is
also not created, and buildkit daemon will use the docker ignore file at
build context root as usual.

## Type of Change
- [X] Bug fix
- [ ] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
Docker specific ignore works.

## Testing
- [X] Tested locally
- [ ] Added/updated tests
- [ ] Added/updated docs
This commit is contained in:
jwhur
2026-03-16 12:58:32 -07:00
committed by GitHub
parent 50900938c4
commit ce171ac3ee
4 changed files with 484 additions and 40 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ import PackageDescription
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
let builderShimVersion = "0.8.0"
let builderShimVersion = "0.10.0"
let scVersion = "0.27.0"
let package = Package(
+6
View File
@@ -266,6 +266,7 @@ public struct Builder: Sendable {
public let buildArgs: [String]
public let contextDir: String
public let dockerfile: Data
public let hiddenDockerDir: String?
public let labels: [String]
public let noCache: Bool
public let platforms: [Platform]
@@ -284,6 +285,7 @@ public struct Builder: Sendable {
buildArgs: [String],
contextDir: String,
dockerfile: Data,
hiddenDockerDir: String?,
labels: [String],
noCache: Bool,
platforms: [Platform],
@@ -301,6 +303,7 @@ public struct Builder: Sendable {
self.buildArgs = buildArgs
self.contextDir = contextDir
self.dockerfile = dockerfile
self.hiddenDockerDir = hiddenDockerDir
self.labels = labels
self.noCache = noCache
self.platforms = platforms
@@ -323,6 +326,9 @@ public struct Builder: Sendable {
metadata.addString(config.terminal != nil ? "tty" : "plain", forKey: "progress")
metadata.addString(config.target, forKey: "target")
if let hiddenDockerDir = config.hiddenDockerDir {
metadata.addString(hiddenDockerDir, forKey: "hidden-docker-dir")
}
for tag in config.tags {
metadata.addString(tag, forKey: "tag")
}
+57 -20
View File
@@ -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()
@@ -72,6 +74,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] = []
@@ -204,24 +208,11 @@ 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 buildFileData: Data
var ignoreFileData: Data? = nil
var hiddenDockerDir: String? = nil
// Dockerfile should be read from stdin
if file == "-" {
if dockerfile == "-" {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)")
defer {
try? FileManager.default.removeItem(at: tempFile)
@@ -244,7 +235,27 @@ extension Application {
try fileHandle.close()
buildFileData = try Data(contentsOf: URL(filePath: tempFile.path()))
} else {
buildFileData = try Data(contentsOf: URL(filePath: buildFilePath))
let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore")
buildFileData = try Data(contentsOf: URL(filePath: dockerfile))
ignoreFileData = try? Data(contentsOf: ignoreFileURL)
if var ignoreFileData {
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\(Self.hiddenDockerDir)".data(using: .utf8) ?? Data())
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))
@@ -320,13 +331,14 @@ extension Application {
}
return results
}()
group.addTask { [terminal, buildArg, contextDir, 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,
hiddenDockerDir: hiddenDockerDir,
labels: label,
noCache: noCache,
platforms: [Platform](platforms),
@@ -416,8 +428,8 @@ extension Application {
}
}
public func validate() throws {
// NOTE: We'll "validate" the Dockerfile later.
public mutating func validate() throws {
// 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)")
}
@@ -426,6 +438,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
}
}
}
}
@@ -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)"
@@ -197,7 +197,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
@@ -205,8 +205,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 []
@@ -297,9 +297,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
@@ -307,7 +307,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
@@ -371,7 +371,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
@@ -381,22 +381,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)")
}
@@ -409,7 +404,7 @@ extension TestCLIBuildBase {
ADD . .
RUN cat emptyFile
RUN cat emptyFile
RUN cat Test/testempty
"""
let context: [FileSystemEntry] = [
@@ -757,8 +752,414 @@ 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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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)")
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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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
@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
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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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**/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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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()
defer {
try! FileManager.default.removeItem(at: tempDir)
}
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 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)