Fix empty followpaths handling in BuildFSSync (#2414)

- Resolves #2405 in conjunction with
  apple/container-builder-shim#105 which
  resolves the root cause in the builder shim
  and #2434 which brings in the new shim.
- When BuildKit evaluates instructions requiring
  the full build context (such as steps with tmpfs
  mounts), it emits a Walk request with empty
  or omitted followpaths metadata. BuildFSSync
  previously defaulted missing followpaths to [],
  resulting in no context files being packed into
  the archive.
- This change defaults omitted or empty
  followpaths to ["."], ensuring the entire
  context is served as expected by BuildKit's
  filesync protocol.
This commit is contained in:
Arnav authored and GitHub committed 2026-10-28 15:25:41 -07:00
1 parent 410b29fe8b
commit 305cb6ed0a
3 files changed
+71 -1

No files matched your search

+4 -1
View File
@@ -233,6 +233,9 @@ actor BuildFSSync: BuildPipelineHandler {
/// This is the primary data path for build-context transfer. BuildKit sends
/// a `Walk` request whose `followpaths` field names the context paths needed
/// for the current build step (e.g. the source of a `COPY` instruction).
/// If `followpaths` is omitted or empty (meaning the build step requires the
/// entire context, such as a step with a tmpfs mount), all files in the
/// context root are included.
/// The host resolves those globs, builds an entry set, and passes it to
/// `Archiver.compress` to produce the tar.
///
@@ -253,7 +256,7 @@ actor BuildFSSync: BuildPipelineHandler {
let wantsTar = packet.mode() == "tar"
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths() ?? []
let followPaths: [String] = packet.followPaths().flatMap { $0.isEmpty ? nil : $0 } ?? ["."]
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
@@ -534,6 +534,53 @@ import Testing
let secretLeak = infos.first { $0.name.hasSuffix("secret.txt") }
#expect(secretLeak == nil, "no entry for the external file should appear in walk() results: \(infos.map { $0.name })")
}
// MARK: - walk(): empty or omitted followpaths requests entire context
//
// BuildKit omits followpaths when a build step needs the whole context
// (for example, RUN --mount=type=tmpfs). An empty or omitted followpaths
// must result in the entire context being served rather than an empty
// result.
@Test func testWalkJSONEmptyFollowPathsIncludesEntireContext() async throws {
try write("hello", to: contextDir.appendingPathComponent("a.txt"))
try write("world", to: contextDir.appendingPathComponent("b.txt"))
let fssync = try BuildFSSync(contextDir)
let infos = try await walkJSON(fssync, followPaths: [])
#expect(infos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths is empty")
#expect(infos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths is empty")
}
@Test func testWalkJSONMissingFollowPathsMetadataIncludesEntireContext() async throws {
try write("hello", to: contextDir.appendingPathComponent("a.txt"))
try write("world", to: contextDir.appendingPathComponent("b.txt"))
let fssync = try BuildFSSync(contextDir)
var p = BuildTransfer()
p.id = UUID().uuidString
p.source = "."
p.metadata = [
"mode": "json"
]
var continuation: AsyncStream<ClientStream>.Continuation!
let stream = AsyncStream<ClientStream> { continuation = $0 }
try await fssync.walk(continuation, p, "build-0")
continuation.finish()
var fileInfos: [BuildFSSync.FileInfo] = []
for await resp in stream {
let data = resp.buildTransfer.data
if !data.isEmpty {
fileInfos += try JSONDecoder().decode([BuildFSSync.FileInfo].self, from: data)
}
}
#expect(fileInfos.contains { $0.name == "a.txt" }, "a.txt should be included when followpaths metadata is omitted")
#expect(fileInfos.contains { $0.name == "b.txt" }, "b.txt should be included when followpaths metadata is omitted")
}
}
private final class ManagedAtomicFlag: @unchecked Sendable {
@@ -496,6 +496,26 @@ struct TestCLIBuilder {
}
}
@Test func testRunWithTmpfsMount() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: """
FROM ghcr.io/linuxcontainers/alpine:3.20
COPY a.txt b.txt /out/
RUN --mount=type=tmpfs,dst=/scratch ls -l /out
""",
context: [
.file("a.txt", content: .data("one\n".data(using: .utf8)!)),
.file("b.txt", content: .data("two\n".data(using: .utf8)!)),
])
let image = "registry.local/tmpfs-mount-test:\(UUID().uuidString)"
try f.build(tag: image, contextDir: dir)
try f.assertImageBuilt(image)
}
}
// MARK: - .dockerignore tests
@Test func testBuildDockerIgnore() async throws {