From d1d763530df3c6a326dbae7f0c0a59a335808045 Mon Sep 17 00:00:00 2001 From: J Logan Date: Thu, 23 Jul 2026 15:50:59 -0700 Subject: [PATCH] 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. --- .../Builder/BuilderStart.swift | 16 +- .../ContainerTestSupport/BuildFixture.swift | 36 - .../Client/ContainerClient.swift | 2 + .../Build/TestCLIBuilder.swift | 1003 +++++++++++++++ .../Build/TestCLIBuilderEnvOnly.swift | 128 ++ .../Build/TestCLIBuilderEnvOnlySerial.swift | 139 --- .../Build/TestCLIBuilderLifecycleSerial.swift | 68 +- .../Build/TestCLIBuilderLocalOutput.swift | 141 +++ .../TestCLIBuilderLocalOutputSerial.swift | 148 --- .../Build/TestCLIBuilderSerial.swift | 1105 ----------------- .../Build/TestCLIBuilderTarExport.swift | 117 ++ .../Build/TestCLIBuilderTarExportSerial.swift | 126 -- .../TestCLIBuilderWarmupPullSerial.swift | 37 + 13 files changed, 1470 insertions(+), 1596 deletions(-) create mode 100644 Tests/IntegrationTests/Build/TestCLIBuilder.swift create mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift delete mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift create mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift delete mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift delete mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift create mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift delete mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift create mode 100644 Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index a7f6ff37..5b65544a 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -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") diff --git a/Sources/ContainerTestSupport/BuildFixture.swift b/Sources/ContainerTestSupport/BuildFixture.swift index 9beca800..f317c2fb 100644 --- a/Sources/ContainerTestSupport/BuildFixture.swift +++ b/Sources/ContainerTestSupport/BuildFixture.swift @@ -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(_ 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 diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index b1b64a66..5a2b6d0d 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -71,6 +71,8 @@ public struct ContainerClient: Sendable { } try await xpcSend(message: request) + } catch let error as ContainerizationError { + throw error } catch { throw ContainerizationError( .internalError, diff --git a/Tests/IntegrationTests/Build/TestCLIBuilder.swift b/Tests/IntegrationTests/Build/TestCLIBuilder.swift new file mode 100644 index 00000000..7fc250f1 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilder.swift @@ -0,0 +1,1003 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +import Testing + +// Convenience alias for the verbose entry type. +typealias FSEntry = ContainerFixture.FileSystemEntry + +struct TestCLIBuilder { + + // MARK: - Basic build tests + + @Test func testBuildDefaultParams() 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") + // No tags — runtime generates one and prints it to stdout. + let output = try f.buildWithPaths(contextDir: dir) + let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") + try f.assertImageBuilt(generatedTag) + } + } + + @Test func testBuildDotFileSucceeds() 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)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "registry.local/dot-file:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildFromPreviousStage() 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 AS layer1 + RUN sh -c "echo 'layer1' > /layer1.txt" + FROM layer1 + CMD ["cat", "/layer1.txt"] + """) + let image = "registry.local/from-previous-layer:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildFromLocalImage() 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: 0)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "local-only:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + + let dir2 = try f.createTempDir() + try f.createContext( + dir: dir2, + dockerfile: "FROM \(image)", + context: []) + let image2 = "from-local:\(UUID().uuidString)" + try f.build(tag: image2, contextDir: dir2) + try f.assertImageBuilt(image2) + } + } + + @Test func testBuildAddFromSpecialDirs() 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 image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildScratchAdd() 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 image = "registry.local/scratch-add:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildAddAll() 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 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/add-all:\(UUID().uuidString)" + let output = try f.build(tag: image, contextDir: dir) + #expect(output.contains(image)) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") + let image = "registry.local/build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildSecret() 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 + RUN --mount=type=secret,id=ENV1 \\ + --mount=type=secret,id=env2 \\ + --mount=type=secret,id=env3 \\ + test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" + RUN --mount=type=secret,id=file \\ + awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ + cmp /tmp/foo /run/secrets/file && \\ + rm /tmp/foo + RUN --mount=type=secret,id=empty \\ + ! test -e /run/secrets/file && \\ + test -e /run/secrets/empty && \\ + cmp /dev/null /run/secrets/empty + """) + + setenv("ENV1", "x", 1) + setenv("ENV_VAR", "yy", 1) + setenv("env3", "zzz", 1) + f.addCleanup { + unsetenv("ENV1") + unsetenv("ENV_VAR") + unsetenv("env3") + } + + let testData = Data((0..<17).flatMap { _ in Array(0...255) }) + let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) + let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) + + let image = "registry.local/secrets:\(UUID().uuidString)" + try f.build( + tag: image, contextDir: dir, + otherArgs: [ + "--secret", "id=ENV1", + "--secret", "id=env2,env=ENV_VAR", + "--secret", "id=env3,env=env3", + "--secret", "id=file,src=\(secretFile.string)", + "--secret", "id=empty,src=\(emptyFile.string)", + ]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildNetworkAccess() 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 + ARG HTTP_PROXY + ARG HTTPS_PROXY + ARG NO_PROXY + ARG http_proxy + ARG https_proxy + ARG no_proxy + RUN apk add --no-cache curl + """) + var buildArgs: [String] = [] + for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { + if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } + } + let image = "registry.local/build-network-access:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildDockerfileKeywords() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG TAG=3.20 + FROM ghcr.io/linuxcontainers/alpine:${TAG} + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN echo "Hello, World!" > /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + CMD ["echo", "Exec default"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + LABEL version="1.0" description="Test image" + FROM ghcr.io/linuxcontainers/alpine:3.20 + EXPOSE 8080 + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV MY_ENV=hello + RUN echo $MY_ENV > /env.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD emptyFile / + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY toCopy /toCopy + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENTRYPOINT ["echo", "entrypoint!"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + VOLUME /data + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN adduser -D myuser + USER myuser + CMD whoami + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + RUN pwd > /pwd.out + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG MY_VAR=default + RUN echo $MY_VAR > /var.out + """, + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file("toCopy", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildSymlink() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test1Source Test1Source + ADD Test1Source2 Test1Source2 + RUN cat Test1Source2/test.yaml + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test2Source Test2Source + ADD Test2Source2 Test2Source2 + RUN cat Test2Source2/Test/test.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test3Source Test3Source + ADD Test3Source2 Test3Source2 + RUN cat Test3Source2/Dest/test.txt + """ + let context: [FSEntry] = [ + .directory("Test1Source"), .directory("Test1Source2"), + .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), + .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), + .directory("Test2Source"), .directory("Test2Source2"), + .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), + .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), + .directory("Test3Source/Source"), .directory("Test3Source2"), + .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), + .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let image = "registry.local/build-symlinks:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildAndRun() 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 \"foobar\" > /file") + let image = "\(f.testID)-build-and-run:latest" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + try await f.withContainer(image: image) { name in + let output = try f.doExec(name, cmd: ["cat", "/file"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "foobar") + } + } + } + + @Test func testBuildDifferentPaths() 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 + RUN ls ./ + COPY . /root + RUN cat /root/Test/test.txt + """, + context: [ + .directory(".git"), + .file(".git/FETCH", content: .zeroFilled(size: 1)), + .directory("Test"), + .file("Test/test.txt", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/build-diff-context:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildMultiArch() 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 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/multi-arch:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) + try f.assertImageBuilt(image) + + let output = try f.doInspectImages(image) + #expect(output.count == 1, "expected single inspect result") + let archs = Set(output[0].variants.map { $0.platform.architecture }) + #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") + } + } + + @Test func testBuildMultipleTags() 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 uuid = UUID().uuidString + let tag1 = "registry.local/multi-tag-test:\(uuid)" + let tag2 = "registry.local/multi-tag-test:latest" + let tag3 = "registry.local/multi-tag-test:v1.0.0" + let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) + #expect(output.contains(tag1)) + #expect(output.contains(tag2)) + #expect(output.contains(tag3)) + try f.assertImageBuilt(tag1) + try f.assertImageBuilt(tag2) + try f.assertImageBuilt(tag3) + } + } + + @Test func testBuildAfterContextChange() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let initialContent = "initial".data(using: .utf8)! + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", + context: [ + .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), + .file("bar", content: .data(initialContent)), + ]) + + let image1 = "\(f.testID)-build-context-change:v1" + try f.build(tag: image1, contextDir: dir) + try await f.withContainer(image: image1) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "initial") + } + + let contextBar = dir.appending("context").appending("bar") + try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) + + let image2 = "\(f.testID)-build-context-change:v2" + try f.build(tag: image2, contextDir: dir) + try await f.withContainer(image: image2) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "updated") + } + } + } + + @Test func testBuildWithDockerfileFromStdin() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM scratch\nADD emptyFile /" + try f.createContext( + dir: dir, dockerfile: "", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/stdin-file:\(UUID().uuidString)" + try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) + try f.assertImageBuilt(image) + } + } + + @Test func testLowercaseDockerfile() async throws { + try await ContainerFixture.with { f in + let files: [(String, String, String)] = [ + ("COPY . /app", "copy-uppercase", "COPY"), + ("copy . /app", "copy-lowercase", "copy"), + ("ADD . /app", "add-uppercase", "ADD"), + ("add . /app", "add-lowercase", "add"), + ] + for (instruction, name, _) in files { + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + \(instruction) + RUN test -f /app/testfile.txt + """, + context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) + let image = "registry.local/\(name):\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testRunWithBindMount() 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 + RUN --mount=type=bind,source=.,target=/mnt/context \\ + set -e; \\ + if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ + if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ + cp /mnt/context/app.py /app.py + RUN cat /app.py + """, + context: [ + .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), + .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), + ]) + let image = "registry.local/bind-mount-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + // MARK: - .dockerignore tests + + @Test func testBuildDockerIgnore() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerignore = """ + secret.txt + *.log + **/*.log + !important.log + *.tmp + **/*.tmp + temp/ + node_modules/ + """ + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY . /app + RUN set -e; [ ! -f /app/secret.txt ] || exit 1 + RUN set -e; [ ! -f /app/debug.log ] || exit 1 + RUN set -e; [ -f /app/important.log ] || exit 1 + RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true + RUN set -e; [ ! -d /app/temp ] || exit 1 + RUN set -e; [ ! -d /app/node_modules ] || exit 1 + RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] + """, + context: [ + .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), + .file("secret.txt", content: .data("secret".data(using: .utf8)!)), + .file("debug.log", content: .data("debug".data(using: .utf8)!)), + .file("important.log", content: .data("important".data(using: .utf8)!)), + .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), + .file("main.go", content: .data("package main".data(using: .utf8)!)), + .file("README.md", content: .data("# README".data(using: .utf8)!)), + .directory("temp"), + .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), + .directory("node_modules"), + .directory("src"), + .file("src/app.go", content: .data("package src".data(using: .utf8)!)), + ]) + let image = "registry.local/dockerignore-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testDockerIgnoreBasic() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), + .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]) + try result.check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/ignored.txt") + } + } + } + + @Test func testDockerIgnoreDockerfileSpecific() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .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("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") + try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") + } + } + } + + @Test func testDockerIgnoreOutsideContext() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt") + try f.assertContainerHasFile(name, at: "/app/general.txt") + } + } + } + + @Test func testDockerIgnoreIgnoredDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), + .file("test.txt", content: .data("test\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/Dockerfile") + try f.assertContainerMissingFile(name, at: "/app/.dockerignore") + try f.assertContainerHasFile(name, at: "/app/test.txt") + } + } + } + + @Test func testDockerIgnoreSubdirDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + .file("nested/secret.txt", content: .data("nested secret\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("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") + let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") + } + } + } + + @Test func testDockerIgnoreCustomDockerfileName() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", // no top-level Dockerfile + context: [ + .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("app1 specific\n".data(using: .utf8)!)), + .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app1.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") + try f.assertContainerHasFile(name, at: "/app/generic.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + + @Test func testDockerIgnoreCustomNameSubdir() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), + .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), + .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), + .file("always-included.txt", content: .data("always\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\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") + let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/always-included.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") + } + } + } + + @Test func testDockerIgnoreCoexistingDockerfiles() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .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("df specific\n".data(using: .utf8)!)), + .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") + try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + + @Test func testDockerIgnoreReadonlyContext() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + ]) + try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + + let contextDir = dir.appending("context") + // Make the context read-only, then restore before cleanup. + try FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) + f.addCleanup { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) + } + + let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + } + } + } + + @Test func testNonExistingDockerfile() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" + let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) + #expect(r1.status != 0) + let r2 = try f.run(["build", "-t", image, dir.string]) + #expect(r2.status != 0) + } + } + + // MARK: - Dockerfile ARG quoting + + @Test func testBuildQuotedImageDockerfileArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedStringDockerfileArg() 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\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildForwardReferencedDockerfileArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE="ghcr.io/linuxcontainers/alpine" + ARG IMAGE="${ALPINE}:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedImageBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildQuotedStringBuildArg() 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\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) + try f.assertImageBuilt(image) + } + } + + @Test func testBuildForwardReferencedBuildArg() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE + ARG IMAGE="$ALPINE:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) + try f.assertImageBuilt(image) + } + } + + // MARK: - COPY --from tests + + @Test func testCopyFromLocalImage() async throws { + try await ContainerFixture.with { f in + let baseDir = try f.createTempDir() + let baseName = "local-base:\(UUID().uuidString)" + try f.createContext( + dir: baseDir, + dockerfile: "FROM scratch\nADD hello.txt /hello.txt", + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + try f.build(tag: baseName, contextDir: baseDir) + try f.assertImageBuilt(baseName) + + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") + let image = "registry.local/copy-from-local:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyFromBuildStage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /copied.txt + RUN cat /copied.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-from-stage:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyRenameFromStage() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /renamed.txt + RUN cat /renamed.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-rename:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + + @Test func testCopyMissingFileFails() async throws { + try await ContainerFixture.with { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /does-not-exist.txt /copied.txt + """) + let image = "registry.local/copy-missing:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source file is missing") + } + } + + @Test func testCopyInvalidStageFails() 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\nCOPY --from=not_a_stage /hello.txt /copied.txt") + let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail with invalid stage name") + } + } + + @Test func testCopyFromNonexistentImageFails() 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\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") + let image = "registry.local/copy-bad-image:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source image does not exist") + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift new file mode 100644 index 00000000..5bcb0f2a --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnly.swift @@ -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) + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift deleted file mode 100644 index 0e534979..00000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift +++ /dev/null @@ -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) - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift index 16b64699..a5a8c026 100644 --- a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift @@ -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") } } } diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift new file mode 100644 index 00000000..64151afd --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutput.swift @@ -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") + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift deleted file mode 100644 index 54a012cf..00000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift +++ /dev/null @@ -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") - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift deleted file mode 100644 index 745afc89..00000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift +++ /dev/null @@ -1,1105 +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 Darwin -import Foundation -import Testing - -// Convenience alias for the verbose entry type. -typealias FSEntry = ContainerFixture.FileSystemEntry - -@Suite(.serialized) -struct TestCLIBuilderSerial { - - // MARK: - Basic build tests - - @Test func testBuildDefaultParams() 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") - // No tags — runtime generates one and prints it to stdout. - let output = try f.buildWithPaths(contextDir: dir) - let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) - #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") - try f.assertImageBuilt(generatedTag) - } - } - } - - @Test func testBuildDotFileSucceeds() 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)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ]) - let image = "registry.local/dot-file:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildFromPreviousStage() 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 AS layer1 - RUN sh -c "echo 'layer1' > /layer1.txt" - FROM layer1 - CMD ["cat", "/layer1.txt"] - """) - let image = "registry.local/from-previous-layer:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildFromLocalImage() 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: 0)), - .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), - ]) - let image = "local-only:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - - let dir2 = try f.createTempDir() - try f.createContext( - dir: dir2, - dockerfile: "FROM \(image)", - context: []) - let image2 = "from-local:\(UUID().uuidString)" - try f.build(tag: image2, contextDir: dir2) - try f.assertImageBuilt(image2) - } - } - } - - @Test func testBuildAddFromSpecialDirs() 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 image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildScratchAdd() 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 image = "registry.local/scratch-add:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildAddAll() 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 - ADD . . - RUN cat emptyFile - RUN cat Test/testempty - """, - context: [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/add-all:\(UUID().uuidString)" - let output = try f.build(tag: image, contextDir: dir) - #expect(output.contains(image)) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildArg() 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: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") - let image = "registry.local/build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildSecret() 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 - RUN --mount=type=secret,id=ENV1 \\ - --mount=type=secret,id=env2 \\ - --mount=type=secret,id=env3 \\ - test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" - RUN --mount=type=secret,id=file \\ - awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ - cmp /tmp/foo /run/secrets/file && \\ - rm /tmp/foo - RUN --mount=type=secret,id=empty \\ - ! test -e /run/secrets/file && \\ - test -e /run/secrets/empty && \\ - cmp /dev/null /run/secrets/empty - """) - - setenv("ENV1", "x", 1) - setenv("ENV_VAR", "yy", 1) - setenv("env3", "zzz", 1) - f.addCleanup { - unsetenv("ENV1") - unsetenv("ENV_VAR") - unsetenv("env3") - } - - let testData = Data((0..<17).flatMap { _ in Array(0...255) }) - let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) - let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) - - let image = "registry.local/secrets:\(UUID().uuidString)" - try f.build( - tag: image, contextDir: dir, - otherArgs: [ - "--secret", "id=ENV1", - "--secret", "id=env2,env=ENV_VAR", - "--secret", "id=env3,env=env3", - "--secret", "id=file,src=\(secretFile.string)", - "--secret", "id=empty,src=\(emptyFile.string)", - ]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildNetworkAccess() 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 - ARG HTTP_PROXY - ARG HTTPS_PROXY - ARG NO_PROXY - ARG http_proxy - ARG https_proxy - ARG no_proxy - RUN apk add --no-cache curl - """) - var buildArgs: [String] = [] - for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { - if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } - } - let image = "registry.local/build-network-access:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildDockerfileKeywords() 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: """ - ARG TAG=3.20 - FROM ghcr.io/linuxcontainers/alpine:${TAG} - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN echo "Hello, World!" > /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - CMD ["echo", "Exec default"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - LABEL version="1.0" description="Test image" - FROM ghcr.io/linuxcontainers/alpine:3.20 - EXPOSE 8080 - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENV MY_ENV=hello - RUN echo $MY_ENV > /env.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD emptyFile / - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY toCopy /toCopy - FROM ghcr.io/linuxcontainers/alpine:3.20 - ENTRYPOINT ["echo", "entrypoint!"] - FROM ghcr.io/linuxcontainers/alpine:3.20 - VOLUME /data - FROM ghcr.io/linuxcontainers/alpine:3.20 - RUN adduser -D myuser - USER myuser - CMD whoami - FROM ghcr.io/linuxcontainers/alpine:3.20 - WORKDIR /app - RUN pwd > /pwd.out - FROM ghcr.io/linuxcontainers/alpine:3.20 - ARG MY_VAR=default - RUN echo $MY_VAR > /var.out - """, - context: [ - .file("emptyFile", content: .zeroFilled(size: 1)), - .file("toCopy", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildSymlink() 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 - ADD Test1Source Test1Source - ADD Test1Source2 Test1Source2 - RUN cat Test1Source2/test.yaml - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD Test2Source Test2Source - ADD Test2Source2 Test2Source2 - RUN cat Test2Source2/Test/test.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - ADD Test3Source Test3Source - ADD Test3Source2 Test3Source2 - RUN cat Test3Source2/Dest/test.txt - """ - let context: [FSEntry] = [ - .directory("Test1Source"), .directory("Test1Source2"), - .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), - .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), - .directory("Test2Source"), .directory("Test2Source2"), - .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), - .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), - .directory("Test3Source/Source"), .directory("Test3Source2"), - .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), - .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), - ] - try f.createContext(dir: dir, dockerfile: dockerfile, context: context) - let image = "registry.local/build-symlinks:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildAndRun() 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 \"foobar\" > /file") - let image = "\(f.testID)-build-and-run:latest" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - try await f.withContainer(image: image) { name in - let output = try f.doExec(name, cmd: ["cat", "/file"]) - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == "foobar") - } - } - } - } - - @Test func testBuildDifferentPaths() 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 - RUN ls ./ - COPY . /root - RUN cat /root/Test/test.txt - """, - context: [ - .directory(".git"), - .file(".git/FETCH", content: .zeroFilled(size: 1)), - .directory("Test"), - .file("Test/test.txt", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/build-diff-context:\(UUID().uuidString)" - try f.buildWithPaths(tags: [image], contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildMultiArch() 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 - ADD . . - RUN cat emptyFile - RUN cat Test/testempty - """, - context: [ - .directory("Test"), - .file("Test/testempty", content: .zeroFilled(size: 1)), - .file("emptyFile", content: .zeroFilled(size: 1)), - ]) - let image = "registry.local/multi-arch:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) - try f.assertImageBuilt(image) - - let output = try f.doInspectImages(image) - #expect(output.count == 1, "expected single inspect result") - let archs = Set(output[0].variants.map { $0.platform.architecture }) - #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") - } - } - } - - @Test func testBuildMultipleTags() 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 uuid = UUID().uuidString - let tag1 = "registry.local/multi-tag-test:\(uuid)" - let tag2 = "registry.local/multi-tag-test:latest" - let tag3 = "registry.local/multi-tag-test:v1.0.0" - let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) - #expect(output.contains(tag1)) - #expect(output.contains(tag2)) - #expect(output.contains(tag3)) - try f.assertImageBuilt(tag1) - try f.assertImageBuilt(tag2) - try f.assertImageBuilt(tag3) - } - } - } - - @Test func testBuildAfterContextChange() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let initialContent = "initial".data(using: .utf8)! - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", - context: [ - .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), - .file("bar", content: .data(initialContent)), - ]) - - let image1 = "\(f.testID)-build-context-change:v1" - try f.build(tag: image1, contextDir: dir) - try await f.withContainer(image: image1) { name in - let out = try f.doExec(name, cmd: ["cat", "/bar"]) - #expect(out == "initial") - } - - let contextBar = dir.appending("context").appending("bar") - try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) - - let image2 = "\(f.testID)-build-context-change:v2" - try f.build(tag: image2, contextDir: dir) - try await f.withContainer(image: image2) { name in - let out = try f.doExec(name, cmd: ["cat", "/bar"]) - #expect(out == "updated") - } - } - } - } - - @Test func testBuildWithDockerfileFromStdin() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerfile = "FROM scratch\nADD emptyFile /" - try f.createContext( - dir: dir, dockerfile: "", - context: [.file("emptyFile", content: .zeroFilled(size: 1))]) - let image = "registry.local/stdin-file:\(UUID().uuidString)" - try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) - try f.assertImageBuilt(image) - } - } - } - - @Test func testLowercaseDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let files: [(String, String, String)] = [ - ("COPY . /app", "copy-uppercase", "COPY"), - ("copy . /app", "copy-lowercase", "copy"), - ("ADD . /app", "add-uppercase", "ADD"), - ("add . /app", "add-lowercase", "add"), - ] - for (instruction, name, _) in files { - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - \(instruction) - RUN test -f /app/testfile.txt - """, - context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) - let image = "registry.local/\(name):\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - } - - @Test func testRunWithBindMount() 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 - RUN --mount=type=bind,source=.,target=/mnt/context \\ - set -e; \\ - if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ - if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ - cp /mnt/context/app.py /app.py - RUN cat /app.py - """, - context: [ - .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), - .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), - ]) - let image = "registry.local/bind-mount-test:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - // MARK: - .dockerignore tests - - @Test func testBuildDockerIgnore() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let dockerignore = """ - secret.txt - *.log - **/*.log - !important.log - *.tmp - **/*.tmp - temp/ - node_modules/ - """ - try f.createContext( - dir: dir, - dockerfile: """ - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY . /app - RUN set -e; [ ! -f /app/secret.txt ] || exit 1 - RUN set -e; [ ! -f /app/debug.log ] || exit 1 - RUN set -e; [ -f /app/important.log ] || exit 1 - RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true - RUN set -e; [ ! -d /app/temp ] || exit 1 - RUN set -e; [ ! -d /app/node_modules ] || exit 1 - RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] - """, - context: [ - .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), - .file("secret.txt", content: .data("secret".data(using: .utf8)!)), - .file("debug.log", content: .data("debug".data(using: .utf8)!)), - .file("important.log", content: .data("important".data(using: .utf8)!)), - .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), - .file("main.go", content: .data("package main".data(using: .utf8)!)), - .file("README.md", content: .data("# README".data(using: .utf8)!)), - .directory("temp"), - .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), - .directory("node_modules"), - .directory("src"), - .file("src/app.go", content: .data("package src".data(using: .utf8)!)), - ]) - let image = "registry.local/dockerignore-test:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testDockerIgnoreBasic() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, - dockerfile: dockerfile, - context: [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), - .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]) - try result.check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/ignored.txt") - } - } - } - } - - @Test func testDockerIgnoreDockerfileSpecific() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .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("general\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") - try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") - } - } - } - } - - @Test func testDockerIgnoreOutsideContext() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), - .file("general.txt", content: .data("general\n".data(using: .utf8)!)), - .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), - ]) - try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) - let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" - try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/specific.txt") - try f.assertContainerHasFile(name, at: "/app/general.txt") - } - } - } - } - - @Test func testDockerIgnoreIgnoredDockerfile() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), - .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), - .file("test.txt", content: .data("test\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/Dockerfile") - try f.assertContainerMissingFile(name, at: "/app/.dockerignore") - try f.assertContainerHasFile(name, at: "/app/test.txt") - } - } - } - } - - @Test func testDockerIgnoreSubdirDockerfile() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), - .file("nested/secret.txt", content: .data("nested secret\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("config\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") - let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" - try f.run([ - "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/secret.txt") - try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") - try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") - } - } - } - } - - @Test func testDockerIgnoreCustomDockerfileName() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", // no top-level Dockerfile - context: [ - .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("app1 specific\n".data(using: .utf8)!)), - .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("app1.Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") - try f.assertContainerHasFile(name, at: "/app/generic.txt") - try f.assertContainerHasFile(name, at: "/app/included.txt") - } - } - } - } - - @Test func testDockerIgnoreCustomNameSubdir() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", - context: [ - .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), - .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), - .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), - .file("always-included.txt", content: .data("always\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\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") - let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" - try f.run([ - "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") - try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") - try f.assertContainerHasFile(name, at: "/app/always-included.txt") - try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") - } - } - } - } - - @Test func testDockerIgnoreCoexistingDockerfiles() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: "", - context: [ - .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("df specific\n".data(using: .utf8)!)), - .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - ]) - let contextDir = dir.appending("context") - let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" - try f.run([ - "build", "-f", contextDir.appending("app.Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") - try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") - try f.assertContainerHasFile(name, at: "/app/included.txt") - } - } - } - } - - @Test func testDockerIgnoreReadonlyContext() 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\nWORKDIR /app\nCOPY . ." - try f.createContext( - dir: dir, dockerfile: dockerfile, - context: [ - .file("included.txt", content: .data("included\n".data(using: .utf8)!)), - .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), - ]) - try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) - - let contextDir = dir.appending("context") - // Make the context read-only, then restore before cleanup. - try FileManager.default.setAttributes( - [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) - f.addCleanup { - try? FileManager.default.setAttributes( - [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) - } - - let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" - try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, contextDir.string, - ]).check() - try await f.withContainer(image: image, tag: "c") { name in - try f.assertContainerHasFile(name, at: "/app/included.txt") - try f.assertContainerMissingFile(name, at: "/app/secret.txt") - } - } - } - } - - @Test func testNonExistingDockerfile() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let dir = try f.createTempDir() - let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" - let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) - #expect(r1.status != 0) - let r2 = try f.run(["build", "-t", image, dir.string]) - #expect(r2.status != 0) - } - } - } - - @Test func testBuildNoCachePullLatestImage() 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 \(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) - } - } - } - - // MARK: - Dockerfile ARG quoting - - @Test func testBuildQuotedImageDockerfileArg() 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: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") - let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedStringDockerfileArg() 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\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") - let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildForwardReferencedDockerfileArg() 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: """ - ARG ALPINE="ghcr.io/linuxcontainers/alpine" - ARG IMAGE="${ALPINE}:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """) - let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedImageBuildArg() 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: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") - let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildQuotedStringBuildArg() 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\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") - let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) - try f.assertImageBuilt(image) - } - } - } - - @Test func testBuildForwardReferencedBuildArg() 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: """ - ARG ALPINE - ARG IMAGE="$ALPINE:3.20" - FROM $IMAGE - RUN test -f /etc/alpine-release - """) - let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) - try f.assertImageBuilt(image) - } - } - } - - // MARK: - COPY --from tests - - @Test func testCopyFromLocalImage() async throws { - try await ContainerFixture.with { f in - try await f.withBuilder { f in - let baseDir = try f.createTempDir() - let baseName = "local-base:\(UUID().uuidString)" - try f.createContext( - dir: baseDir, - dockerfile: "FROM scratch\nADD hello.txt /hello.txt", - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - try f.build(tag: baseName, contextDir: baseDir) - try f.assertImageBuilt(baseName) - - let dir = try f.createTempDir() - try f.createContext( - dir: dir, - dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") - let image = "registry.local/copy-from-local:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyFromBuildStage() 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 AS builder - ADD hello.txt /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /hello.txt /copied.txt - RUN cat /copied.txt - """, - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - let image = "registry.local/copy-from-stage:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyRenameFromStage() 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 AS builder - ADD hello.txt /hello.txt - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /hello.txt /renamed.txt - RUN cat /renamed.txt - """, - context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) - let image = "registry.local/copy-rename:\(UUID().uuidString)" - try f.build(tag: image, contextDir: dir) - try f.assertImageBuilt(image) - } - } - } - - @Test func testCopyMissingFileFails() 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 AS builder - FROM ghcr.io/linuxcontainers/alpine:3.20 - COPY --from=builder /does-not-exist.txt /copied.txt - """) - let image = "registry.local/copy-missing:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail when source file is missing") - } - } - } - - @Test func testCopyInvalidStageFails() 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\nCOPY --from=not_a_stage /hello.txt /copied.txt") - let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail with invalid stage name") - } - } - } - - @Test func testCopyFromNonexistentImageFails() 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\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") - let image = "registry.local/copy-bad-image:\(UUID().uuidString)" - let result = try f.run([ - "build", "-f", dir.appending("Dockerfile").string, - "-t", image, dir.appending("context").string, - ]) - #expect(result.status != 0, "build should fail when source image does not exist") - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift new file mode 100644 index 00000000..888e0d49 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderTarExport.swift @@ -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")) + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift deleted file mode 100644 index 9952c90a..00000000 --- a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift +++ /dev/null @@ -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")) - } - } - } -} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift new file mode 100644 index 00000000..c923295f --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderWarmupPullSerial.swift @@ -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) + } + } +}