Fix BuilderStart race, parallelize container build tests. (#2002)

- Closes #2001.
- Handle "container exists" error gracefully instead
  of failing, when trying to start the buildkit container.
- Move build tests to parallel suites, while the builder
  lifecycle tests remain serial. Parallel builds don't
  use the fixture lock that deletes and restarts the
  builder and runs a build block in isolation.
This commit is contained in:
J Logan
2026-07-23 15:50:59 -07:00
committed by GitHub
parent 78e2cb4417
commit d1d763530d
13 changed files with 1470 additions and 1596 deletions
@@ -292,11 +292,17 @@ extension Application {
.setDescription("Starting BuildKit container")
])
try await client.create(
configuration: config,
options: .default,
kernel: kernel
)
do {
try await client.create(
configuration: config,
options: .default,
kernel: kernel
)
} catch let error as ContainerizationError where error.code == .exists {
// A concurrent `container build` invocation already created the builder
// while we were fetching the image/kernel above. `bootstrap` below is
// idempotent, so just proceed against the container the winner created.
}
try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager)
log.debug("starting BuildKit and BuildKit-shim")
@@ -94,42 +94,6 @@ extension ContainerFixture {
}
throw CommandError.executionFailed("timed out waiting for container-builder-shim on buildkit")
}
/// Deletes any existing builder, starts a fresh one, runs `body`, then deletes the builder.
///
/// Each build test gets an isolated builder to avoid inter-test contamination.
/// Acquires a process-wide lock so only one test holds the buildkit singleton at a time,
/// regardless of how many suites run concurrently in the global pass.
public func withBuilder(
cpus: Int64 = 2,
memoryInGBs: Int64 = 2,
_ body: @Sendable (ContainerFixture) async throws -> Void
) async throws {
try await withoutActuallyEscaping(body) { escapingBody in
try await Self.builderLock.withLock { _ in
_ = try? self.run(["builder", "delete", "--force"])
try self.builderStart(cpus: cpus, memoryInGBs: memoryInGBs)
defer { _ = try? self.run(["builder", "delete", "--force"]) }
try await self.waitForBuilderRunning()
try await escapingBody(self)
}
}
}
/// Acquires the process-wide builder lock without starting a builder.
///
/// Use this in tests that manually manage the builder lifecycle (e.g. lifecycle
/// tests that call ``builderStart()``/``builderStop()`` directly) so they
/// serialise correctly with tests that use ``withBuilder(_:)``.
public func withBuilderLock<T: Sendable>(_ body: @Sendable () async throws -> T) async throws -> T {
try await withoutActuallyEscaping(body) { escapingBody in
try await Self.builderLock.withLock { _ in
try await escapingBody()
}
}
}
private static let builderLock = AsyncLock()
}
// MARK: - Build context helpers
@@ -71,6 +71,8 @@ public struct ContainerClient: Sendable {
}
try await xpcSend(message: request)
} catch let error as ContainerizationError {
throw error
} catch {
throw ContainerizationError(
.internalError,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
struct TestCLIBuilderEnvOnly {
@Test func testBuildEnvironmentOnlyImageFromScratch() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
ARG BUILD_DATE
ARG VERSION=1.0.0
ENV TERM=xterm \\
BUILD_DATE=${BUILD_DATE} \\
APP_VERSION=${VERSION} \\
PATH=/usr/local/bin:/usr/bin:/bin
LABEL maintainer="test@example.com" version="${VERSION}"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-env-only:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"])
try f.assertImageBuilt(imageName)
}
}
@Test func testBuildEnvironmentOnlyImageFromAlpine() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production
LABEL maintainer="test@example.com" version="1.0.0"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-alpine-env:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir)
try f.assertImageBuilt(imageName)
}
}
@Test func testMultiStageBuildWithEnvOnlyBase() async throws {
try await ContainerFixture.with { f in
let baseDir = try f.createTempDir()
let baseDockerfile =
"""
FROM scratch
ARG JOBS=6
ARG ARCH=amd64
ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin
"""
try f.createContext(dir: baseDir, dockerfile: baseDockerfile)
let baseImageName = "test-env-base:\(UUID().uuidString)"
try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"])
try f.assertImageBuilt(baseImageName)
let downstreamDir = try f.createTempDir()
let downstreamDockerfile =
"""
FROM \(baseImageName)
LABEL test="env-inherited"
"""
try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile)
let downstreamImageName = "test-env-child:\(UUID().uuidString)"
try f.build(tag: downstreamImageName, contextDir: downstreamDir)
try f.assertImageBuilt(downstreamImageName)
}
}
@Test func testComplexArgAndEnvCombinations() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
ARG JOBS=6
ARG MAXLOAD=7.00
ARG ARCH=amd64
ARG PROFILE_PATH=23.0/split-usr/no-multilib
ARG CHOST=x86_64-pc-linux-gnu
ARG CFLAGS=-O2 -pipe
ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\
GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\
CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\
CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}"
LABEL maintainer="test@example.com"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-complex-env:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"])
try f.assertImageBuilt(imageName)
}
}
@Test func testLabelOnlyDockerfile() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
LABEL maintainer="test@example.com" version="1.0.0" \\
description="Test image with only labels" \\
org.opencontainers.image.title="Test Image"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-label-only:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir)
try f.assertImageBuilt(imageName)
}
}
}
@@ -1,139 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
@Suite(.serialized)
struct TestCLIBuilderEnvOnlySerial {
@Test func testBuildEnvironmentOnlyImageFromScratch() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
ARG BUILD_DATE
ARG VERSION=1.0.0
ENV TERM=xterm \\
BUILD_DATE=${BUILD_DATE} \\
APP_VERSION=${VERSION} \\
PATH=/usr/local/bin:/usr/bin:/bin
LABEL maintainer="test@example.com" version="${VERSION}"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-env-only:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"])
try f.assertImageBuilt(imageName)
}
}
}
@Test func testBuildEnvironmentOnlyImageFromAlpine() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM ghcr.io/linuxcontainers/alpine:3.20
ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production
LABEL maintainer="test@example.com" version="1.0.0"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-alpine-env:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir)
try f.assertImageBuilt(imageName)
}
}
}
@Test func testMultiStageBuildWithEnvOnlyBase() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let baseDir = try f.createTempDir()
let baseDockerfile =
"""
FROM scratch
ARG JOBS=6
ARG ARCH=amd64
ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin
"""
try f.createContext(dir: baseDir, dockerfile: baseDockerfile)
let baseImageName = "test-env-base:\(UUID().uuidString)"
try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"])
try f.assertImageBuilt(baseImageName)
let downstreamDir = try f.createTempDir()
let downstreamDockerfile =
"""
FROM \(baseImageName)
LABEL test="env-inherited"
"""
try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile)
let downstreamImageName = "test-env-child:\(UUID().uuidString)"
try f.build(tag: downstreamImageName, contextDir: downstreamDir)
try f.assertImageBuilt(downstreamImageName)
}
}
}
@Test func testComplexArgAndEnvCombinations() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
ARG JOBS=6
ARG MAXLOAD=7.00
ARG ARCH=amd64
ARG PROFILE_PATH=23.0/split-usr/no-multilib
ARG CHOST=x86_64-pc-linux-gnu
ARG CFLAGS=-O2 -pipe
ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\
GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\
CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\
CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}"
LABEL maintainer="test@example.com"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-complex-env:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"])
try f.assertImageBuilt(imageName)
}
}
}
@Test func testLabelOnlyDockerfile() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
let dockerfile =
"""
FROM scratch
LABEL maintainer="test@example.com" version="1.0.0" \\
description="Test image with only labels" \\
org.opencontainers.image.title="Test Image"
"""
try f.createContext(dir: dir, dockerfile: dockerfile)
let imageName = "test-label-only:\(UUID().uuidString)"
try f.build(tag: imageName, contextDir: dir)
try f.assertImageBuilt(imageName)
}
}
}
}
@@ -21,56 +21,50 @@ import Testing
/// Tests for `container builder start`, `stop`, and `delete` lifecycle commands.
///
/// These tests manage the builder manually they do not use ``withBuilder``
/// because they are specifically testing the lifecycle commands themselves.
/// They acquire the shared builder lock via ``withBuilderLock`` to serialise
/// correctly with tests that use ``withBuilder(_:)``.
/// Serialized because they stop/delete the shared `buildkit` container, which
/// would race with in-flight builds in the concurrent pool.
@Suite(.serialized)
struct TestCLIBuilderLifecycleSerial {
@Test func testBuilderStartStopCommand() async throws {
try await ContainerFixture.with { f in
try await f.withBuilderLock {
f.addCleanup { try? f.builderDelete(force: true) }
f.addCleanup { try? f.builderDelete(force: true) }
try f.builderStart()
try await f.waitForBuilderRunning()
let status1 = try f.getContainerStatus("buildkit")
#expect(status1 == "running", "buildkit container should be running")
try f.builderStart()
try await f.waitForBuilderRunning()
let status1 = try f.getContainerStatus("buildkit")
#expect(status1 == "running", "buildkit container should be running")
try f.builderStop()
let status2 = try f.getContainerStatus("buildkit")
#expect(status2 == "stopped", "buildkit container should be stopped")
}
try f.builderStop()
let status2 = try f.getContainerStatus("buildkit")
#expect(status2 == "stopped", "buildkit container should be stopped")
}
}
@Test func testBuilderEnvironmentColors() async throws {
try await ContainerFixture.with { f in
try await f.withBuilderLock {
let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"]
let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"]
f.addCleanup {
if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") }
if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") }
_ = try? f.builderDelete(force: true)
}
let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"]
let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"]
f.addCleanup {
if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") }
if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") }
_ = try? f.builderDelete(force: true)
setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1)
setenv("NO_COLOR", "true", 1)
try f.run(["builder", "start"]).check()
try await f.waitForBuilderRunning()
let container = try f.inspectContainer("buildkit")
let env = container.configuration.initProcess.environment
#expect(
env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"),
"BUILDKIT_COLORS should be forwarded to the buildkit container")
#expect(
env.contains("NO_COLOR=true"),
"NO_COLOR should be forwarded to the buildkit container")
}
_ = try? f.builderDelete(force: true)
setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1)
setenv("NO_COLOR", "true", 1)
try f.run(["builder", "start"]).check()
try await f.waitForBuilderRunning()
let container = try f.inspectContainer("buildkit")
let env = container.configuration.initProcess.environment
#expect(
env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"),
"BUILDKIT_COLORS should be forwarded to the buildkit container")
#expect(
env.contains("NO_COLOR=true"),
"NO_COLOR should be forwarded to the buildkit container")
}
}
}
@@ -0,0 +1,141 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
struct TestCLIBuilderLocalOutput {
@Test func testBuildLocalOutputHappyPath() async throws {
try await ContainerFixture.with { f in
// Comprehensive multi-stage build with context and build args.
let dir = try f.createTempDir()
let dockerfile =
"""
ARG MESSAGE=default
FROM scratch AS builder
ADD build.txt /build.txt
ADD testfile.txt /hello.txt
FROM scratch
COPY --from=builder /build.txt /final.txt
COPY --from=builder /hello.txt /app/hello.txt
ADD message.txt /message.txt
"""
let context: [ContainerFixture.FileSystemEntry] = [
.file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)),
.file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)),
.file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)),
]
try f.createContext(dir: dir, dockerfile: dockerfile, context: context)
let outputDir = dir.appending("comprehensive-local-output")
let imageName = "local-comprehensive-test:\(UUID().uuidString)"
let response = try f.buildWithPathsAndLocalOutput(
tag: imageName, contextDir: dir, outputDir: outputDir,
buildArgs: ["MESSAGE=Hello from build args"])
#expect(response.contains(outputDir.string), "output should reference the export path")
#expect(FileManager.default.fileExists(atPath: outputDir.string))
let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string)
#expect(!contents.isEmpty, "output directory should contain files")
// Basic local output.
let basicDir = try f.createTempDir()
try f.createContext(
dir: basicDir,
dockerfile: "FROM scratch\nADD testfile.txt /hello.txt",
context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))])
let basicOutputDir = basicDir.appending("basic-local-output")
let basicResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir)
#expect(basicResponse.contains(basicOutputDir.string))
#expect(FileManager.default.fileExists(atPath: basicOutputDir.string))
// Build with context (COPY instruction).
let ctxDir = try f.createTempDir()
try f.createContext(
dir: ctxDir,
dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt",
context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))])
let ctxOutputDir = ctxDir.appending("context-local-output")
let ctxResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir)
#expect(ctxResponse.contains(ctxOutputDir.string))
#expect(FileManager.default.fileExists(atPath: ctxOutputDir.string))
}
}
@Test func testBuildLocalOutputEdgeCases() async throws {
try await ContainerFixture.with { f in
// Different paths for Dockerfile context and build context.
let dockerfileDir = try f.createTempDir()
try f.createContext(
dir: dockerfileDir,
dockerfile: "FROM scratch\nCOPY . /app",
context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))])
let buildContextDir = try f.createTempDir()
try f.createContext(
dir: buildContextDir, dockerfile: "",
context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))])
let outputDir = dockerfileDir.appending("diffpaths-local-output")
let response = try f.buildWithPathsAndLocalOutput(
tag: "local-diffpaths-test:\(UUID().uuidString)",
contextDir: buildContextDir,
dockerfilePath: dockerfileDir.appending("Dockerfile"),
outputDir: outputDir)
#expect(response.contains(outputDir.string))
#expect(FileManager.default.fileExists(atPath: outputDir.string))
// Build into an existing output directory (should merge/overwrite).
let existingDir = try f.createTempDir()
try f.createContext(
dir: existingDir,
dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt",
context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))])
let existingOutputDir = existingDir.appending("existing-output")
try FileManager.default.createDirectory(
atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil)
try "Existing content\n".data(using: .utf8)!
.write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic)
let existingResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-existing-test:\(UUID().uuidString)",
contextDir: existingDir, outputDir: existingOutputDir)
#expect(existingResponse.contains(existingOutputDir.string))
let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string)
#expect(!contents.isEmpty)
}
}
@Test func testBuildLocalOutputFailure() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD test.txt /test.txt",
context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))])
// An uncreateable path should cause the build to fail.
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-t", "local-invalid-test:\(UUID().uuidString)",
"--output", "type=local,dest=/nonexistent/invalid/path",
dir.appending("context").string,
])
#expect(result.status != 0, "build with invalid output path should fail")
}
}
}
@@ -1,148 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
@Suite(.serialized)
struct TestCLIBuilderLocalOutputSerial {
@Test func testBuildLocalOutputHappyPath() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
// Comprehensive multi-stage build with context and build args.
let dir = try f.createTempDir()
let dockerfile =
"""
ARG MESSAGE=default
FROM scratch AS builder
ADD build.txt /build.txt
ADD testfile.txt /hello.txt
FROM scratch
COPY --from=builder /build.txt /final.txt
COPY --from=builder /hello.txt /app/hello.txt
ADD message.txt /message.txt
"""
let context: [ContainerFixture.FileSystemEntry] = [
.file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)),
.file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)),
.file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)),
]
try f.createContext(dir: dir, dockerfile: dockerfile, context: context)
let outputDir = dir.appending("comprehensive-local-output")
let imageName = "local-comprehensive-test:\(UUID().uuidString)"
let response = try f.buildWithPathsAndLocalOutput(
tag: imageName, contextDir: dir, outputDir: outputDir,
buildArgs: ["MESSAGE=Hello from build args"])
#expect(response.contains(outputDir.string), "output should reference the export path")
#expect(FileManager.default.fileExists(atPath: outputDir.string))
let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string)
#expect(!contents.isEmpty, "output directory should contain files")
// Basic local output.
let basicDir = try f.createTempDir()
try f.createContext(
dir: basicDir,
dockerfile: "FROM scratch\nADD testfile.txt /hello.txt",
context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))])
let basicOutputDir = basicDir.appending("basic-local-output")
let basicResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir)
#expect(basicResponse.contains(basicOutputDir.string))
#expect(FileManager.default.fileExists(atPath: basicOutputDir.string))
// Build with context (COPY instruction).
let ctxDir = try f.createTempDir()
try f.createContext(
dir: ctxDir,
dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt",
context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))])
let ctxOutputDir = ctxDir.appending("context-local-output")
let ctxResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir)
#expect(ctxResponse.contains(ctxOutputDir.string))
#expect(FileManager.default.fileExists(atPath: ctxOutputDir.string))
}
}
}
@Test func testBuildLocalOutputEdgeCases() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
// Different paths for Dockerfile context and build context.
let dockerfileDir = try f.createTempDir()
try f.createContext(
dir: dockerfileDir,
dockerfile: "FROM scratch\nCOPY . /app",
context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))])
let buildContextDir = try f.createTempDir()
try f.createContext(
dir: buildContextDir, dockerfile: "",
context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))])
let outputDir = dockerfileDir.appending("diffpaths-local-output")
let response = try f.buildWithPathsAndLocalOutput(
tag: "local-diffpaths-test:\(UUID().uuidString)",
contextDir: buildContextDir,
dockerfilePath: dockerfileDir.appending("Dockerfile"),
outputDir: outputDir)
#expect(response.contains(outputDir.string))
#expect(FileManager.default.fileExists(atPath: outputDir.string))
// Build into an existing output directory (should merge/overwrite).
let existingDir = try f.createTempDir()
try f.createContext(
dir: existingDir,
dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt",
context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))])
let existingOutputDir = existingDir.appending("existing-output")
try FileManager.default.createDirectory(
atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil)
try "Existing content\n".data(using: .utf8)!
.write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic)
let existingResponse = try f.buildWithPathsAndLocalOutput(
tag: "local-existing-test:\(UUID().uuidString)",
contextDir: existingDir, outputDir: existingOutputDir)
#expect(existingResponse.contains(existingOutputDir.string))
let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string)
#expect(!contents.isEmpty)
}
}
}
@Test func testBuildLocalOutputFailure() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD test.txt /test.txt",
context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))])
// An uncreateable path should cause the build to fail.
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-t", "local-invalid-test:\(UUID().uuidString)",
"--output", "type=local,dest=/nonexistent/invalid/path",
dir.appending("context").string,
])
#expect(result.status != 0, "build with invalid output path should fail")
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,117 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
struct TestCLIBuilderTarExport {
@Test func testBuildExportTar() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD emptyFile /",
context: [.file("emptyFile", content: .zeroFilled(size: 1))])
let exportPath = dir.appending("export.tar")
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportPath.string)",
dir.appending("context").string,
])
#expect(result.status == 0, "build with tar export should succeed")
#expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist")
#expect(result.output.contains(exportPath.string), "output should reference export path")
let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string)
#expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty")
}
}
@Test func testBuildExportTarToDirectory() 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\nRUN echo \"test\" > /test.txt")
let exportDir = dir.appending("exports")
try FileManager.default.createDirectory(
atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil)
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportDir.string)",
dir.appending("context").string,
])
#expect(result.status == 0, "build with tar export to directory should succeed")
let expectedTar = exportDir.appending("out.tar")
#expect(
FileManager.default.fileExists(atPath: expectedTar.string),
"tar file should exist at out.tar")
#expect(result.output.contains(expectedTar.string), "output should reference out.tar")
}
}
@Test func testBuildExportTarMultipleRuns() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD testFile /",
context: [.file("testFile", content: .data("test data".data(using: .utf8)!))])
let exportDir = dir.appending("exports")
try FileManager.default.createDirectory(
atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil)
let buildArgs = [
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportDir.string)",
dir.appending("context").string,
]
let r1 = try f.run(buildArgs)
#expect(r1.status == 0, "first build should succeed")
#expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string))
let r2 = try f.run(buildArgs)
#expect(r2.status == 0, "second build should succeed")
#expect(
FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string),
"second tar should exist at out.tar.1")
}
}
@Test func testBuildExportTarInvalidDest() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(dir: dir, dockerfile: "FROM scratch")
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar", // missing dest
dir.appending("context").string,
])
#expect(result.status != 0, "build without dest should fail")
#expect(result.error.contains("dest field is required"))
}
}
}
@@ -1,126 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
@Suite(.serialized)
struct TestCLIBuilderTarExportSerial {
@Test func testBuildExportTar() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD emptyFile /",
context: [.file("emptyFile", content: .zeroFilled(size: 1))])
let exportPath = dir.appending("export.tar")
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportPath.string)",
dir.appending("context").string,
])
#expect(result.status == 0, "build with tar export should succeed")
#expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist")
#expect(result.output.contains(exportPath.string), "output should reference export path")
let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string)
#expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty")
}
}
}
@Test func testBuildExportTarToDirectory() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"test\" > /test.txt")
let exportDir = dir.appending("exports")
try FileManager.default.createDirectory(
atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil)
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportDir.string)",
dir.appending("context").string,
])
#expect(result.status == 0, "build with tar export to directory should succeed")
let expectedTar = exportDir.appending("out.tar")
#expect(
FileManager.default.fileExists(atPath: expectedTar.string),
"tar file should exist at out.tar")
#expect(result.output.contains(expectedTar.string), "output should reference out.tar")
}
}
}
@Test func testBuildExportTarMultipleRuns() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM scratch\nADD testFile /",
context: [.file("testFile", content: .data("test data".data(using: .utf8)!))])
let exportDir = dir.appending("exports")
try FileManager.default.createDirectory(
atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil)
let buildArgs = [
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar,dest=\(exportDir.string)",
dir.appending("context").string,
]
let r1 = try f.run(buildArgs)
#expect(r1.status == 0, "first build should succeed")
#expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string))
let r2 = try f.run(buildArgs)
#expect(r2.status == 0, "second build should succeed")
#expect(
FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string),
"second tar should exist at out.tar.1")
}
}
}
@Test func testBuildExportTarInvalidDest() async throws {
try await ContainerFixture.with { f in
try await f.withBuilder { f in
let dir = try f.createTempDir()
try f.createContext(dir: dir, dockerfile: "FROM scratch")
let result = try f.run([
"build",
"-f", dir.appending("Dockerfile").string,
"-o", "type=tar", // missing dest
dir.appending("context").string,
])
#expect(result.status != 0, "build without dest should fail")
#expect(result.error.contains("dest field is required"))
}
}
}
}
@@ -0,0 +1,37 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerTestSupport
import Foundation
import Testing
/// Serial because this repulls the shared warmup alpine image with `--no-cache`,
/// which would race with concurrent-pool tests relying on it already being cached.
@Suite(.serialized)
struct TestCLIBuilderWarmupPullSerial {
@Test func testBuildNoCachePullLatestImage() async throws {
try await ContainerFixture.with { f in
let dir = try f.createTempDir()
try f.createContext(
dir: dir,
dockerfile: "FROM \(WarmupImage.alpine320.rawValue)\nADD emptyFile /",
context: [.file("emptyFile", content: .zeroFilled(size: 1))])
let image = "registry.local/no-cache-pull:\(UUID().uuidString)"
try f.buildWithPaths(tags: [image], contextDir: dir, otherArgs: ["--pull", "--no-cache"])
try f.assertImageBuilt(image)
}
}
}